From 83f2b6f67a5245214f4bb365ff55f7dcba9ce7fa Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Sun, 11 Oct 2026 19:58:32 +0200 Subject: [PATCH] refactor(console): make Zen routes a thin inference proxy (#54563) --- bun.lock | 3 - infra/console.ts | 38 - infra/secret.ts | 2 - packages/console/app/package.json | 1 - .../console/app/src/lib/inference-proxy.ts | 3 +- .../console/app/src/lib/request-country.ts | 38 - .../console/app/src/routes/stripe/webhook.ts | 399 ----- .../routes/workspace/[id]/go/lite-section.tsx | 2 - .../app/src/routes/workspace/common.tsx | 4 +- .../src/routes/zen/go/v1/chat/completions.ts | 4 - .../app/src/routes/zen/go/v1/messages.ts | 4 - .../app/src/routes/zen/go/v1/models.ts | 6 +- .../app/src/routes/zen/go/v1/responses.ts | 4 - .../app/src/routes/zen/go/v1/systemone.ts | 3 - .../console/app/src/routes/zen/go/v1/usage.ts | 155 +- .../console/app/src/routes/zen/util/error.ts | 29 - .../app/src/routes/zen/util/handler.ts | 1334 +---------------- .../app/src/routes/zen/util/ipRateLimiter.ts | 61 - .../app/src/routes/zen/util/keyRateLimiter.ts | 37 - .../console/app/src/routes/zen/util/logger.ts | 12 - .../src/routes/zen/util/modelTpmLimiter.ts | 47 - .../src/routes/zen/util/modelTpsLimiter.ts | 88 -- .../app/src/routes/zen/util/modelsHandler.ts | 21 - .../app/src/routes/zen/util/pricing.ts | 11 - .../src/routes/zen/util/provider/anthropic.ts | 759 ---------- .../src/routes/zen/util/provider/google.ts | 75 - .../zen/util/provider/openai-compatible.ts | 555 ------- .../src/routes/zen/util/provider/openai.ts | 633 -------- .../src/routes/zen/util/provider/provider.ts | 228 --- .../src/routes/zen/util/provider/systemone.ts | 30 - .../routes/zen/util/providerBudgetTracker.ts | 150 -- .../console/app/src/routes/zen/util/redis.ts | 26 - .../routes/zen/util/stickyProviderTracker.ts | 46 - .../src/routes/zen/util/trainingConsent.ts | 3 - .../app/src/routes/zen/util/trialLimiter.ts | 46 - .../app/src/routes/zen/util/usageBatcher.ts | 31 - .../app/src/routes/zen/util/variant.ts | 21 - .../app/src/routes/zen/v1/chat/completions.ts | 4 - .../console/app/src/routes/zen/v1/messages.ts | 4 - .../console/app/src/routes/zen/v1/models.ts | 37 +- .../app/src/routes/zen/v1/models/[model].ts | 8 +- .../app/src/routes/zen/v1/responses.ts | 4 - .../app/src/routes/zen/v1/systemone.ts | 3 - .../console/app/test/museSparkPolicy.test.ts | 31 - packages/console/app/test/pricing.test.ts | 16 - .../console/app/test/providerUsage.test.ts | 126 -- packages/console/app/test/rateLimiter.test.ts | 19 - sst-env.d.ts | 12 - 48 files changed, 53 insertions(+), 5120 deletions(-) delete mode 100644 packages/console/app/src/routes/stripe/webhook.ts delete mode 100644 packages/console/app/src/routes/zen/util/error.ts delete mode 100644 packages/console/app/src/routes/zen/util/ipRateLimiter.ts delete mode 100644 packages/console/app/src/routes/zen/util/keyRateLimiter.ts delete mode 100644 packages/console/app/src/routes/zen/util/logger.ts delete mode 100644 packages/console/app/src/routes/zen/util/modelTpmLimiter.ts delete mode 100644 packages/console/app/src/routes/zen/util/modelTpsLimiter.ts delete mode 100644 packages/console/app/src/routes/zen/util/pricing.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/anthropic.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/google.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/openai-compatible.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/openai.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/provider.ts delete mode 100644 packages/console/app/src/routes/zen/util/provider/systemone.ts delete mode 100644 packages/console/app/src/routes/zen/util/providerBudgetTracker.ts delete mode 100644 packages/console/app/src/routes/zen/util/redis.ts delete mode 100644 packages/console/app/src/routes/zen/util/stickyProviderTracker.ts delete mode 100644 packages/console/app/src/routes/zen/util/trainingConsent.ts delete mode 100644 packages/console/app/src/routes/zen/util/trialLimiter.ts delete mode 100644 packages/console/app/src/routes/zen/util/usageBatcher.ts delete mode 100644 packages/console/app/src/routes/zen/util/variant.ts delete mode 100644 packages/console/app/test/museSparkPolicy.test.ts delete mode 100644 packages/console/app/test/pricing.test.ts delete mode 100644 packages/console/app/test/providerUsage.test.ts delete mode 100644 packages/console/app/test/rateLimiter.test.ts diff --git a/bun.lock b/bun.lock index 5cd9a1b5495..9cf565894d5 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,6 @@ "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@stripe/stripe-js": "8.6.1", - "@upstash/redis": "1.38.0", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "number-flow": "0.6.2", @@ -3275,8 +3274,6 @@ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], - "@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], diff --git a/infra/console.ts b/infra/console.ts index 9b94afe90d3..5d75e03e8a5 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -69,38 +69,6 @@ export const auth = new sst.cloudflare.Worker("AuthApi", { // GATEWAY //////////////// -export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", { - url: $interpolate`https://${domain}/stripe/webhook`, - enabledEvents: [ - "checkout.session.async_payment_failed", - "checkout.session.async_payment_succeeded", - "checkout.session.completed", - "checkout.session.expired", - "charge.refunded", - "invoice.payment_succeeded", - "invoice.payment_failed", - "invoice.payment_action_required", - "customer.created", - "customer.deleted", - "customer.updated", - "customer.discount.created", - "customer.discount.deleted", - "customer.discount.updated", - "customer.source.created", - "customer.source.deleted", - "customer.source.expiring", - "customer.source.updated", - "customer.subscription.created", - "customer.subscription.deleted", - "customer.subscription.paused", - "customer.subscription.pending_update_applied", - "customer.subscription.pending_update_expired", - "customer.subscription.resumed", - "customer.subscription.trial_will_end", - "customer.subscription.updated", - ], -}) - const zenLiteProduct = new stripe.Product("ZenLite", { name: "OpenCode Go", }) @@ -229,9 +197,6 @@ const consoleMigration = new sst.Linkable("ConsoleMigration", { inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "", }, }) -const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { - properties: { value: stripeWebhook.secret }, -}) //////////////// // CONSOLE @@ -261,11 +226,8 @@ new sst.cloudflare.x.SolidStart("Console", { bucket, bucketNew, database, - SECRET.UpstashRedisRestUrl, - SECRET.UpstashRedisRestToken, AUTH_API_URL, consoleMigration, - STRIPE_WEBHOOK_SECRET, SECRET.SupportApiKey, DISCORD_INCIDENT_WEBHOOK_URL, SECRET.HoneycombWebhookSecret, diff --git a/infra/secret.ts b/infra/secret.ts index 2df3e607764..7974fc75c98 100644 --- a/infra/secret.ts +++ b/infra/secret.ts @@ -10,6 +10,4 @@ export const SECRET = { HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"), HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }), SupportApiKey: new sst.Secret("SUPPORT_API_KEY"), - UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"), - UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"), } diff --git a/packages/console/app/package.json b/packages/console/app/package.json index c311daa3b02..35958237182 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -26,7 +26,6 @@ "@solidjs/router": "catalog:", "@solidjs/start": "catalog:", "@stripe/stripe-js": "8.6.1", - "@upstash/redis": "1.38.0", "chart.js": "4.5.1", "nitro": "3.0.1-alpha.1", "number-flow": "0.6.2", diff --git a/packages/console/app/src/lib/inference-proxy.ts b/packages/console/app/src/lib/inference-proxy.ts index 608331ea444..f11b9a911f2 100644 --- a/packages/console/app/src/lib/inference-proxy.ts +++ b/packages/console/app/src/lib/inference-proxy.ts @@ -44,7 +44,8 @@ export async function proxyInference( ? request.headers.get("x-goog-api-key") : request.headers.get("authorization")?.split(" ")[1] const anonymous = !key || key === "public" - if (anonymous && !generation?.anonymous) return undefined + // The destination answers keyless reads itself; keyless generation is only forwarded for free models. + if (anonymous && request.method !== "GET" && !generation?.anonymous) return undefined // New Console keys are never in the legacy key table; every legacy key is `sk-`. const legacy = !anonymous && !key.startsWith("oc_sk_") diff --git a/packages/console/app/src/lib/request-country.ts b/packages/console/app/src/lib/request-country.ts index 5806fc2f64a..a4e39eb6071 100644 --- a/packages/console/app/src/lib/request-country.ts +++ b/packages/console/app/src/lib/request-country.ts @@ -1,43 +1,5 @@ -const MUSE_SPARK_BLOCKED_COUNTRIES = new Set([ - "AF", - "BY", - "CN", - "CU", - "EH", - "ER", - "ET", - "HK", - "HT", - "IQ", - "IR", - "KH", - "KP", - "LY", - "MM", - "MO", - "NI", - "PK", - "RU", - "SO", - "SY", - "VE", -]) - export function countryFromRequest(request: Request | undefined) { if (!request) return undefined const cloudflareRequest = request as Request & { cf?: { country?: string } } return cloudflareRequest.cf?.country ?? request.headers.get("cf-ipcountry") ?? undefined } - -export function isModelCountryRestricted(model: string, country: string | undefined) { - return ( - [ - "muse-spark-1.3-contributor", - "muse-spark-1.3-contributor-free", - "muse-spark-1.2-contributor", - "muse-spark-1.2-contributor-free", - ].includes(model) && - country !== undefined && - MUSE_SPARK_BLOCKED_COUNTRIES.has(country.toUpperCase()) - ) -} diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts deleted file mode 100644 index c68aaa8ed30..00000000000 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ /dev/null @@ -1,399 +0,0 @@ -import type { Stripe } from "stripe" -import { Billing } from "@opencode/console-core/billing.js" -import type { APIEvent } from "@solidjs/start/server" -import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js" -import { BillingTable, LiteTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js" -import { Identifier } from "@opencode/console-core/identifier.js" -import { centsToMicroCents } from "@opencode/console-core/util/price.js" -import { Actor } from "@opencode/console-core/actor.js" -import { Resource } from "@opencode/console-resource" -import { LiteData } from "@opencode/console-core/lite.js" -import { BlackData } from "@opencode/console-core/black.js" -import { Referral } from "@opencode/console-core/referral.js" - -export async function POST(input: APIEvent) { - const body = await Billing.stripe().webhooks.constructEventAsync( - await input.request.text(), - input.request.headers.get("stripe-signature")!, - Resource.STRIPE_WEBHOOK_SECRET.value, - ) - console.log(body.type, JSON.stringify(body, null, 2)) - - return (async () => { - if (body.type === "customer.updated") { - // check default payment method changed - const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {} - if (!("default_payment_method" in prevInvoiceSettings)) return "ignored" - - const customerID = body.data.object.id - const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string - - if (!customerID) throw new Error("Customer ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") - - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) - await Database.use(async (tx) => { - await tx - .update(BillingTable) - .set({ - paymentMethodID, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .where(eq(BillingTable.customerID, customerID)) - }) - } - if (body.type === "checkout.session.completed" && body.data.object.mode === "payment") { - const workspaceID = body.data.object.metadata?.workspaceID - const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) - const customerID = body.data.object.customer as string - const paymentID = body.data.object.payment_intent as string - const invoiceID = body.data.object.invoice as string - - if (!workspaceID) throw new Error("Workspace ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!amountInCents) throw new Error("Amount not found") - if (!paymentID) throw new Error("Payment ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - - await Actor.provide("system", { workspaceID }, async () => { - const customer = await Billing.get() - if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") - - // set customer metadata - if (!customer?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { - workspaceID, - }, - }) - } - - // get payment method for the payment intent - const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, { - expand: ["payment_method"], - }) - const paymentMethod = paymentIntent.payment_method - if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded") - - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, - customerID, - paymentMethodID: paymentMethod.id, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - // enable reload if first time enabling billing - ...(customer?.customerID - ? {} - : { - reloadError: null, - timeReloadError: null, - }), - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - await tx.insert(PaymentTable).values({ - workspaceID, - id: Identifier.create("payment"), - amount: centsToMicroCents(amountInCents), - paymentID, - invoiceID, - customerID, - }) - }) - }) - } - if (body.type === "customer.subscription.created") { - const type = body.data.object.metadata?.type - if (type === "lite") { - const workspaceID = body.data.object.metadata?.workspaceID - const userID = body.data.object.metadata?.userID - const userEmail = body.data.object.metadata?.userEmail - const coupon = body.data.object.metadata?.coupon - const customerID = body.data.object.customer as string - const invoiceID = body.data.object.latest_invoice as string - const subscriptionID = body.data.object.id as string - const paymentMethodID = body.data.object.default_payment_method as string - - if (!workspaceID) throw new Error("Workspace ID not found") - if (!userID) throw new Error("User ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - if (!subscriptionID) throw new Error("Subscription ID not found") - if (!paymentMethodID) throw new Error("Payment method ID not found") - - // get payment method for the payment intent - const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID) - await Actor.provide("system", { workspaceID }, async () => { - // look up current billing - const billing = await Billing.get() - if (!billing) throw new Error(`Workspace with ID ${workspaceID} not found`) - if (billing.customerID && billing.customerID !== customerID) throw new Error("Customer ID mismatch") - - // set customer metadata - if (!billing?.customerID) { - await Billing.stripe().customers.update(customerID, { - metadata: { - workspaceID, - }, - }) - } - - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - customerID, - liteSubscriptionID: subscriptionID, - lite: {}, - paymentMethodID: paymentMethod.id, - paymentMethodLast4: paymentMethod.card?.last4 ?? null, - paymentMethodType: paymentMethod.type, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - - await tx.insert(LiteTable).values({ - workspaceID, - id: Identifier.create("lite"), - userID: userID, - }) - - if (userEmail) { - if (coupon === LiteData.firstMonth50Coupon) { - await Billing.redeemCoupon(userEmail, "GO1MONTH50") - } else if (coupon === LiteData.firstMonth100Coupon) { - await Billing.redeemCoupon(userEmail, "GOFREEMONTH") - } else if (coupon === LiteData.threeMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO3MONTHS100") - } else if (coupon === LiteData.sixMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO6MONTHS100") - } else if (coupon === LiteData.twelveMonths100Coupon) { - await Billing.redeemCoupon(userEmail, "GO12MONTHS100") - } - } - }) - - await Referral.completeFromLiteSubscription({ - workspaceID, - userID, - }).catch((error) => { - console.error("Referral sync failed", error) - }) - }) - } - } - if (body.type === "customer.subscription.updated" && body.data.object.status === "incomplete_expired") { - const subscriptionID = body.data.object.id - if (!subscriptionID) throw new Error("Subscription ID not found") - - const productID = body.data.object.items.data[0].price.product as string - if (productID === LiteData.productID()) { - await Billing.unsubscribeLite({ subscriptionID }) - } else if (productID === BlackData.productID()) { - await Billing.unsubscribeBlack({ subscriptionID }) - } - } - if (body.type === "customer.subscription.updated") { - // Black is retired: no subscription may renew, so undo any renewal made in the billing portal. - const subscription = body.data.object - if (subscription.items.data[0].price.product !== BlackData.productID()) return "ignored" - if (!["active", "trialing", "past_due"].includes(subscription.status)) return "ignored" - if (subscription.cancel_at_period_end || subscription.cancel_at) return "ignored" - - await Billing.stripe().subscriptions.update(subscription.id, { - cancel_at_period_end: true, - cancellation_details: { comment: "Legacy Black retirement: renewal is not allowed" }, - }) - } - if (body.type === "customer.subscription.deleted") { - const subscriptionID = body.data.object.id - if (!subscriptionID) throw new Error("Subscription ID not found") - - const productID = body.data.object.items.data[0].price.product as string - if (productID === LiteData.productID()) { - await Billing.unsubscribeLite({ subscriptionID }) - } else if (productID === BlackData.productID()) { - await Billing.unsubscribeBlack({ subscriptionID }) - } - - const latestInvoice = body.data.object.latest_invoice - const invoiceID = typeof latestInvoice === "string" ? latestInvoice : latestInvoice?.id - if (invoiceID) { - const invoice = await Billing.stripe().invoices.retrieve(invoiceID) - if (invoice.status === "open") await Billing.stripe().invoices.voidInvoice(invoiceID) - } - } - if (body.type === "invoice.payment_succeeded") { - if ( - body.data.object.billing_reason === "subscription_create" || - body.data.object.billing_reason === "subscription_cycle" - ) { - const invoiceID = body.data.object.id as string - const amountInCents = body.data.object.amount_paid - const customerID = body.data.object.customer as string - const subscriptionID = body.data.object.parent?.subscription_details?.subscription as string - const productID = body.data.object.lines?.data[0].pricing?.price_details?.product as string - - if (!customerID) throw new Error("Customer ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - if (!subscriptionID) throw new Error("Subscription ID not found") - - // get coupon id from subscription - const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { - expand: ["discounts", "payments"], - }) - const paymentID = invoice.payments?.data[0]?.payment.payment_intent as string - const couponID = (invoice.discounts[0] as Stripe.Discount)?.coupon?.id as string - if (!paymentID) { - // payment id can be undefined when using coupon - if (!couponID) throw new Error("Payment ID not found") - } - - const workspaceID = await Database.use((tx) => - tx - .select({ workspaceID: BillingTable.workspaceID }) - .from(BillingTable) - .where(eq(BillingTable.customerID, customerID)) - .then((rows) => rows[0]?.workspaceID), - ) - if (!workspaceID) throw new Error("Workspace ID not found for customer") - - await Database.use((tx) => - tx.insert(PaymentTable).values({ - workspaceID, - id: Identifier.create("payment"), - amount: centsToMicroCents(amountInCents), - paymentID, - invoiceID, - customerID, - enrichment: { - type: productID === LiteData.productID() ? "lite" : "subscription", - currency: body.data.object.currency === "inr" ? "inr" : undefined, - couponID, - }, - }), - ) - } else if (body.data.object.billing_reason === "manual") { - const workspaceID = body.data.object.metadata?.workspaceID - const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount) - const invoiceID = body.data.object.id as string - const customerID = body.data.object.customer as string - - if (!workspaceID) throw new Error("Workspace ID not found") - if (!customerID) throw new Error("Customer ID not found") - if (!amountInCents) throw new Error("Amount not found") - if (!invoiceID) throw new Error("Invoice ID not found") - - await Actor.provide("system", { workspaceID }, async () => { - // get payment id from invoice - const invoice = await Billing.stripe().invoices.retrieve(invoiceID, { - expand: ["payments"], - }) - await Database.transaction(async (tx) => { - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`, - reloadError: null, - timeReloadError: null, - }) - .where(eq(BillingTable.workspaceID, Actor.workspace())) - await tx.insert(PaymentTable).values({ - workspaceID: Actor.workspace(), - id: Identifier.create("payment"), - amount: centsToMicroCents(amountInCents), - invoiceID, - paymentID: invoice.payments?.data[0].payment.payment_intent as string, - customerID, - }) - }) - }) - } - } - if (body.type === "invoice.payment_failed" || body.type === "invoice.payment_action_required") { - if (body.data.object.billing_reason === "manual") { - const workspaceID = body.data.object.metadata?.workspaceID - const invoiceID = body.data.object.id - - if (!workspaceID) throw new Error("Workspace ID not found") - if (!invoiceID) throw new Error("Invoice ID not found") - - const paymentIntent = await Billing.stripe().paymentIntents.retrieve(invoiceID) - console.log(JSON.stringify(paymentIntent)) - const errorMessage = - typeof paymentIntent === "object" && paymentIntent !== null - ? paymentIntent.last_payment_error?.message - : undefined - - await Actor.provide("system", { workspaceID }, async () => { - await Database.use((tx) => - tx - .update(BillingTable) - .set({ - reload: false, - reloadError: errorMessage ?? "workspace.reload.error.paymentFailed", - timeReloadError: sql`now()`, - }) - .where(eq(BillingTable.workspaceID, Actor.workspace())), - ) - }) - } - } - if (body.type === "charge.refunded") { - const customerID = body.data.object.customer as string - const paymentIntentID = body.data.object.payment_intent as string - if (!customerID) throw new Error("Customer ID not found") - if (!paymentIntentID) throw new Error("Payment ID not found") - - const workspaceID = await Database.use((tx) => - tx - .select({ - workspaceID: BillingTable.workspaceID, - }) - .from(BillingTable) - .where(eq(BillingTable.customerID, customerID)) - .then((rows) => rows[0]?.workspaceID), - ) - if (!workspaceID) throw new Error("Workspace ID not found") - - const payment = await Database.use((tx) => - tx - .select({ - amount: PaymentTable.amount, - enrichment: PaymentTable.enrichment, - }) - .from(PaymentTable) - .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - .then((rows) => rows[0]), - ) - if (!payment) throw new Error("Payment not found") - - await Database.transaction(async (tx) => { - await tx - .update(PaymentTable) - .set({ - timeRefunded: new Date(body.created * 1000), - }) - .where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID))) - - // deduct balance only for top up - if (!payment.enrichment?.type) { - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${payment.amount}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - } - }) - } - })() - .then((message) => { - return Response.json({ message: message ?? "done" }, { status: 200 }) - }) - .catch((error: any) => { - return Response.json({ message: error.message }, { status: 500 }) - }) -} diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 319bed83e98..8a68f5ab424 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -24,7 +24,6 @@ import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { createReferralFromCookie } from "~/lib/referral-invite" import { getRequestEvent } from "solid-js/web" import { countryFromRequest } from "~/lib/request-country" -import { checkCheckoutRateLimit } from "~/routes/zen/util/redis" import { IconAlipay, IconChevron, IconUpi } from "~/component/icon" import { buildLiteUsageBreakdown, getModelQuotaLimit, getUsagePercent } from "~/lib/lite-usage" @@ -220,7 +219,6 @@ const createLiteCheckoutUrl = action( "use server" return json( await withActor(async () => { - await checkCheckoutRateLimit(Actor.account()) const data = await Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) await createReferralFromCookie() return { error: undefined, data } diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index 47e6aa55571..e514ac9de97 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -6,7 +6,6 @@ import { Billing } from "@opencode/console-core/billing.js" import { and, Database, desc, eq, isNull } from "@opencode/console-core/drizzle/index.js" import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" import { UserTable } from "@opencode/console-core/schema/user.sql.js" -import { checkCheckoutRateLimit } from "~/routes/zen/util/redis" export function formatDateForTable(date: Date) { const options: Intl.DateTimeFormatOptions = { @@ -79,8 +78,7 @@ export const createCheckoutUrl = action( return json( await withActor( () => - checkCheckoutRateLimit(Actor.account()) - .then(() => Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })) + Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl }) .then((data) => ({ error: undefined, data })) .catch((e) => ({ error: e.message as string, diff --git a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts index 71fb8f2e6db..5e5e0bc6a66 100644 --- a/packages/console/app/src/routes/zen/go/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/go/v1/chat/completions.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "oa-compat", modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseOpenAiVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/messages.ts b/packages/console/app/src/routes/zen/go/v1/messages.ts index d356b0bf53f..d8929eebac8 100644 --- a/packages/console/app/src/routes/zen/go/v1/messages.ts +++ b/packages/console/app/src/routes/zen/go/v1/messages.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "anthropic", modelList: "lite", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseAnthropicVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/models.ts b/packages/console/app/src/routes/zen/go/v1/models.ts index bde6359586a..4d949be13f0 100644 --- a/packages/console/app/src/routes/zen/go/v1/models.ts +++ b/packages/console/app/src/routes/zen/go/v1/models.ts @@ -1,6 +1,5 @@ import type { APIEvent } from "@solidjs/start/server" -import { ZenData } from "@opencode/console-core/model.js" -import { buildModelsResponse, buildOptionsResponse } from "../../util/modelsHandler" +import { buildOptionsResponse } from "../../util/modelsHandler" import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy" export async function OPTIONS(_input: APIEvent) { @@ -10,6 +9,5 @@ export async function OPTIONS(_input: APIEvent) { export async function GET(input: APIEvent) { const response = await proxyInference(input.request).catch(inferenceUnavailable) if (response) return response - const models = Object.keys(ZenData.list("lite").models) - return buildModelsResponse(models) + return Response.json({ type: "error", error: { type: "AuthError", message: "Invalid API key." } }, { status: 401 }) } diff --git a/packages/console/app/src/routes/zen/go/v1/responses.ts b/packages/console/app/src/routes/zen/go/v1/responses.ts index 54f5b6bfebc..cb9018ec3c2 100644 --- a/packages/console/app/src/routes/zen/go/v1/responses.ts +++ b/packages/console/app/src/routes/zen/go/v1/responses.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "openai", modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseOpenAiVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/systemone.ts b/packages/console/app/src/routes/zen/go/v1/systemone.ts index 4b9954fb2a7..a3526867f98 100644 --- a/packages/console/app/src/routes/zen/go/v1/systemone.ts +++ b/packages/console/app/src/routes/zen/go/v1/systemone.ts @@ -6,8 +6,5 @@ export function POST(input: APIEvent) { format: "systemone", modelList: "lite", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: () => undefined, - parseIsStream: () => false, }) } diff --git a/packages/console/app/src/routes/zen/go/v1/usage.ts b/packages/console/app/src/routes/zen/go/v1/usage.ts index 33fa2ad354a..1ecb81a6ca1 100644 --- a/packages/console/app/src/routes/zen/go/v1/usage.ts +++ b/packages/console/app/src/routes/zen/go/v1/usage.ts @@ -1,161 +1,8 @@ import type { APIEvent } from "@solidjs/start/server" -import { and, Database, eq, isNull } from "@opencode/console-core/drizzle/index.js" -import { BillingTable, LiteTable } from "@opencode/console-core/schema/billing.sql.js" -import { KeyTable } from "@opencode/console-core/schema/key.sql.js" -import { UserTable } from "@opencode/console-core/schema/user.sql.js" -import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" -import { LiteData } from "@opencode/console-core/lite.js" -import { Subscription } from "@opencode/console-core/subscription.js" import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy" export async function GET(input: APIEvent) { const response = await proxyInference(input.request).catch(inferenceUnavailable) if (response) return response - const apiKey = input.request.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] - - if (!apiKey) { - return new Response( - JSON.stringify({ - type: "error", - error: { - type: "AuthError", - message: "Missing API key.", - }, - }), - { - status: 401, - headers: { - "Content-Type": "application/json", - }, - }, - ) - } - - const auth = await Database.use((tx) => - tx - .select({ - userID: KeyTable.userID, - workspaceID: KeyTable.workspaceID, - }) - .from(KeyTable) - .innerJoin( - UserTable, - and( - eq(UserTable.workspaceID, KeyTable.workspaceID), - eq(UserTable.id, KeyTable.userID), - isNull(UserTable.timeDeleted), - ), - ) - .innerJoin(WorkspaceTable, and(eq(WorkspaceTable.id, KeyTable.workspaceID), isNull(WorkspaceTable.timeDeleted))) - .innerJoin( - BillingTable, - and(eq(BillingTable.workspaceID, KeyTable.workspaceID), isNull(BillingTable.timeDeleted)), - ) - .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) - .then((rows) => rows[0]), - ) - - if (!auth) { - return new Response( - JSON.stringify({ - type: "error", - error: { - type: "AuthError", - message: "Unauthorized", - }, - }), - { - status: 401, - headers: { - "Content-Type": "application/json", - }, - }, - ) - } - - const row = await Database.use((tx) => - tx - .select({ - timeCreated: LiteTable.timeCreated, - rollingUsage: LiteTable.rollingUsage, - weeklyUsage: LiteTable.weeklyUsage, - monthlyUsage: LiteTable.monthlyUsage, - timeRollingUpdated: LiteTable.timeRollingUpdated, - timeWeeklyUpdated: LiteTable.timeWeeklyUpdated, - timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, - }) - .from(LiteTable) - .where( - and( - eq(LiteTable.workspaceID, auth.workspaceID), - eq(LiteTable.userID, auth.userID), - isNull(LiteTable.timeDeleted), - ), - ) - .then((rows) => rows[0]), - ) - - if (!row) { - return new Response( - JSON.stringify({ - type: "error", - error: { - type: "EntitlementError", - message: "OpenCode Go subscription required.", - }, - }), - { - status: 403, - headers: { - "Content-Type": "application/json", - }, - }, - ) - } - - const limits = LiteData.getLimits() - - return new Response( - JSON.stringify({ - usage: { - rolling: formatUsage( - Subscription.analyzeRollingUsage({ - limit: limits.rollingLimit, - window: limits.rollingWindow, - usage: row.rollingUsage ?? 0, - timeUpdated: row.timeRollingUpdated ?? new Date(), - }), - ), - weekly: formatUsage( - Subscription.analyzeWeeklyUsage({ - limit: limits.weeklyLimit, - usage: row.weeklyUsage ?? 0, - timeUpdated: row.timeWeeklyUpdated ?? new Date(), - }), - ), - monthly: formatUsage( - Subscription.analyzeMonthlyUsage({ - limit: limits.monthlyLimit, - usage: row.monthlyUsage ?? 0, - timeUpdated: row.timeMonthlyUpdated ?? new Date(), - timeSubscribed: row.timeCreated, - }), - ), - }, - }), - { - status: 200, - headers: { - "Content-Type": "application/json", - }, - }, - ) -} - -function formatUsage(usage: { status: "ok" | "rate-limited"; resetInSec: number; usagePercent: number }) { - return { - status: usage.status, - percent: usage.usagePercent, - resetsAt: new Date(Date.now() + usage.resetInSec * 1000).toISOString(), - } + return Response.json({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }, { status: 401 }) } diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts deleted file mode 100644 index bb87ef6be25..00000000000 --- a/packages/console/app/src/routes/zen/util/error.ts +++ /dev/null @@ -1,29 +0,0 @@ -export class AuthError extends Error {} -export class CreditsError extends Error {} -export class MonthlyLimitError extends Error {} -export class UserLimitError extends Error {} -export class ModelError extends Error {} -export class RegionError extends Error {} -export class DataPolicyError extends Error {} - -class LimitError extends Error { - retryAfter?: number - constructor(message: string, retryAfter?: number) { - super(message) - this.retryAfter = retryAfter - } -} -export class RateLimitError extends LimitError {} -export class FreeUsageLimitError extends LimitError {} -export class BlackUsageLimitError extends LimitError {} - -type LimitName = "5 hour" | "weekly" | "monthly" -export class GoUsageLimitError extends LimitError { - workspace: string - limitName: LimitName - constructor(message: string, workspace: string, limitName: LimitName, retryAfter?: number) { - super(message, retryAfter) - this.workspace = workspace - this.limitName = limitName - } -} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 48c77a45dc2..ed4aaeb36d6 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -1,1314 +1,62 @@ import type { APIEvent } from "@solidjs/start/server" -import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js" -import { KeyTable } from "@opencode/console-core/schema/key.sql.js" -import { BillingTable, LiteTable, SubscriptionTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js" -import { centsToMicroCents } from "@opencode/console-core/util/price.js" -import { getMonthlyBounds, getWeekBounds } from "@opencode/console-core/util/date.js" -import { Identifier } from "@opencode/console-core/identifier.js" -import { Billing } from "@opencode/console-core/billing.js" -import { Actor } from "@opencode/console-core/actor.js" -import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" import { ZenData } from "@opencode/console-core/model.js" -import { Subscription } from "@opencode/console-core/subscription.js" -import { BlackData } from "@opencode/console-core/black.js" -import { UserTable } from "@opencode/console-core/schema/user.sql.js" -import { ModelTable } from "@opencode/console-core/schema/model.sql.js" -import { ProviderTable } from "@opencode/console-core/schema/provider.sql.js" -import { logger } from "./logger" -import { - AuthError, - CreditsError, - MonthlyLimitError, - UserLimitError, - ModelError, - RegionError, - DataPolicyError, - RateLimitError, - FreeUsageLimitError, - GoUsageLimitError, - BlackUsageLimitError, -} from "./error" -import { buildCostChunk, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider" -import { anthropicHelper } from "./provider/anthropic" -import { googleHelper } from "./provider/google" -import { openaiHelper } from "./provider/openai" -import { oaCompatHelper } from "./provider/openai-compatible" -import { systemoneHelper } from "./provider/systemone" -import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter" -import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter" -import { createTrialLimiter } from "./trialLimiter" -import { createStickyTracker } from "./stickyProviderTracker" -import { LiteData } from "@opencode/console-core/lite.js" -import { Resource } from "@opencode/console-resource" -import { i18n, type Key } from "~/i18n" +import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" -import { createModelTpmLimiter } from "./modelTpmLimiter" -import { createModelTpsLimiter } from "./modelTpsLimiter" -import { createProviderBudgetTracker } from "./providerBudgetTracker" -import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher" -import { Workspace } from "@opencode/console-core/workspace.js" -import { countryFromRequest, isModelCountryRestricted } from "~/lib/request-country" -import { isPeakPricing } from "./pricing" import { prepareRequestBody } from "./requestBody" -import { requiresGoTrainingConsent } from "./trainingConsent" import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy" -type ZenData = Awaited> -type PreparedBody = Awaited> -type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance" - -function resolve(text: string, params?: Record) { - if (!params) return text - return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => { - const value = params[key] - if (value === undefined || value === null) return raw - return String(value) - }) -} - +// Every request is served by new inference; this only routes and rejects what it cannot route. export async function handler( input: APIEvent, opts: { format: ZenData.Format modelList: "lite" | "full" parseApiKey: (headers: Headers) => string | undefined - parseModel: (url: string, body: any) => string - parseVariant: (url: string, body: any) => string | undefined - parseIsStream: (url: string, body: any) => boolean + /** Reads the model from the URL for formats that do not send it in the body. */ + parseModel?: (url: string) => string }, ) { - type AuthInfo = Awaited> - type ModelInfo = Awaited> - type ProviderInfo = Awaited> - type CostInfo = ReturnType - const dict = i18n(localeFromRequest(input.request)) - const t = (key: Key, params?: Record) => resolve(dict[key], params) - const ADMIN_WORKSPACES = [ - "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // anomaly - "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // benchmark - "wrk_01KKZDKDWCS1VTJF8QTX62DD50", // contributors - ] + const body = input.request.body + if (!body) return error(400, "error", "Missing request body") + const requestBody = opts.format === "google" ? undefined : await prepareRequestBody(body) + const model = requestBody ? requestBody.model : (opts.parseModel?.(input.request.url) ?? "") + const configured = model ? ZenData.list(opts.modelList).models[model] : undefined + const entry = Array.isArray(configured) ? configured.find((entry) => entry.formatFilter === opts.format) : configured + const cancel = () => void (requestBody ? requestBody.cancel() : body.cancel()).catch(() => {}) - let requestBody: PreparedBody | undefined - try { - const url = input.request.url - const body = input.request.body - if (!body) throw new Error("Missing request body") - requestBody = opts.format === "google" ? undefined : await prepareRequestBody(body) - const model = opts.format === "google" ? opts.parseModel(url, undefined) : (requestBody?.model ?? "") - const googleStream = opts.format === "google" ? opts.parseIsStream(url, undefined) : undefined - const rawIp = input.request.headers.get("x-real-ip") ?? "" - const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp - const rawZenApiKey = opts.parseApiKey(input.request.headers) - const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey - const zenData = ZenData.list(opts.modelList) - if (model) { - // Read routing metadata without running legacy model, auth, or balance checks. - const configured = zenData.models[model] - const entry = Array.isArray(configured) - ? configured.find((entry) => entry.formatFilter === opts.format) - : configured - const response = await proxyInference(input.request, { - provider: opts.modelList === "full" ? entry?.byokProvider : undefined, - model: - opts.modelList === "full" - ? entry?.providers.find((provider) => provider.id === entry.byokProvider)?.model - : undefined, - // Keyless requests for free models skip legacy checks and go straight to new inference. - anonymous: opts.modelList === "full" && entry?.allowAnonymous === true, - body: (providerModel) => requestBody?.stream(providerModel ?? model, false) ?? body, - }).catch(() => { - void (requestBody ? requestBody.cancel() : body.cancel()).catch(() => {}) - return inferenceUnavailable() - }) - if (response) return response - } - const sessionId = input.request.headers.get("x-opencode-session") ?? "" - const requestId = input.request.headers.get("x-opencode-request") ?? "" - const ocClient = input.request.headers.get("x-opencode-client") ?? "" - const projectId = input.request.headers.get("x-opencode-project") ?? "" - const userAgent = input.request.headers.get("user-agent") ?? "" - logger.metric({ - session: sessionId, - request: requestId, - client: ocClient, - user_agent: userAgent, - "model.tier": opts.modelList === "full" ? "zen" : "go", + if (model) { + const response = await proxyInference(input.request, { + provider: opts.modelList === "full" ? entry?.byokProvider : undefined, + model: + opts.modelList === "full" + ? entry?.providers.find((provider) => provider.id === entry.byokProvider)?.model + : undefined, + // Keyless requests for free models go straight to new inference. + anonymous: opts.modelList === "full" && entry?.allowAnonymous === true, + body: (providerModel) => requestBody?.stream(providerModel ?? model, false) ?? body, + }).catch(() => { + cancel() + return inferenceUnavailable() }) - const modelInfo = validateModel(zenData, model) - const country = countryFromRequest(input.request) - if (isModelCountryRestricted(modelInfo.id, country)) throw new RegionError(t("zen.api.error.countryNotAllowed")) - const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) - const trialProviders = await trialLimiter?.check() - const rateLimiter = modelInfo.allowAnonymous - ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request) - : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) - await rateLimiter?.check() - const authInfo = await authenticate(modelInfo, zenApiKey) - if (authInfo && opts.modelList === "lite" && requiresGoTrainingConsent(modelInfo.id) && !authInfo.allowTraining) - throw new DataPolicyError( - t("zen.api.error.trainingNotAllowed", { - consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`, - }), - ) - const allowedRegions = authInfo?.region - ? authInfo.region - : await (async () => { - if (!authInfo) return - return Actor.provide("system", { workspaceID: authInfo.workspaceID }, () => - Workspace.setDefaultRegion({ country }), - ) - })() - if ( - authInfo && - opts.modelList === "lite" && - ["deepseek-v4.1-flash", "deepseek-flash", "deepseek-v4-flash", "deepseek-v4-pro"].includes(modelInfo.id) && - !allowedRegions?.includes("cn") - ) - throw new RegionError( - t("zen.api.error.regionNotAllowed", { - consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`, - }), - ) - const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip) - const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId) - const stickyProvider = await stickyTracker?.get() - const billingSource = validateBilling(authInfo, modelInfo) - logger.metric({ source: billingSource }) - const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers) - const modelTpmLimits = await modelTpmLimiter?.check() - const modelTpsLimiter = createModelTpsLimiter(modelInfo.providers) - const modelTpsLimits = await modelTpsLimiter?.check() - const providerBudgetTracker = createProviderBudgetTracker( - modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })), - ) - const providerBudget = await providerBudgetTracker?.check() - - const providerRequest = async () => { - const providerInfo = selectProvider( - model, - zenData, - authInfo, - modelInfo, - stickyId, - trialProviders, - stickyProvider, - modelTpmLimits, - modelTpsLimits, - providerBudget, - ) - validateModelSettings(billingSource, authInfo) - updateProviderKey(authInfo, providerInfo) - logger.metric({ - provider: providerInfo.id, - "provider.model": providerInfo.model, - shallowProvider: providerInfo.id, - "shallowProvider.model": providerInfo.model, - }) - - const startTimestamp = Date.now() - const reqUrl = providerInfo.modifyUrl(providerInfo.api, googleStream ?? false) - const specialAnthropic = - providerInfo.format === "anthropic" && - (providerInfo.model.startsWith("arn:aws:bedrock:") || - providerInfo.model.startsWith("global.anthropic.") || - providerInfo.model.startsWith("databricks-claude-")) - if (providerInfo.format !== opts.format) throw new Error("Zen provider format must match request format") - if (specialAnthropic) throw new Error("Anthropic provider body modifiers are incompatible with streaming") - const prepared = requestBody - - const reqBody = (() => { - if (opts.format === "google") return body - if (!prepared) throw new Error("Missing prepared request body") - return prepared.stream(providerInfo.model, providerInfo.format === "oa-compat") - })() - logger.debug("REQUEST URL: " + reqUrl) - logger.debug("REQUEST: " + (requestBody?.preview ?? "") + "...") - const isNewInference = - providerInfo.id.startsWith("console.") || - providerInfo.id.startsWith("console-go.") || - providerInfo.id.startsWith("inf.") || - providerInfo.id.startsWith("inf-go.") - const res = await fetch(reqUrl, { - method: "POST", - headers: (() => { - const headers = new Headers(input.request.headers) - providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId) - Object.entries(providerInfo.headerModifier ?? {}).forEach(([k, v]) => { - if (v === "$ip") return headers.set(k, ip) - if (v === "$caller") return headers.set(k, stickyId) - if (v === "$session") return headers.set(k, sessionId) - if (v === "$model") return headers.set(k, model) - if (v === "$request") return headers.set(k, requestId) - if (v === "$client") return headers.set(k, ocClient) - if (v === "$project") return headers.set(k, projectId) - if (v === "$workspace") { - if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID) - return - } - if (v === "$org") { - if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID.replace("wrk_", "org_")) - return - } - headers.set(k, v) - }) - if (isNewInference) { - headers.set("x-zen-model", model) - if (opts.modelList === "lite") - headers.set("x-zen-billing-source", billingSource === "lite" ? "go" : "credit") - } - headers.delete("host") - headers.delete("content-length") - if (!isNewInference) { - headers.delete("x-opencode-session") - headers.delete("x-opencode-project") - headers.delete("x-opencode-client") - headers.delete("x-opencode-request") - headers.delete("x-zen-model") - headers.delete("x-zen-billing-source") - } - return headers - })(), - body: reqBody, - duplex: "half", - // Propagate caller disconnects to the upstream provider request so - // abandoned Console requests do not leave orphaned inference work open. - signal: input.request.signal, - } as RequestInit & { duplex: "half" }) - const isStream = res.headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false - logger.metric({ is_stream: isStream }) - - if (isNewInference) { - const resEndpointId = res.headers.get("x-opencode-endpoint-id") - const resEndpointModelId = res.headers.get("x-opencode-upstream-model-id") - if (resEndpointId && resEndpointModelId) - logger.metric({ - provider: resEndpointId, - "provider.model": resEndpointModelId, - }) - } - - if (res.status !== 200) { - logger.metric({ - "llm.error.code": res.status, - "llm.error.message": res.statusText, - }) - } - - return { providerInfo, res, startTimestamp, isStream } - } - - const { providerInfo, res, startTimestamp, isStream } = await providerRequest() - - // Store sticky provider - if (res.status === 200) await stickyTracker?.set(providerInfo.id) - - // Temporarily change 404 to 400 status code b/c solid start automatically override 404 response - const resStatus = res.status === 404 ? 400 : res.status - - // Scrub response headers - const resHeaders = new Headers() - const keepHeaders = ["content-type", "cache-control"] - for (const [k, v] of res.headers.entries()) { - if (keepHeaders.includes(k.toLowerCase())) { - resHeaders.set(k, v) - } - } - logger.debug("STATUS: " + res.status + " " + res.statusText) - - // Handle non-streaming response - if (!isStream || [400, 404, 429, 529].includes(res.status)) { - const json = await res.json() - await rateLimiter?.track() - const usage = providerInfo.extractUsage(json) - if (usage) { - const usageInfo = providerInfo.normalizeUsage(usage) - const costInfo = calculateCost(modelInfo, usageInfo) - await trialLimiter?.track(usageInfo) - await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) - await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent) - await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) - await reload(billingSource, authInfo, costInfo) - json.cost = calculateOccurredCost(billingSource, costInfo) - } - if (res.status === 400) { - logger.metric({ "error.response": JSON.stringify(json) }) - } - if (json.error?.message) { - json.error.message = `Error from provider${providerInfo.displayName ? ` (${providerInfo.displayName})` : ""}: ${json.error.message}` - } - - const responseConverter = createResponseConverter(providerInfo.format, opts.format) - const body = JSON.stringify(responseConverter(json)) - logger.metric({ response_length: body.length }) - logger.debug("RESPONSE: " + body) - return new Response(body, { - status: resStatus, - statusText: res.statusText, - headers: resHeaders, - }) - } - - // Handle streaming response - const streamConverter = createStreamPartConverter(providerInfo.format, opts.format) - const usageParser = providerInfo.createUsageParser() - const binaryDecoder = providerInfo.createBinaryStreamDecoder() - let reader: ReadableStreamDefaultReader | undefined - const stream = new ReadableStream({ - start(c) { - reader = res.body?.getReader() - const decoder = new TextDecoder() - const encoder = new TextEncoder() - - let buffer = "" - let responseLength = 0 - let timestampFirstByte = 0 - - function pump(): Promise { - return ( - reader?.read().then(async ({ done, value: rawValue }) => { - if (done) { - const timestampLastByte = Date.now() - logger.metric({ - response_length: responseLength, - "timestamp.last_byte": timestampLastByte, - }) - await rateLimiter?.track() - const usage = usageParser.retrieve() - if (usage) { - const usageInfo = providerInfo.normalizeUsage(usage) - const costInfo = calculateCost(modelInfo, usageInfo) - await trialLimiter?.track(usageInfo) - await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) - await modelTpsLimiter?.track( - providerInfo.id, - providerInfo.model, - providerInfo.tpsGoal, - timestampFirstByte, - timestampLastByte, - usageInfo, - ) - await providerBudgetTracker?.track( - providerInfo.id, - providerInfo.budgetPriority, - costInfo.totalCostInCent, - ) - await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) - await reload(billingSource, authInfo, costInfo) - const cost = calculateOccurredCost(billingSource, costInfo) - c.enqueue(encoder.encode(buildCostChunk(opts.format, cost))) - } - c.close() - return - } - - if (responseLength === 0) { - timestampFirstByte = Date.now() - logger.metric({ - time_to_first_byte: timestampFirstByte - startTimestamp, - "timestamp.first_byte": timestampFirstByte, - }) - } - - const value = binaryDecoder ? binaryDecoder(rawValue) : rawValue - if (!value) return - - responseLength += value.length - buffer += decoder.decode(value, { stream: true }) - - const parts = buffer.split(/\r\n\r\n|\n\n|\r\r/) - buffer = parts.pop() ?? "" - - for (let part of parts) { - logger.debug("PART: " + part) - - part = part.trim() - usageParser.parse(part) - - if (providerInfo.format !== opts.format) { - part = streamConverter(part) - c.enqueue(encoder.encode(part + "\n\n")) - } - } - - if (providerInfo.format === opts.format) { - c.enqueue(value) - } - - return pump() - }) || Promise.resolve() - ) - } - - return pump() - }, - cancel() { - // When the downstream caller stops reading, release the upstream - // response body instead of keeping the provider/inference stream alive. - return reader?.cancel() - }, - }) - return new Response(stream, { - status: resStatus, - statusText: res.statusText, - headers: resHeaders, - }) - } catch (error: any) { - if (requestBody) void requestBody.cancel().catch(() => {}) - else void input.request.body?.cancel().catch(() => {}) - // The caller disconnected before we finished. Because the outbound provider - // request shares input.request.signal, an aborted caller surfaces here as an - // AbortError. There is no client left to receive a body, so skip the error - // metric and 500 and return a quiet client-closed response. - if (input.request.signal.aborted || error?.name === "AbortError") { - logger.debug("REQUEST ABORTED BY CALLER") - return new Response(null, { status: 499 }) - } - - logger.metric({ - "error.type": error.constructor.name, - "error.message": error.message, - "error.cause": error.cause?.toString(), - }) - if (error.message.startsWith("Failed query")) { - try { - logger.metric({ - "error.cause2": JSON.stringify(error.cause), - }) - } catch {} - } - - if (error instanceof RegionError || error instanceof DataPolicyError) - return new Response( - JSON.stringify({ - type: "error", - error: { type: error.constructor.name, message: error.message }, - }), - { status: 403 }, - ) - - // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. - if ( - error instanceof AuthError || - error instanceof CreditsError || - error instanceof MonthlyLimitError || - error instanceof UserLimitError || - error instanceof ModelError - ) - return new Response( - JSON.stringify({ - type: "error", - error: { type: error.constructor.name, message: error.message }, - }), - { status: 401 }, - ) - - if ( - error instanceof RateLimitError || - error instanceof FreeUsageLimitError || - error instanceof GoUsageLimitError || - error instanceof BlackUsageLimitError - ) { - const headers = new Headers() - if (error.retryAfter) { - headers.set("retry-after", String(error.retryAfter)) - } - return new Response( - JSON.stringify({ - type: "error", - error: { - type: error.constructor.name, - message: error.message, - }, - metadata: - error instanceof GoUsageLimitError - ? { - workspace: error.workspace, - limitName: error.limitName, - } - : {}, - }), - { status: 429, headers }, - ) - } - - return new Response( - JSON.stringify({ - type: "error", - error: { - type: "error", - message: "Internal server error", - }, - }), - { status: 500 }, - ) + if (response) return response } - function validateModel(zenData: ZenData, reqModel: string) { - if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel })) - - const modelId = reqModel - const modelData = Array.isArray(zenData.models[modelId]) - ? zenData.models[modelId].find((model) => opts.format === model.formatFilter) - : zenData.models[modelId] - - if (!modelData) - throw new ModelError( - t("zen.api.error.modelFormatNotSupported", { - model: reqModel, - format: opts.format, - }), - ) - - if (modelData.trialEnded) - throw new ModelError( - `${t("zen.api.error.trialEnded", { - model: modelData.name, - link: "https://opencode.ai/go", - })}`, - ) - - logger.metric({ model: modelId }) - - return { id: modelId, ...modelData } - } - - function selectProvider( - reqModel: string, - zenData: ZenData, - authInfo: AuthInfo, - modelInfo: ModelInfo, - stickyId: string, - trialProviders: string[] | undefined, - stickyProviderId: string | undefined, - modelTpmLimits: Record | undefined, - modelTpsLimits: Record | undefined, - providerBudget: - | { - qualify: (providerId: string, priority: number) => boolean - prefer: (providerId: string, priority: number) => boolean - } - | undefined, - ) { - const modelProvider = (() => { - // Byok is top priority b/c if user set their own API key, we should use it - // instead of using the sticky provider for the same session - if (authInfo?.provider?.credentials) { - return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider) - } - - // Prioritize trial providers - let allProviders = modelInfo.providers.filter((provider) => !provider.disabled) - if (trialProviders) { - allProviders = allProviders.map((provider) => ({ - ...provider, - priority: trialProviders.includes(provider.id) ? 0 : provider.priority, - })) - } - - const fallbackProvider = allProviders.find((provider) => provider.id === modelInfo.fallbackProvider) - - let topPriority = Infinity - const providers = allProviders - .filter((provider) => provider.weight !== 0) - .filter((provider) => { - if (provider.budgetPriority === undefined) return true - if (!providerBudget) return true - return providerBudget.qualify(provider.id, provider.budgetPriority) - }) - .filter((provider) => { - if (!provider.tpmLimit) return true - const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0 - return usage < provider.tpmLimit * 1_000_000 - }) - .filter((provider) => { - if (!provider.tpsGoal) return true - const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { - qualify: 0, - unqualify: 0, - } - const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify - return !isLowTps - }) - .map((provider) => { - topPriority = Math.min(topPriority, provider.priority) - return provider - }) - .filter((p) => p.priority <= topPriority) - .flatMap((provider) => Array(provider.weight).fill(provider)) - - // Use the last 4 characters of session ID to select a provider - let h = 0 - const l = stickyId.length - for (let i = l - 4; i < l; i++) { - h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int - } - const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 - const provider = providers[index || 0] ?? fallbackProvider - - // sticky provider does not exist => use selected provider - if (!stickyProviderId) return provider - const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) - if (!stickProvider) return provider - - const preferBudgetProvider = - provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority) - - const preferTpsProvider = (() => { - if (!provider.tpsGoal) return false - const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { - qualify: 0, - unqualify: 0, - } - return tps.qualify > tps.unqualify * 3 - })() - - if (!preferBudgetProvider && !preferTpsProvider) return stickProvider - - return provider - })() - - if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable")) - if (!(modelProvider.id in zenData.providers)) - throw new ModelError(t("zen.api.error.providerNotSupported", { provider: modelProvider.id })) - - return { - ...modelProvider, - ...zenData.providers[modelProvider.id], - ...(() => { - const providerProps = zenData.providers[modelProvider.id] - const format = providerProps.format - const opts = { - reqModel, - providerModel: modelProvider.model, - adjustCacheUsage: providerProps.adjustCacheUsage, - workspaceID: authInfo?.workspaceID, - } - if (format === "anthropic") return anthropicHelper(opts) - if (format === "google") return googleHelper(opts) - if (format === "openai") return openaiHelper(opts) - if (format === "systemone") return systemoneHelper(opts) - return oaCompatHelper(opts) - })(), - } - } - - async function authenticate(modelInfo: ModelInfo, zenApiKey?: string) { - if (!zenApiKey) { - if (modelInfo.allowAnonymous) return - throw new AuthError(t("zen.api.error.missingApiKey")) - } - - const data = await Database.use((tx) => - tx - .select({ - apiKey: KeyTable.id, - workspace: { - id: WorkspaceTable.id, - region: WorkspaceTable.region, - allowTraining: WorkspaceTable.allow_training, - isBlocked: WorkspaceTable.is_blocked, - isFlaggedByAnthropic: WorkspaceTable.is_flagged_by_anthropic, - isFlaggedByOpenAI: WorkspaceTable.is_flagged_by_openai, - }, - billing: { - balance: BillingTable.balance, - paymentMethodID: BillingTable.paymentMethodID, - monthlyLimit: BillingTable.monthlyLimit, - monthlyUsage: BillingTable.monthlyUsage, - timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated, - reloadTrigger: BillingTable.reloadTrigger, - timeReloadLockedTill: BillingTable.timeReloadLockedTill, - subscription: BillingTable.subscription, - lite: BillingTable.lite, - }, - user: { - id: UserTable.id, - monthlyLimit: UserTable.monthlyLimit, - monthlyUsage: UserTable.monthlyUsage, - timeMonthlyUsageUpdated: UserTable.timeMonthlyUsageUpdated, - }, - black: { - id: SubscriptionTable.id, - rollingUsage: SubscriptionTable.rollingUsage, - fixedUsage: SubscriptionTable.fixedUsage, - timeRollingUpdated: SubscriptionTable.timeRollingUpdated, - timeFixedUpdated: SubscriptionTable.timeFixedUpdated, - }, - lite: { - id: LiteTable.id, - timeCreated: LiteTable.timeCreated, - rollingUsage: LiteTable.rollingUsage, - weeklyUsage: LiteTable.weeklyUsage, - monthlyUsage: LiteTable.monthlyUsage, - timeRollingUpdated: LiteTable.timeRollingUpdated, - timeWeeklyUpdated: LiteTable.timeWeeklyUpdated, - timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, - }, - provider: { - credentials: ProviderTable.credentials, - }, - timeDisabled: ModelTable.timeCreated, - }) - .from(KeyTable) - .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) - .innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID)) - .innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID))) - .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id))) - .leftJoin( - ProviderTable, - modelInfo.byokProvider - ? and( - eq(ProviderTable.workspaceID, KeyTable.workspaceID), - eq(ProviderTable.provider, modelInfo.byokProvider), - ) - : sql`false`, - ) - .leftJoin( - SubscriptionTable, - and( - eq(SubscriptionTable.workspaceID, KeyTable.workspaceID), - eq(SubscriptionTable.userID, KeyTable.userID), - isNull(SubscriptionTable.timeDeleted), - ), - ) - .leftJoin( - LiteTable, - and( - eq(LiteTable.workspaceID, KeyTable.workspaceID), - eq(LiteTable.userID, KeyTable.userID), - isNull(LiteTable.timeDeleted), - ), - ) - .where(and(eq(KeyTable.key, zenApiKey), isNull(KeyTable.timeDeleted))) - .then((rows) => rows[0]), + // Unrouted: unknown model, missing key, or a key without a migrated workspace. + cancel() + if (!configured) return error(401, "ModelError", dict["zen.api.error.modelNotSupported"].replace("{{model}}", model)) + if (!entry) + return error( + 401, + "ModelError", + dict["zen.api.error.modelFormatNotSupported"].replace("{{model}}", model).replace("{{format}}", opts.format), ) - - if (!data) throw new AuthError(t("zen.api.error.invalidApiKey")) - if ( - data.workspace.isBlocked || - (data.workspace.isFlaggedByAnthropic && modelInfo.id.startsWith("claude-")) || - (data.workspace.isFlaggedByOpenAI && modelInfo.id.startsWith("gpt-")) - ) - throw new AuthError(t("zen.api.error.requestBlockedByUpstreamProvider")) - if ( - modelInfo.id.startsWith("alpha-") && - Resource.App.stage === "production" && - !ADMIN_WORKSPACES.includes(data.workspace.id) - ) - throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id })) - - logger.metric({ - api_key: data.apiKey, - workspace: data.workspace.id, - user_id: data.user.id, - ...(() => { - if (data.billing.subscription) - return { - subscription: data.billing.subscription.plan, - } - if (data.billing.lite) - return { - subscription: "lite", - } - return {} - })(), - }) - - return { - apiKeyId: data.apiKey, - workspaceID: data.workspace.id, - region: data.workspace.region, - allowTraining: data.workspace.allowTraining ?? false, - billing: data.billing, - user: data.user, - black: data.black, - lite: data.lite, - provider: data.provider, - isFree: ADMIN_WORKSPACES.includes(data.workspace.id), - isDisabled: !!data.timeDisabled, - } - } - - function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo): BillingSource { - if (!authInfo) return "anonymous" - if (authInfo.provider?.credentials) return "byok" - if (authInfo.isFree) return "free" - if (modelInfo.allowAnonymous) return "free" - - const formatRetryTime = (seconds: number) => { - const days = Math.floor(seconds / 86400) - if (days >= 1) return `${days} day${days > 1 ? "s" : ""}` - const hours = Math.floor(seconds / 3600) - const minutes = Math.ceil((seconds % 3600) / 60) - if (hours >= 1) return `${hours}hr ${minutes}min` - return `${minutes}min` - } - - // Validate black subscription billing - if (authInfo.billing.subscription && authInfo.black) { - try { - const sub = authInfo.black - const plan = authInfo.billing.subscription.plan - - // Check weekly limit - if (sub.fixedUsage && sub.timeFixedUpdated) { - const blackData = BlackData.getLimits({ plan }) - const result = Subscription.analyzeWeeklyUsage({ - limit: blackData.fixedLimit, - usage: sub.fixedUsage, - timeUpdated: sub.timeFixedUpdated, - }) - if (result.status === "rate-limited") - throw new BlackUsageLimitError( - t("zen.api.error.subscriptionQuotaExceeded", { - retryIn: formatRetryTime(result.resetInSec), - }), - result.resetInSec, - ) - } - - // Check rolling limit - if (sub.rollingUsage && sub.timeRollingUpdated) { - const blackData = BlackData.getLimits({ plan }) - const result = Subscription.analyzeRollingUsage({ - limit: blackData.rollingLimit, - window: blackData.rollingWindow, - usage: sub.rollingUsage, - timeUpdated: sub.timeRollingUpdated, - }) - if (result.status === "rate-limited") - throw new BlackUsageLimitError( - t("zen.api.error.subscriptionQuotaExceeded", { - retryIn: formatRetryTime(result.resetInSec), - }), - result.resetInSec, - ) - } - - return "subscription" - } catch (e) { - if (!authInfo.billing.subscription.useBalance) throw e - } - } - - // Validate lite subscription billing - if (opts.modelList === "lite" && authInfo.billing.lite && authInfo.lite) { - if (Object.values(modelInfo.cost).every((price) => price === 0)) return "lite" - - try { - const consoleGoUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/go` - const sub = authInfo.lite - const liteData = LiteData.getLimits() - - // Check weekly limit - if (sub.weeklyUsage && sub.timeWeeklyUpdated) { - const result = Subscription.analyzeWeeklyUsage({ - limit: liteData.weeklyLimit, - usage: sub.weeklyUsage, - timeUpdated: sub.timeWeeklyUpdated, - }) - if (result.status === "rate-limited") - throw new GoUsageLimitError( - t("zen.api.error.goSubscriptionWeeklyLimitExceeded", { - retryIn: formatRetryTime(result.resetInSec), - consoleGoUrl, - }), - authInfo.workspaceID, - "weekly", - result.resetInSec, - ) - } - - // Check monthly limit - if (sub.monthlyUsage && sub.timeMonthlyUpdated) { - const result = Subscription.analyzeMonthlyUsage({ - limit: liteData.monthlyLimit, - usage: sub.monthlyUsage, - timeUpdated: sub.timeMonthlyUpdated, - timeSubscribed: sub.timeCreated, - }) - if (result.status === "rate-limited") - throw new GoUsageLimitError( - t("zen.api.error.goSubscriptionMonthlyLimitExceeded", { - retryIn: formatRetryTime(result.resetInSec), - consoleGoUrl, - }), - authInfo.workspaceID, - "monthly", - result.resetInSec, - ) - } - - // Check rolling limit - if (sub.rollingUsage && sub.timeRollingUpdated) { - const result = Subscription.analyzeRollingUsage({ - limit: liteData.rollingLimit, - window: liteData.rollingWindow, - usage: sub.rollingUsage, - timeUpdated: sub.timeRollingUpdated, - }) - if (result.status === "rate-limited") - throw new GoUsageLimitError( - t("zen.api.error.goSubscriptionRollingLimitExceeded", { - retryIn: formatRetryTime(result.resetInSec), - consoleGoUrl, - }), - authInfo.workspaceID, - "5 hour", - result.resetInSec, - ) - } - - return "lite" - } catch (e) { - if (!authInfo.billing.lite.useBalance) throw e - } - } - - // Validate pay as you go billing - const billing = authInfo.billing - const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing` - const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members` - if (!billing.paymentMethodID && billing.balance <= 0) - throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl })) - if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl })) - - const now = new Date() - const currentYear = now.getUTCFullYear() - const currentMonth = now.getUTCMonth() - if ( - billing.monthlyLimit && - billing.monthlyUsage && - billing.timeMonthlyUsageUpdated && - billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100) && - currentYear === billing.timeMonthlyUsageUpdated.getUTCFullYear() && - currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth() - ) - throw new MonthlyLimitError( - t("zen.api.error.workspaceMonthlyLimitReached", { - amount: billing.monthlyLimit, - billingUrl, - }), - ) - - if ( - authInfo.user.monthlyLimit && - authInfo.user.monthlyUsage && - authInfo.user.timeMonthlyUsageUpdated && - authInfo.user.monthlyUsage >= centsToMicroCents(authInfo.user.monthlyLimit * 100) && - currentYear === authInfo.user.timeMonthlyUsageUpdated.getUTCFullYear() && - currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth() - ) - throw new UserLimitError( - t("zen.api.error.userMonthlyLimitReached", { - amount: authInfo.user.monthlyLimit, - membersUrl, - }), - ) - - return "balance" - } - - function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) { - if (billingSource === "lite") return - if (billingSource === "anonymous") return - if (authInfo!.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled")) - } - - function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) { - if (!authInfo?.provider?.credentials) return - providerInfo.apiKey = authInfo.provider.credentials - } - - function calculateCost(modelInfo: ModelInfo, usageInfo: UsageInfo) { - const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = - usageInfo - - const modelCost = - modelInfo.costPeak && isPeakPricing(new Date()) - ? modelInfo.costPeak - : modelInfo.cost200K && - inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > - modelInfo.cost200K.threshold - ? modelInfo.cost200K - : modelInfo.cost - - const inputCost = modelCost.input * inputTokens * 100 - const outputCost = modelCost.output * outputTokens * 100 - const cacheReadCost = (() => { - if (!cacheReadTokens) return undefined - if (!modelCost.cacheRead) return undefined - return modelCost.cacheRead * cacheReadTokens * 100 - })() - const cacheWrite5mCost = (() => { - if (!cacheWrite5mTokens) return undefined - if (!modelCost.cacheWrite5m) return undefined - return modelCost.cacheWrite5m * cacheWrite5mTokens * 100 - })() - const cacheWrite1hCost = (() => { - if (!cacheWrite1hTokens) return undefined - if (!modelCost.cacheWrite1h) return undefined - return modelCost.cacheWrite1h * cacheWrite1hTokens * 100 - })() - const totalCostInCent = - inputCost + outputCost + (cacheReadCost ?? 0) + (cacheWrite5mCost ?? 0) + (cacheWrite1hCost ?? 0) - return { - totalCostInCent, - inputCost, - outputCost, - cacheReadCost, - cacheWrite5mCost, - cacheWrite1hCost, - } - } - - function calculateOccurredCost(billingSource: BillingSource, costInfo: CostInfo) { - return billingSource === "balance" ? (costInfo.totalCostInCent / 100).toFixed(8) : "0" - } - - async function trackUsage( - sessionId: string, - billingSource: BillingSource, - authInfo: AuthInfo, - modelInfo: ModelInfo, - providerInfo: ProviderInfo, - usageInfo: UsageInfo, - costInfo: CostInfo, - ) { - const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } = - usageInfo - const { totalCostInCent, inputCost, outputCost, cacheReadCost, cacheWrite5mCost, cacheWrite1hCost } = costInfo - - logger.metric({ - "tokens.input": inputTokens, - "tokens.output": outputTokens, - "tokens.reasoning": reasoningTokens, - "tokens.cache_read": cacheReadTokens, - "tokens.cache_write_5m": cacheWrite5mTokens, - "tokens.cache_write_1h": cacheWrite1hTokens, - "cost.input.microcents": centsToMicroCents(inputCost), - "cost.output.microcents": centsToMicroCents(outputCost), - "cost.cache_read.microcents": cacheReadCost ? centsToMicroCents(cacheReadCost) : undefined, - "cost.cache_write.microcents": cacheWrite5mCost ? centsToMicroCents(cacheWrite5mCost) : undefined, - "cost.total.microcents": centsToMicroCents(totalCostInCent), - // deprecated - remove after May 20, 2026 - "cost.input": Math.round(inputCost), - "cost.output": Math.round(outputCost), - "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined, - "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined, - "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined, - "cost.total": Math.round(totalCostInCent), - }) - - if (billingSource === "anonymous") return - authInfo = authInfo! - - const cost = centsToMicroCents(totalCostInCent) - // Keep period bounds and persisted timestamps on one snapshot when a queued write crosses a reset boundary. - const trackedAt = new Date() - - // For hot workspaces, batch balance/usage updates through Redis to avoid - // row-level lock contention on BillingTable/UserTable. Returns the amount - // to flush this request, or null to skip the DB writes entirely. - const balanceFlush = await (async () => { - if (billingSource !== "subscription" && billingSource !== "lite" && HOT_WORKSPACES.has(authInfo.workspaceID)) { - const workspaceCost = billingSource === "free" || billingSource === "byok" ? 0 : cost - const flush = await accumulateUsage(authInfo.workspaceID, authInfo.user.id, workspaceCost, cost) - return { batched: true as const, flush } - } - return { batched: false as const, flush: null } - })() - - await Database.use((db) => - Promise.all([ - db.insert(UsageTable).values({ - workspaceID: authInfo.workspaceID, - id: Identifier.create("usage"), - model: modelInfo.id, - provider: providerInfo.id, - inputTokens, - outputTokens, - reasoningTokens, - cacheReadTokens, - cacheWrite5mTokens, - cacheWrite1hTokens, - cost, - keyID: authInfo.apiKeyId, - sessionID: sessionId.substring(0, 30), - enrichment: (() => { - if (billingSource === "subscription") return { plan: "sub" } - if (billingSource === "byok") return { plan: "byok" } - if (billingSource === "lite") return { plan: "lite", costMultiplier: modelInfo.costMultiplier } - return undefined - })(), - }), - ...(() => { - if (billingSource === "subscription") { - const plan = authInfo.billing.subscription!.plan - const black = BlackData.getLimits({ plan }) - const week = getWeekBounds(trackedAt) - const rollingWindowSeconds = black.rollingWindow * 3600 - return [ - db - .update(SubscriptionTable) - .set({ - fixedUsage: sql` - CASE - WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.end} THEN ${SubscriptionTable.fixedUsage} - WHEN ${SubscriptionTable.timeFixedUpdated} >= ${week.start} THEN ${SubscriptionTable.fixedUsage} + ${cost} - ELSE ${cost} - END - `, - timeFixedUpdated: sql` - CASE - WHEN ${SubscriptionTable.timeFixedUpdated} > ${trackedAt} THEN ${SubscriptionTable.timeFixedUpdated} - ELSE ${trackedAt} - END - `, - rollingUsage: sql` - CASE - WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.rollingUsage} + ${cost} - ELSE ${cost} - END - `, - timeRollingUpdated: sql` - CASE - WHEN UNIX_TIMESTAMP(${SubscriptionTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${SubscriptionTable.timeRollingUpdated} - ELSE now() - END - `, - }) - .where( - and( - eq(SubscriptionTable.workspaceID, authInfo.workspaceID), - eq(SubscriptionTable.userID, authInfo.user.id), - ), - ), - ] - } - if (billingSource === "lite") { - const lite = LiteData.getLimits() - const week = getWeekBounds(trackedAt) - const month = getMonthlyBounds(trackedAt, authInfo.lite!.timeCreated) - const rollingWindowSeconds = lite.rollingWindow * 3600 - const quotaCost = Math.round(cost * modelInfo.costMultiplier) - return [ - db - .update(LiteTable) - .set({ - monthlyUsage: sql` - CASE - WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.end} THEN ${LiteTable.monthlyUsage} - WHEN ${LiteTable.timeMonthlyUpdated} >= ${month.start} THEN ${LiteTable.monthlyUsage} + ${quotaCost} - ELSE ${quotaCost} - END - `, - timeMonthlyUpdated: sql` - CASE - WHEN ${LiteTable.timeMonthlyUpdated} > ${trackedAt} THEN ${LiteTable.timeMonthlyUpdated} - ELSE ${trackedAt} - END - `, - weeklyUsage: sql` - CASE - WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.end} THEN ${LiteTable.weeklyUsage} - WHEN ${LiteTable.timeWeeklyUpdated} >= ${week.start} THEN ${LiteTable.weeklyUsage} + ${quotaCost} - ELSE ${quotaCost} - END - `, - timeWeeklyUpdated: sql` - CASE - WHEN ${LiteTable.timeWeeklyUpdated} > ${trackedAt} THEN ${LiteTable.timeWeeklyUpdated} - ELSE ${trackedAt} - END - `, - rollingUsage: sql` - CASE - WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.rollingUsage} + ${quotaCost} - ELSE ${quotaCost} - END - `, - timeRollingUpdated: sql` - CASE - WHEN UNIX_TIMESTAMP(${LiteTable.timeRollingUpdated}) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds} THEN ${LiteTable.timeRollingUpdated} - ELSE now() - END - `, - }) - .where(and(eq(LiteTable.workspaceID, authInfo.workspaceID), eq(LiteTable.userID, authInfo.user.id))), - ] - } - - // Batched hot workspace: skip DB writes unless this request is the flush. - if (balanceFlush.batched && !balanceFlush.flush) return [] - - const workspaceDelta = balanceFlush.flush?.workspaceCost ?? cost - const userDelta = balanceFlush.flush?.userCost ?? cost - const balanceDelta = billingSource === "free" || billingSource === "byok" ? 0 : workspaceDelta - - return [ - db - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${balanceDelta}`, - monthlyUsage: sql` - CASE - WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${workspaceDelta} - ELSE ${workspaceDelta} - END - `, - timeMonthlyUsageUpdated: sql`now()`, - }) - .where(eq(BillingTable.workspaceID, authInfo.workspaceID)), - db - .update(UserTable) - .set({ - monthlyUsage: sql` - CASE - WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${userDelta} - ELSE ${userDelta} - END - `, - timeMonthlyUsageUpdated: sql`now()`, - }) - .where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))), - ] - })(), - ]), - ) - - return { costInMicroCents: cost } - } - - async function reload(billingSource: BillingSource, authInfo: AuthInfo, costInfo: CostInfo) { - if (billingSource !== "balance") return - authInfo = authInfo! - - const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100) - if (authInfo.billing.balance - costInfo.totalCostInCent >= reloadTrigger) return - if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return - - const lock = await Database.use((tx) => - tx - .update(BillingTable) - .set({ - timeReloadLockedTill: sql`now() + interval 1 minute`, - }) - .where( - and( - eq(BillingTable.workspaceID, authInfo.workspaceID), - eq(BillingTable.reload, true), - lt(BillingTable.balance, reloadTrigger), - or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)), - ), - ), - ) - if (lock.rowsAffected === 0) return - - await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => { - await Billing.reload() - }) - } + const key = opts.parseApiKey(input.request.headers) + if (!key || key === "public") return error(401, "AuthError", dict["zen.api.error.missingApiKey"]) + return error(401, "AuthError", dict["zen.api.error.invalidApiKey"]) +} + +// Both top level "type" and "error.type" are used by the @ai-sdk/anthropic client to render the error message. +function error(status: number, type: string, message: string) { + return new Response(JSON.stringify({ type: "error", error: { type, message } }), { status }) } diff --git a/packages/console/app/src/routes/zen/util/ipRateLimiter.ts b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts deleted file mode 100644 index 4f4ad7f1000..00000000000 --- a/packages/console/app/src/routes/zen/util/ipRateLimiter.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FreeUsageLimitError } from "./error" -import { logger } from "./logger" -import { buildRateLimitKey, getRedis } from "./redis" -import { i18n } from "~/i18n" -import { localeFromRequest } from "~/lib/language" -import { Subscription } from "@opencode/console-core/subscription.js" - -export function createRateLimiter(modelId: string, rateLimit: number | undefined, rawIp: string, request: Request) { - const dict = i18n(localeFromRequest(request)) - - const limits = Subscription.getFreeLimits() - // temporarily disable check headers - //const headersExist = Object.entries(limits.checkHeaders).every( - // ([name, value]) => request.headers.get(name)?.toLowerCase().includes(value) ?? false, - //) - //const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests) - const headersExist = true - const dailyLimit = !headersExist ? limits.dailyRequestsFallback : (rateLimit ?? limits.dailyRequests) - const isDefaultModel = headersExist && !rateLimit - - const ip = !rawIp.length ? "unknown" : rawIp - const now = Date.now() - const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now) - const retryAfter = getRetryAfterDay(now) - const redis = getRedis() - const lifetimeKey = buildRateLimitKey("ip", ip) - const dailyKey = buildRateLimitKey("ip", ip, dailyInterval) - let isNew = false - - return { - check: async () => { - const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]) - const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0 - const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0) - logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`) - - isNew = isDefaultModel && lifetimeCount < dailyLimit * 7 - - if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit)) - throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter) - }, - track: async () => { - const pipeline = redis.pipeline() - pipeline.incr(dailyKey) - pipeline.expire(dailyKey, retryAfter) - if (isNew) pipeline.incr(lifetimeKey) - await pipeline.exec() - }, - } -} - -export function getRetryAfterDay(now: number) { - return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000) -} - -function buildYYYYMMDD(timestamp: number) { - return new Date(timestamp) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 8) -} diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts deleted file mode 100644 index fdf5925299e..00000000000 --- a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { RateLimitError } from "./error" -import { buildRateLimitKey, getRedis } from "./redis" -import { i18n } from "~/i18n" -import { localeFromRequest } from "~/lib/language" - -export function createRateLimiter( - modelId: string, - rateLimit: number | undefined, - zenApiKey: string | undefined, - request: Request, -) { - if (!zenApiKey) return - const dict = i18n(localeFromRequest(request)) - - const LIMIT = rateLimit ?? 1000 - const yyyyMMddHHmm = new Date(Date.now()) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12) - const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}` - const redis = getRedis() - const key = buildRateLimitKey("key", zenApiKey, interval) - - return { - check: async () => { - const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0) - - if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60) - }, - track: async () => { - const pipeline = redis.pipeline() - pipeline.incr(key) - pipeline.expire(key, 60) - await pipeline.exec() - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/logger.ts b/packages/console/app/src/routes/zen/util/logger.ts deleted file mode 100644 index 6db759e7137..00000000000 --- a/packages/console/app/src/routes/zen/util/logger.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Resource } from "@opencode/console-resource" - -export const logger = { - metric: (values: Record) => { - console.log(`_metric:${JSON.stringify(values)}`) - }, - log: console.log, - debug: (message: string) => { - if (Resource.App.stage === "production") return - console.debug(message) - }, -} diff --git a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts deleted file mode 100644 index 87dc27e34cb..00000000000 --- a/packages/console/app/src/routes/zen/util/modelTpmLimiter.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { and, Database, eq, inArray, sql } from "@opencode/console-core/drizzle/index.js" -import { ModelTpmRateLimitTable } from "@opencode/console-core/schema/ip.sql.js" -import { UsageInfo } from "./provider/provider" - -export function createModelTpmLimiter(providers: { id: string; model: string; tpmLimit?: number }[]) { - const ids = providers.filter((p) => p.tpmLimit).map((p) => `${p.id}/${p.model}`) - if (ids.length === 0) return - - const yyyyMMddHHmm = parseInt( - new Date(Date.now()) - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12), - ) - - return { - check: async () => { - const data = await Database.use((tx) => - tx - .select() - .from(ModelTpmRateLimitTable) - .where(and(inArray(ModelTpmRateLimitTable.id, ids), eq(ModelTpmRateLimitTable.interval, yyyyMMddHHmm))), - ) - - // convert to map of model to count - return data.reduce( - (acc, curr) => { - acc[curr.id] = curr.count - return acc - }, - {} as Record, - ) - }, - track: async (provider: string, model: string, usageInfo: UsageInfo) => { - const id = `${provider}/${model}` - if (!ids.includes(id)) return - const usage = usageInfo.inputTokens - if (usage <= 0) return - await Database.use((tx) => - tx - .insert(ModelTpmRateLimitTable) - .values({ id, interval: yyyyMMddHHmm, count: usage }) - .onDuplicateKeyUpdate({ set: { count: sql`${ModelTpmRateLimitTable.count} + ${usage}` } }), - ) - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts b/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts deleted file mode 100644 index ce3d1a0242a..00000000000 --- a/packages/console/app/src/routes/zen/util/modelTpsLimiter.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { and, Database, inArray, sql } from "@opencode/console-core/drizzle/index.js" -import { ModelTpsRateLimitTable } from "@opencode/console-core/schema/ip.sql.js" -import { UsageInfo } from "./provider/provider" - -export function createModelTpsLimiter(providers: { id: string; model: string; tpsGoal?: number }[]) { - const tpsGoals = Object.fromEntries( - providers.flatMap((p) => { - return p.tpsGoal ? [[`${p.id}/${p.model}/${p.tpsGoal}`, p.tpsGoal]] : [] - }), - ) - const ids = Object.keys(tpsGoals) - if (ids.length === 0) return - - const toInterval = (date: Date) => - parseInt( - date - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12), - ) - const now = Date.now() - const currInterval = toInterval(new Date(now)) - const prevInterval = toInterval(new Date(now - 60_000)) - - return { - check: async () => { - const data = await Database.use((tx) => - tx - .select() - .from(ModelTpsRateLimitTable) - .where( - and( - inArray(ModelTpsRateLimitTable.id, ids), - inArray(ModelTpsRateLimitTable.interval, [currInterval, prevInterval]), - ), - ), - ) - - // convert to map of model to summed count across current and previous intervals - return data.reduce( - (acc, curr) => { - const existing = acc[curr.id] ?? { qualify: 0, unqualify: 0 } - acc[curr.id] = { - qualify: existing.qualify + curr.qualify, - unqualify: existing.unqualify + curr.unqualify, - } - return acc - }, - {} as Record, - ) - }, - track: async ( - provider: string, - model: string, - tpsGoal: number | undefined, - tsFirstByte: number, - tsLastByte: number, - usageInfo: UsageInfo, - ) => { - if (!tpsGoal) return - const id = `${provider}/${model}/${tpsGoal}` - if (!ids.includes(id)) return - if (tsFirstByte <= 0 || tsLastByte <= 0) return - const tokens = usageInfo.outputTokens - if (tokens <= 10) return - - const tps = (tokens / (tsLastByte - tsFirstByte)) * 1000 - const qualify = tps >= tpsGoal ? 1 : 0 - const unqualify = tps < tpsGoal ? 1 : 0 - await Database.use((tx) => - tx - .insert(ModelTpsRateLimitTable) - .values({ - id, - interval: currInterval, - qualify, - unqualify, - }) - .onDuplicateKeyUpdate({ - set: { - qualify: sql`${ModelTpsRateLimitTable.qualify} + ${qualify}`, - unqualify: sql`${ModelTpsRateLimitTable.unqualify} + ${unqualify}`, - }, - }), - ) - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/modelsHandler.ts b/packages/console/app/src/routes/zen/util/modelsHandler.ts index 102e4151b9f..21a81c98f3e 100644 --- a/packages/console/app/src/routes/zen/util/modelsHandler.ts +++ b/packages/console/app/src/routes/zen/util/modelsHandler.ts @@ -8,24 +8,3 @@ export async function buildOptionsResponse() { }, }) } - -export async function buildModelsResponse(models: string[]) { - return new Response( - JSON.stringify({ - object: "list", - data: models - .filter((id) => !id.startsWith("alpha-")) - .map((id) => ({ - id, - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: "opencode", - })), - }), - { - headers: { - "Content-Type": "application/json", - }, - }, - ) -} diff --git a/packages/console/app/src/routes/zen/util/pricing.ts b/packages/console/app/src/routes/zen/util/pricing.ts deleted file mode 100644 index b7b6def61ec..00000000000 --- a/packages/console/app/src/routes/zen/util/pricing.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function isPeakPricing(date: Date) { - // DeepSeek peak pricing in China Standard Time (UTC+8): - // - Weekdays only - // - 9 AM to noon - // - 2 PM to 6 PM - const dateCN = new Date(date.getTime() + 8 * 3_600 * 1000) - const dayCN = dateCN.getUTCDay() - if (dayCN === 0 || dayCN === 6) return false - const hourCN = dateCN.getUTCHours() - return (hourCN >= 9 && hourCN < 12) || (hourCN >= 14 && hourCN < 18) -} diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts deleted file mode 100644 index e975c2fc74b..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { EventStreamCodec } from "@smithy/eventstream-codec" -import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider" -import { fromUtf8, toUtf8 } from "@smithy/util-utf8" - -type Usage = { - cache_creation?: { - ephemeral_5m_input_tokens?: number - ephemeral_1h_input_tokens?: number - } - cache_creation_input_tokens?: number - cache_read_input_tokens?: number - input_tokens?: number - output_tokens?: number - server_tool_use?: { - web_search_requests?: number - } -} - -export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => { - const isBedrockModelArn = providerModel.startsWith("arn:aws:bedrock:") - const isBedrockModelID = providerModel.startsWith("global.anthropic.") - const isBedrock = isBedrockModelArn || isBedrockModelID - const isDatabricks = providerModel.startsWith("databricks-claude-") - const supports1m = reqModel.includes("sonnet") || reqModel.includes("opus-4-6") - return { - format: "anthropic", - modifyUrl: (providerApi: string, isStream?: boolean) => - isBedrock - ? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}` - : providerApi + "/messages", - modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { - if (isBedrock || isDatabricks) { - headers.set("Authorization", `Bearer ${apiKey}`) - } else { - headers.set("x-api-key", apiKey) - headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01") - if (supports1m) { - headers.set("anthropic-beta", "context-1m-2025-08-07") - } - } - }, - modifyBody: (body: Record) => ({ - ...body, - ...(isBedrock - ? { - anthropic_version: "bedrock-2023-05-31", - anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined, - model: undefined, - stream: undefined, - } - : isDatabricks - ? { - anthropic_version: "bedrock-2023-05-31", - anthropic_beta: supports1m ? ["context-1m-2025-08-07"] : undefined, - } - : {}), - }), - createBinaryStreamDecoder: () => { - if (!isBedrock) return undefined - - const decoder = new TextDecoder() - const encoder = new TextEncoder() - const codec = new EventStreamCodec(toUtf8, fromUtf8) - let buffer = new Uint8Array(0) - return (value: Uint8Array) => { - const newBuffer = new Uint8Array(buffer.length + value.length) - newBuffer.set(buffer) - newBuffer.set(value, buffer.length) - buffer = newBuffer - - const messages = [] - while (buffer.length >= 4) { - // first 4 bytes are the total length (big-endian) - const totalLength = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength).getUint32(0, false) - - // wait for more chunks - if (buffer.length < totalLength) break - - try { - const subView = buffer.subarray(0, totalLength) - const decoded = codec.decode(subView) - buffer = buffer.slice(totalLength) - - /* Example of Bedrock data - ``` - { - bytes: 'eyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtb3B1cy00LTUtMjAyNTExMDEiLCJpZCI6Im1zZ19iZHJrXzAxMjVGdHRGb2lkNGlwWmZ4SzZMbktxeCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjo0LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjEsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxMTk2MywiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6MSwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjF9fX0=', - p: '...' - } - ``` - - Decoded bytes - ``` - { - type: 'message_start', - message: { - model: 'claude-opus-4-5-20251101', - id: 'msg_bdrk_0125FttFoid4ipZfxK6LnKqx', - type: 'message', - role: 'assistant', - content: [], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 4, - cache_creation_input_tokens: 1, - cache_read_input_tokens: 11963, - cache_creation: [Object], - output_tokens: 1 - } - } - } - ``` - */ - - /* Example of Anthropic data - ``` - event: message_delta - data: {"type":"message_start","message":{"model":"claude-opus-4-5-20251101","id":"msg_01ETvwVWSKULxzPdkQ1xAnk2","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":11543,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":11543,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}} - ``` - */ - if (decoded.headers[":message-type"]?.value === "event") { - const data = decoder.decode(decoded.body, { stream: true }) - - const parsedDataResult = JSON.parse(data) - delete parsedDataResult.p - const binary = atob(parsedDataResult.bytes) - const uint8 = Uint8Array.from(binary, (c) => c.charCodeAt(0)) - const bytes = decoder.decode(uint8) - const eventName = JSON.parse(bytes).type - messages.push([`event: ${eventName}`, "\n", `data: ${bytes}`, "\n\n"].join("")) - } - } catch (e) { - console.log("@@@EE@@@") - console.log(e) - break - } - } - return encoder.encode(messages.join("")) - } - }, - createUsageParser: () => { - let usage: Usage - - return { - parse: (chunk: string) => { - const data = chunk.split("\n")[1] - // Claude models start with "data: {" - // Alibaba models start with "data:{" - if (!data.startsWith("data:")) return - - let json - try { - json = JSON.parse(data.replace(/^data:\s*/, "")) - } catch { - return - } - - const usageUpdate = json.usage ?? json.message?.usage - if (!usageUpdate) return - usage = { - ...usage, - ...usageUpdate, - cache_creation: { - ...usage?.cache_creation, - ...usageUpdate.cache_creation, - }, - server_tool_use: { - ...usage?.server_tool_use, - ...usageUpdate.server_tool_use, - }, - } - }, - retrieve: () => usage, - } - }, - extractUsage: (response: any) => response.usage, - normalizeUsage: (usage: Usage) => ({ - inputTokens: usage.input_tokens ?? 0, - outputTokens: usage.output_tokens ?? 0, - reasoningTokens: undefined, - cacheReadTokens: usage.cache_read_input_tokens ?? undefined, - cacheWrite5mTokens: - usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens ?? undefined, - cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined, - }), - } -} - -export function fromAnthropicRequest(body: any): CommonRequest { - if (!body || typeof body !== "object") return body - - const msgs: any[] = [] - - const sys = Array.isArray(body.system) ? body.system : undefined - if (sys && sys.length > 0) { - for (const s of sys) { - if (!s) continue - if ((s as any).type !== "text") continue - if (typeof (s as any).text !== "string") continue - if ((s as any).text.length === 0) continue - msgs.push({ role: "system", content: (s as any).text }) - } - } - - const toImg = (src: any) => { - if (!src || typeof src !== "object") return undefined - if ((src as any).type === "url" && typeof (src as any).url === "string") - return { type: "image_url", image_url: { url: (src as any).url } } - if ( - (src as any).type === "base64" && - typeof (src as any).media_type === "string" && - typeof (src as any).data === "string" - ) - return { - type: "image_url", - image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` }, - } - return undefined - } - - const inMsgs = Array.isArray(body.messages) ? body.messages : [] - for (const m of inMsgs) { - if (!m || !(m as any).role) continue - - if ((m as any).role === "user") { - const partsIn = Array.isArray((m as any).content) ? (m as any).content : [] - const partsOut: any[] = [] - for (const p of partsIn) { - if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") - partsOut.push({ type: "text", text: (p as any).text }) - if ((p as any).type === "image") { - const ip = toImg((p as any).source) - if (ip) partsOut.push(ip) - } - if ((p as any).type === "tool_result") { - const id = (p as any).tool_use_id - const content = - typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content) - msgs.push({ role: "tool", tool_call_id: id, content }) - } - } - if (partsOut.length > 0) { - if (partsOut.length === 1 && partsOut[0].type === "text") msgs.push({ role: "user", content: partsOut[0].text }) - else msgs.push({ role: "user", content: partsOut }) - } - continue - } - - if ((m as any).role === "assistant") { - const partsIn = Array.isArray((m as any).content) ? (m as any).content : [] - const texts: string[] = [] - const tcs: any[] = [] - for (const p of partsIn) { - if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text) - if ((p as any).type === "tool_use") { - const name = (p as any).name - const id = (p as any).id - const inp = (p as any).input - const input = (() => { - if (typeof inp === "string") return inp - try { - return JSON.stringify(inp ?? {}) - } catch { - return String(inp ?? "") - } - })() - tcs.push({ id, type: "function", function: { name, arguments: input } }) - } - } - const out: any = { role: "assistant", content: texts.join("") } - if (tcs.length > 0) out.tool_calls = tcs - msgs.push(out) - continue - } - } - - const tools = Array.isArray(body.tools) - ? body.tools - .filter((t: any) => t && typeof t === "object" && "input_schema" in t) - .map((t: any) => ({ - type: "function", - function: { - name: (t as any).name, - description: (t as any).description, - parameters: (t as any).input_schema, - }, - })) - : undefined - - const tcin = body.tool_choice - const tc = (() => { - if (!tcin) return undefined - if ((tcin as any).type === "auto") return "auto" - if ((tcin as any).type === "any") return "required" - if ((tcin as any).type === "tool" && typeof (tcin as any).name === "string") - return { type: "function" as const, function: { name: (tcin as any).name } } - return undefined - })() - - const stop = (() => { - const v = body.stop_sequences - if (!v) return undefined - if (Array.isArray(v)) return v.length === 1 ? v[0] : v - if (typeof v === "string") return v - return undefined - })() - - return { - model: body.model, - max_tokens: body.max_tokens, - temperature: body.temperature, - top_p: body.top_p, - stop, - messages: msgs, - stream: !!body.stream, - tools, - tool_choice: tc, - } -} - -export function toAnthropicRequest(body: CommonRequest) { - if (!body || typeof body !== "object") return body - - const sysIn = Array.isArray(body.messages) ? body.messages.filter((m: any) => m && m.role === "system") : [] - let ccCount = 0 - const cc = () => { - ccCount++ - return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {} - } - const system = sysIn - .filter((m: any) => typeof m.content === "string" && m.content.length > 0) - .map((m: any) => ({ type: "text", text: m.content, ...cc() })) - - const msgsIn = Array.isArray(body.messages) ? body.messages : [] - const msgsOut: any[] = [] - - const toSrc = (p: any) => { - if (!p || typeof p !== "object") return undefined - if ((p as any).type === "image_url" && (p as any).image_url) { - const u = (p as any).image_url.url ?? (p as any).image_url - if (typeof u === "string" && u.startsWith("data:")) { - const m = u.match(/^data:([^;]+);base64,(.*)$/) - if (m) return { type: "base64", media_type: m[1], data: m[2] } - } - if (typeof u === "string") return { type: "url", url: u } - } - return undefined - } - - for (const m of msgsIn) { - if (!m || !(m as any).role) continue - - if ((m as any).role === "user") { - if (typeof (m as any).content === "string") { - msgsOut.push({ - role: "user", - content: [{ type: "text", text: (m as any).content, ...cc() }], - }) - } else if (Array.isArray((m as any).content)) { - const parts: any[] = [] - for (const p of (m as any).content) { - if (!p || !(p as any).type) continue - if ((p as any).type === "text" && typeof (p as any).text === "string") - parts.push({ type: "text", text: (p as any).text, ...cc() }) - if ((p as any).type === "image_url") { - const s = toSrc(p) - if (s) parts.push({ type: "image", source: s, ...cc() }) - } - } - if (parts.length > 0) msgsOut.push({ role: "user", content: parts }) - } - continue - } - - if ((m as any).role === "assistant") { - const out: any = { role: "assistant", content: [] as any[] } - if (typeof (m as any).content === "string" && (m as any).content.length > 0) { - ;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() }) - } - if (Array.isArray((m as any).tool_calls)) { - for (const tc of (m as any).tool_calls) { - if ((tc as any).type === "function" && (tc as any).function) { - let input: any - const a = (tc as any).function.arguments - if (typeof a === "string") { - try { - input = JSON.parse(a) - } catch { - input = a - } - } else input = a - const id = (tc as any).id || `toolu_${Math.random().toString(36).slice(2)}` - ;(out.content as any[]).push({ - type: "tool_use", - id, - name: (tc as any).function.name, - input, - ...cc(), - }) - } - } - } - if ((out.content as any[]).length > 0) msgsOut.push(out) - continue - } - - if ((m as any).role === "tool") { - msgsOut.push({ - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: (m as any).tool_call_id, - content: (m as any).content, - ...cc(), - }, - ], - }) - continue - } - } - - const tools = Array.isArray(body.tools) - ? body.tools - .filter((t: any) => t && typeof t === "object" && (t as any).type === "function") - .map((t: any) => ({ - name: (t as any).function.name, - description: (t as any).function.description, - input_schema: (t as any).function.parameters, - ...cc(), - })) - : undefined - - const tcIn = body.tool_choice - const tool_choice = (() => { - if (!tcIn) return undefined - if (tcIn === "auto") return { type: "auto" } - if (tcIn === "required") return { type: "any" } - if ((tcIn as any).type === "function" && (tcIn as any).function?.name) - return { type: "tool", name: (tcIn as any).function.name } - return undefined - })() - - const stop_sequences = (() => { - const v = body.stop - if (!v) return undefined - if (Array.isArray(v)) return v - if (typeof v === "string") return [v] - return undefined - })() - - return { - max_tokens: body.max_tokens ?? 32_000, - temperature: body.temperature, - top_p: body.top_p, - system: system.length > 0 ? system : undefined, - messages: msgsOut, - stream: !!body.stream, - tools, - tool_choice, - stop_sequences, - } -} - -export function fromAnthropicResponse(resp: any): CommonResponse { - if (!resp || typeof resp !== "object") return resp - - if (Array.isArray((resp as any).choices)) return resp - - const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message" - if (!isAnthropic) return resp - - const idIn = (resp as any).id - const id = - typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}` - const model = (resp as any).model - - const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : [] - const text = blocks - .filter((b) => b && b.type === "text" && typeof (b as any).text === "string") - .map((b: any) => b.text) - .join("") - const tcs = blocks - .filter((b) => b && b.type === "tool_use") - .map((b: any) => { - const name = (b as any).name - const args = (() => { - const inp = (b as any).input - if (typeof inp === "string") return inp - try { - return JSON.stringify(inp ?? {}) - } catch { - return String(inp ?? "") - } - })() - const tid = - typeof (b as any).id === "string" && (b as any).id.length > 0 - ? (b as any).id - : `toolu_${Math.random().toString(36).slice(2)}` - return { id: tid, type: "function" as const, function: { name, arguments: args } } - }) - - const finish = (r: string | null) => { - if (r === "end_turn") return "stop" - if (r === "tool_use") return "tool_calls" - if (r === "max_tokens") return "length" - if (r === "content_filter") return "content_filter" - return null - } - - const u = (resp as any).usage - const usage = (() => { - if (!u) return undefined as any - const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined - const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined - const total = pt != null && ct != null ? pt + ct : undefined - const cached = - typeof (u as any).cache_read_input_tokens === "number" ? (u as any).cache_read_input_tokens : undefined - const details = cached != null ? { cached_tokens: cached } : undefined - return { - prompt_tokens: pt, - completion_tokens: ct, - total_tokens: total, - ...(details ? { prompt_tokens_details: details } : {}), - } - })() - - return { - id, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - ...(text && text.length > 0 ? { content: text } : {}), - ...(tcs.length > 0 ? { tool_calls: tcs } : {}), - }, - finish_reason: finish((resp as any).stop_reason ?? null), - }, - ], - ...(usage ? { usage } : {}), - } -} - -export function toAnthropicResponse(resp: CommonResponse) { - if (!resp || typeof resp !== "object") return resp - - if (!Array.isArray((resp as any).choices)) return resp - - const choice = (resp as any).choices[0] - if (!choice) return resp - - const message = choice.message - if (!message) return resp - - const content: any[] = [] - - if (typeof message.content === "string" && message.content.length > 0) - content.push({ type: "text", text: message.content }) - - if (Array.isArray(message.tool_calls)) { - for (const tc of message.tool_calls) { - if ((tc as any).type === "function" && (tc as any).function) { - let input: any - try { - input = JSON.parse((tc as any).function.arguments) - } catch { - input = (tc as any).function.arguments - } - content.push({ - type: "tool_use", - id: (tc as any).id, - name: (tc as any).function.name, - input, - }) - } - } - } - - const stop_reason = (() => { - const r = choice.finish_reason - if (r === "stop") return "end_turn" - if (r === "tool_calls") return "tool_use" - if (r === "length") return "max_tokens" - if (r === "content_filter") return "content_filter" - return null - })() - - const usage = (() => { - const u = (resp as any).usage - if (!u) return undefined - return { - input_tokens: u.prompt_tokens, - output_tokens: u.completion_tokens, - cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens, - } - })() - - return { - id: (resp as any).id, - type: "message", - role: "assistant", - content: content.length > 0 ? content : [{ type: "text", text: "" }], - model: (resp as any).model, - stop_reason, - usage, - } -} - -export function fromAnthropicChunk(chunk: string): CommonChunk | string { - // Anthropic sends two lines per part: "event: \n" + "data: " - const lines = chunk.split("\n") - const dataLine = lines.find((l) => l.startsWith("data: ")) - if (!dataLine) return chunk - - let json - try { - json = JSON.parse(dataLine.slice(6)) - } catch { - return chunk - } - - const out: CommonChunk = { - id: json.id ?? json.message?.id ?? "", - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: json.model ?? json.message?.model ?? "", - choices: [], - } - - if (json.type === "content_block_start") { - const cb = json.content_block - if (cb?.type === "text") { - out.choices.push({ - index: json.index ?? 0, - delta: { role: "assistant", content: "" }, - finish_reason: null, - }) - } else if (cb?.type === "tool_use") { - out.choices.push({ - index: json.index ?? 0, - delta: { - tool_calls: [ - { - index: json.index ?? 0, - id: cb.id, - type: "function", - function: { name: cb.name, arguments: "" }, - }, - ], - }, - finish_reason: null, - }) - } - } - - if (json.type === "content_block_delta") { - const d = json.delta - if (d?.type === "text_delta") { - out.choices.push({ index: json.index ?? 0, delta: { content: d.text }, finish_reason: null }) - } else if (d?.type === "input_json_delta") { - out.choices.push({ - index: json.index ?? 0, - delta: { - tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }], - }, - finish_reason: null, - }) - } - } - - if (json.type === "message_delta") { - const d = json.delta - const finish_reason = (() => { - const r = d?.stop_reason - if (r === "end_turn") return "stop" - if (r === "tool_use") return "tool_calls" - if (r === "max_tokens") return "length" - if (r === "content_filter") return "content_filter" - return null - })() - - out.choices.push({ index: 0, delta: {}, finish_reason }) - } - - if (json.usage) { - const u = json.usage - out.usage = { - prompt_tokens: u.input_tokens, - completion_tokens: u.output_tokens, - total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0), - ...(u.cache_read_input_tokens ? { prompt_tokens_details: { cached_tokens: u.cache_read_input_tokens } } : {}), - } - } - - return out -} - -export function toAnthropicChunk(chunk: CommonChunk): string { - if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) { - return JSON.stringify({}) - } - - const choice = chunk.choices[0] - const delta = choice.delta - if (!delta) return JSON.stringify({}) - - const result: any = {} - - if (delta.content) { - result.type = "content_block_delta" - result.index = 0 - result.delta = { type: "text_delta", text: delta.content } - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - if (tc.function?.name) { - result.type = "content_block_start" - result.index = tc.index ?? 0 - result.content_block = { type: "tool_use", id: tc.id, name: tc.function.name, input: {} } - } else if (tc.function?.arguments) { - result.type = "content_block_delta" - result.index = tc.index ?? 0 - result.delta = { type: "input_json_delta", partial_json: tc.function.arguments } - } - } - } - - if (choice.finish_reason) { - const stop_reason = (() => { - const r = choice.finish_reason - if (r === "stop") return "end_turn" - if (r === "tool_calls") return "tool_use" - if (r === "length") return "max_tokens" - if (r === "content_filter") return "content_filter" - return null - })() - result.type = "message_delta" - result.delta = { stop_reason, stop_sequence: null } - } - - if (chunk.usage) { - const u = chunk.usage - result.usage = { - input_tokens: u.prompt_tokens, - output_tokens: u.completion_tokens, - cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens, - } - } - - return JSON.stringify(result) -} diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts deleted file mode 100644 index 1bc2ce1f210..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/google.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { ProviderHelper } from "./provider" - -/* -{ - promptTokenCount: 11453, - candidatesTokenCount: 71, - totalTokenCount: 11625, - cachedContentTokenCount: 8100, - promptTokensDetails: [ - {modality: "TEXT",tokenCount: 11453} - ], - cacheTokensDetails: [ - {modality: "TEXT",tokenCount: 8100} - ], - thoughtsTokenCount: 101 -} -*/ - -type Usage = { - promptTokenCount?: number - candidatesTokenCount?: number - totalTokenCount?: number - cachedContentTokenCount?: number - promptTokensDetails?: { modality: string; tokenCount: number }[] - cacheTokensDetails?: { modality: string; tokenCount: number }[] - thoughtsTokenCount?: number -} - -export const googleHelper: ProviderHelper = ({ providerModel }) => ({ - format: "google", - modifyUrl: (providerApi: string, isStream?: boolean) => - `${providerApi}/models/${providerModel}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`, - modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { - headers.set("x-goog-api-key", apiKey) - }, - modifyBody: (body: Record) => { - return body - }, - createBinaryStreamDecoder: () => undefined, - createUsageParser: () => { - let usage: Usage - - return { - parse: (chunk: string) => { - if (!chunk.startsWith("data: ")) return - - let json - try { - json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage } - } catch { - return - } - - if (!json.usageMetadata) return - usage = json.usageMetadata - }, - retrieve: () => usage, - } - }, - extractUsage: (response: any) => response.usageMetadata, - normalizeUsage: (usage: Usage) => { - const inputTokens = usage.promptTokenCount ?? 0 - const outputTokens = usage.candidatesTokenCount ?? 0 - const reasoningTokens = usage.thoughtsTokenCount ?? 0 - const cacheReadTokens = usage.cachedContentTokenCount ?? 0 - return { - inputTokens: inputTokens - cacheReadTokens, - outputTokens: outputTokens + reasoningTokens, - reasoningTokens, - cacheReadTokens, - cacheWrite5mTokens: undefined, - cacheWrite1hTokens: undefined, - } - }, -}) diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts deleted file mode 100644 index 666ed52f97e..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ /dev/null @@ -1,555 +0,0 @@ -import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider" - -type Usage = { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - // used by moonshot - cached_tokens?: number - // used by xai & alibaba - prompt_tokens_details?: { - text_tokens?: number - audio_tokens?: number - image_tokens?: number - cached_tokens?: number - // used by alibaba - cache_creation_input_tokens?: number - } - completion_tokens_details?: { - reasoning_tokens?: number - audio_tokens?: number - accepted_prediction_tokens?: number - rejected_prediction_tokens?: number - } -} - -export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ - format: "oa-compat", - modifyUrl: (providerApi: string) => providerApi + "/chat/completions", - modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => { - headers.set("authorization", `Bearer ${apiKey}`) - headers.set("x-session-affinity", stickyId) - }, - modifyBody: (body: Record, _workspaceID?: string) => { - return { - ...body, - ...(body.stream ? { stream_options: { include_usage: true } } : {}), - } - }, - createBinaryStreamDecoder: () => undefined, - createUsageParser: () => { - let usage: Usage - - return { - parse: (chunk: string) => { - if (!chunk.startsWith("data: ")) return - - let json - try { - json = JSON.parse(chunk.slice(6)) as { usage?: Usage } - } catch { - return - } - - if (!json.usage) return - usage = json.usage - }, - retrieve: () => usage, - } - }, - extractUsage: (response: any) => response.usage, - normalizeUsage: (usage: Usage) => { - let inputTokens = usage.prompt_tokens ?? 0 - const outputTokens = usage.completion_tokens ?? 0 - const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined - let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined - const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined - - if (adjustCacheUsage && !cacheReadTokens) { - cacheReadTokens = Math.floor(inputTokens * 0.9) - } - - return { - inputTokens: inputTokens - (cacheReadTokens ?? 0), - outputTokens, - reasoningTokens, - cacheReadTokens, - cacheWrite5mTokens: cacheWriteTokens, - cacheWrite1hTokens: undefined, - } - }, -}) - -export function fromOaCompatibleRequest(body: any): CommonRequest { - if (!body || typeof body !== "object") return body - - const msgsIn = Array.isArray(body.messages) ? body.messages : [] - const msgsOut: any[] = [] - - for (const m of msgsIn) { - if (!m || !m.role) continue - - if (m.role === "system") { - if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content }) - continue - } - - if (m.role === "user") { - if (typeof m.content === "string") { - msgsOut.push({ role: "user", content: m.content }) - } else if (Array.isArray(m.content)) { - const parts: any[] = [] - for (const p of m.content) { - if (!p || !p.type) continue - if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text }) - if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url }) - } - if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text }) - else if (parts.length > 0) msgsOut.push({ role: "user", content: parts }) - } - continue - } - - if (m.role === "assistant") { - const out: any = { role: "assistant" } - if (typeof m.content === "string") out.content = m.content - if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls - msgsOut.push(out) - continue - } - - if (m.role === "tool") { - msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content }) - continue - } - } - - return { - model: body.model, - max_tokens: body.max_tokens, - temperature: body.temperature, - top_p: body.top_p, - stop: body.stop, - messages: msgsOut, - stream: !!body.stream, - tools: Array.isArray(body.tools) ? body.tools : undefined, - tool_choice: body.tool_choice, - } -} - -export function toOaCompatibleRequest(body: CommonRequest) { - if (!body || typeof body !== "object") return body - - const msgsIn = Array.isArray(body.messages) ? body.messages : [] - const msgsOut: any[] = [] - - const toImg = (p: any) => { - if (!p || typeof p !== "object") return undefined - if (p.type === "image_url" && p.image_url) return { type: "image_url", image_url: p.image_url } - const s = (p as any).source - if (!s || typeof s !== "object") return undefined - if (s.type === "url" && typeof s.url === "string") return { type: "image_url", image_url: { url: s.url } } - if (s.type === "base64" && typeof s.media_type === "string" && typeof s.data === "string") - return { type: "image_url", image_url: { url: `data:${s.media_type};base64,${s.data}` } } - return undefined - } - - for (const m of msgsIn) { - if (!m || !m.role) continue - - if (m.role === "system") { - if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content }) - continue - } - - if (m.role === "user") { - if (typeof m.content === "string") { - msgsOut.push({ role: "user", content: m.content }) - continue - } - if (Array.isArray(m.content)) { - const parts: any[] = [] - for (const p of m.content) { - if (!p || !p.type) continue - if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text }) - const ip = toImg(p) - if (ip) parts.push(ip) - } - if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text }) - else if (parts.length > 0) msgsOut.push({ role: "user", content: parts }) - } - continue - } - - if (m.role === "assistant") { - const out: any = { role: "assistant" } - if (typeof m.content === "string") out.content = m.content - if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls - msgsOut.push(out) - continue - } - - if (m.role === "tool") { - msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content }) - continue - } - } - - const tools = Array.isArray(body.tools) - ? body.tools.map((tool: any) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - })) - : undefined - - return { - model: body.model, - max_tokens: body.max_tokens, - temperature: body.temperature, - top_p: body.top_p, - stop: body.stop, - messages: msgsOut, - stream: !!body.stream, - tools, - tool_choice: body.tool_choice, - response_format: (body as any).response_format, - } -} - -export function fromOaCompatibleResponse(resp: any): CommonResponse { - if (!resp || typeof resp !== "object") return resp - - if (!Array.isArray((resp as any).choices)) return resp - - const choice = (resp as any).choices[0] - if (!choice) return resp - - const message = choice.message - if (!message) return resp - - const content: any[] = [] - - if (typeof message.content === "string" && message.content.length > 0) { - content.push({ type: "text", text: message.content }) - } - - if (Array.isArray(message.tool_calls)) { - for (const toolCall of message.tool_calls) { - if (toolCall.type === "function" && toolCall.function) { - let input - try { - input = JSON.parse(toolCall.function.arguments) - } catch { - input = toolCall.function.arguments - } - content.push({ - type: "tool_use", - id: toolCall.id, - name: toolCall.function.name, - input, - }) - } - } - } - - const stopReason = (() => { - const reason = choice.finish_reason - if (reason === "stop") return "stop" - if (reason === "tool_calls") return "tool_calls" - if (reason === "length") return "length" - if (reason === "content_filter") return "content_filter" - return null - })() - - const usage = (() => { - const u = (resp as any).usage - if (!u) return undefined - return { - prompt_tokens: u.prompt_tokens, - completion_tokens: u.completion_tokens, - total_tokens: u.total_tokens, - ...(u.prompt_tokens_details?.cached_tokens - ? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } - : {}), - } - })() - - return { - id: (resp as any).id, - object: "chat.completion" as const, - created: Math.floor(Date.now() / 1000), - model: (resp as any).model, - choices: [ - { - index: 0, - message: { - role: "assistant" as const, - ...(content.some((c) => c.type === "text") - ? { - content: content - .filter((c) => c.type === "text") - .map((c: any) => c.text) - .join(""), - } - : {}), - ...(content.some((c) => c.type === "tool_use") - ? { - tool_calls: content - .filter((c) => c.type === "tool_use") - .map((c: any) => ({ - id: c.id, - type: "function" as const, - function: { - name: c.name, - arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input), - }, - })), - } - : {}), - }, - finish_reason: stopReason, - }, - ], - ...(usage ? { usage } : {}), - } -} - -export function toOaCompatibleResponse(resp: CommonResponse) { - if (!resp || typeof resp !== "object") return resp - - if (Array.isArray((resp as any).choices)) return resp - - const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message" - if (!isAnthropic) return resp - - const idIn = (resp as any).id - const id = - typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}` - const model = (resp as any).model - - const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : [] - const text = blocks - .filter((b) => b && b.type === "text" && typeof b.text === "string") - .map((b) => b.text) - .join("") - const tcs = blocks - .filter((b) => b && b.type === "tool_use") - .map((b) => { - const name = (b as any).name - const args = (() => { - const inp = (b as any).input - if (typeof inp === "string") return inp - try { - return JSON.stringify(inp ?? {}) - } catch { - return String(inp ?? "") - } - })() - const tid = - typeof (b as any).id === "string" && (b as any).id.length > 0 - ? (b as any).id - : `toolu_${Math.random().toString(36).slice(2)}` - return { id: tid, type: "function" as const, function: { name, arguments: args } } - }) - - const finish = (r: string | null) => { - if (r === "end_turn") return "stop" - if (r === "tool_use") return "tool_calls" - if (r === "max_tokens") return "length" - if (r === "content_filter") return "content_filter" - return null - } - - const u = (resp as any).usage - const usage = (() => { - if (!u) return undefined as any - const pt = typeof u.input_tokens === "number" ? u.input_tokens : undefined - const ct = typeof u.output_tokens === "number" ? u.output_tokens : undefined - const total = pt != null && ct != null ? pt + ct : undefined - const cached = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined - const details = cached != null ? { cached_tokens: cached } : undefined - return { - prompt_tokens: pt, - completion_tokens: ct, - total_tokens: total, - ...(details ? { prompt_tokens_details: details } : {}), - } - })() - - return { - id, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - ...(text && text.length > 0 ? { content: text } : {}), - ...(tcs.length > 0 ? { tool_calls: tcs } : {}), - }, - finish_reason: finish((resp as any).stop_reason ?? null), - }, - ], - ...(usage ? { usage } : {}), - } -} - -export function fromOaCompatibleChunk(chunk: string): CommonChunk | string { - if (!chunk.startsWith("data: ")) return chunk - - let json - try { - json = JSON.parse(chunk.slice(6)) - } catch { - return chunk - } - - if (!json.choices || !Array.isArray(json.choices) || json.choices.length === 0) { - return chunk - } - - const choice = json.choices[0] - const delta = choice.delta - - if (!delta) return chunk - - const result: CommonChunk = { - id: json.id ?? "", - object: "chat.completion.chunk", - created: json.created ?? Math.floor(Date.now() / 1000), - model: json.model ?? "", - choices: [], - } - - if (delta.content) { - result.choices.push({ - index: choice.index ?? 0, - delta: { content: delta.content }, - finish_reason: null, - }) - } - - if (delta.tool_calls) { - for (const toolCall of delta.tool_calls) { - result.choices.push({ - index: choice.index ?? 0, - delta: { - tool_calls: [ - { - index: toolCall.index ?? 0, - id: toolCall.id, - type: toolCall.type ?? "function", - function: toolCall.function, - }, - ], - }, - finish_reason: null, - }) - } - } - - if (choice.finish_reason) { - result.choices.push({ - index: choice.index ?? 0, - delta: {}, - finish_reason: choice.finish_reason, - }) - } - - if (json.usage) { - const usage = json.usage - result.usage = { - prompt_tokens: usage.prompt_tokens, - completion_tokens: usage.completion_tokens, - total_tokens: usage.total_tokens, - ...(usage.prompt_tokens_details?.cached_tokens - ? { prompt_tokens_details: { cached_tokens: usage.prompt_tokens_details.cached_tokens } } - : {}), - } - } - - return result -} - -export function toOaCompatibleChunk(chunk: CommonChunk): string { - const result: any = { - id: chunk.id, - object: "chat.completion.chunk", - created: chunk.created, - model: chunk.model, - choices: [], - } - - if (!chunk.choices || chunk.choices.length === 0) { - return `data: ${JSON.stringify(result)}` - } - - const choice = chunk.choices[0] - const delta = choice.delta - - if (delta?.role) { - result.choices.push({ - index: choice.index, - delta: { role: delta.role }, - finish_reason: null, - }) - } - - if (delta?.content) { - result.choices.push({ - index: choice.index, - delta: { content: delta.content }, - finish_reason: null, - }) - } - - if (delta?.tool_calls) { - for (const tc of delta.tool_calls) { - result.choices.push({ - index: choice.index, - delta: { - tool_calls: [ - { - index: tc.index, - id: tc.id, - type: tc.type, - function: tc.function, - }, - ], - }, - finish_reason: null, - }) - } - } - - if (choice.finish_reason) { - result.choices.push({ - index: choice.index, - delta: {}, - finish_reason: choice.finish_reason, - }) - } - - if (chunk.usage) { - result.usage = { - prompt_tokens: chunk.usage.prompt_tokens, - completion_tokens: chunk.usage.completion_tokens, - total_tokens: chunk.usage.total_tokens, - ...(chunk.usage.prompt_tokens_details?.cached_tokens - ? { - prompt_tokens_details: { - cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens, - }, - } - : {}), - } - } - - return `data: ${JSON.stringify(result)}` -} diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts deleted file mode 100644 index 3e3c25ffe52..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ /dev/null @@ -1,633 +0,0 @@ -import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider" - -type Usage = { - input_tokens?: number - input_tokens_details?: { - cached_tokens?: number - cache_write_tokens?: number - } - output_tokens?: number - output_tokens_details?: { - reasoning_tokens?: number - } - total_tokens?: number -} - -export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ - format: "openai", - modifyUrl: (providerApi: string) => providerApi + "/responses", - modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => { - headers.set("authorization", `Bearer ${apiKey}`) - }, - modifyBody: (body: Record) => body, - createBinaryStreamDecoder: () => undefined, - createUsageParser: () => { - let usage: Usage - - return { - parse: (chunk: string) => { - const [event, data] = chunk.split("\n") - if (event !== "event: response.completed") return - if (!data.startsWith("data: ")) return - - let json - try { - json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } } - } catch { - return - } - - if (!json.response?.usage) return - usage = json.response.usage - }, - retrieve: () => usage, - } - }, - extractUsage: (response: any) => response.usage ?? response.response?.usage, - normalizeUsage: (usage: Usage) => { - const inputTokens = usage.input_tokens ?? 0 - const outputTokens = usage.output_tokens ?? 0 - const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined - const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined - const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined - return { - // OpenAI's input_tokens includes both cached_tokens and cache_write_tokens; - // each is billed separately at its own rate. Clamp to zero so a provider - // reporting overlapping detail fields cannot drive input cost negative. - inputTokens: Math.max(0, inputTokens - (cacheReadTokens ?? 0) - (cacheWriteTokens ?? 0)), - outputTokens, - reasoningTokens, - cacheReadTokens, - cacheWrite5mTokens: cacheWriteTokens, - cacheWrite1hTokens: undefined, - } - }, -}) - -export function fromOpenaiRequest(body: any): CommonRequest { - if (!body || typeof body !== "object") return body - - const toImg = (p: any) => { - if (!p || typeof p !== "object") return undefined - if ((p as any).type === "image_url" && (p as any).image_url) - return { type: "image_url", image_url: (p as any).image_url } - if ((p as any).type === "input_image" && (p as any).image_url) - return { type: "image_url", image_url: (p as any).image_url } - const s = (p as any).source - if (!s || typeof s !== "object") return undefined - if ((s as any).type === "url" && typeof (s as any).url === "string") - return { type: "image_url", image_url: { url: (s as any).url } } - if ( - (s as any).type === "base64" && - typeof (s as any).media_type === "string" && - typeof (s as any).data === "string" - ) - return { - type: "image_url", - image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` }, - } - return undefined - } - - const msgs: any[] = [] - - const inMsgs = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : [] - - for (const m of inMsgs) { - if (!m) continue - - // Responses API items without role: - if (!(m as any).role && (m as any).type) { - if ((m as any).type === "function_call") { - const name = (m as any).name - const a = (m as any).arguments - const args = typeof a === "string" ? a : JSON.stringify(a ?? {}) - msgs.push({ - role: "assistant", - tool_calls: [{ id: (m as any).id, type: "function", function: { name, arguments: args } }], - }) - } - if ((m as any).type === "function_call_output") { - const id = (m as any).call_id - const out = (m as any).output - const content = typeof out === "string" ? out : JSON.stringify(out) - msgs.push({ role: "tool", tool_call_id: id, content }) - } - continue - } - - if ((m as any).role === "system" || (m as any).role === "developer") { - const c = (m as any).content - if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c }) - if (Array.isArray(c)) { - const t = c.find((p: any) => p && typeof p.text === "string") - if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text }) - } - continue - } - - if ((m as any).role === "user") { - const c = (m as any).content - if (typeof c === "string") { - msgs.push({ role: "user", content: c }) - } else if (Array.isArray(c)) { - const parts: any[] = [] - for (const p of c) { - if (!p || !(p as any).type) continue - if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string") - parts.push({ type: "text", text: (p as any).text }) - const ip = toImg(p) - if (ip) parts.push(ip) - if ((p as any).type === "tool_result") { - const id = (p as any).tool_call_id - const content = - typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content) - msgs.push({ role: "tool", tool_call_id: id, content }) - } - } - if (parts.length === 1 && parts[0].type === "text") msgs.push({ role: "user", content: parts[0].text }) - else if (parts.length > 0) msgs.push({ role: "user", content: parts }) - } - continue - } - - if ((m as any).role === "assistant") { - const c = (m as any).content - const out: any = { role: "assistant" } - if (typeof c === "string" && c.length > 0) out.content = c - if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls - msgs.push(out) - continue - } - - if ((m as any).role === "tool") { - msgs.push({ - role: "tool", - tool_call_id: (m as any).tool_call_id, - content: (m as any).content, - }) - continue - } - } - - const tcIn = body.tool_choice - const tc = (() => { - if (!tcIn) return undefined - if (tcIn === "auto") return "auto" - if (tcIn === "required") return "required" - if ((tcIn as any).type === "function" && (tcIn as any).function?.name) - return { type: "function" as const, function: { name: (tcIn as any).function.name } } - return undefined - })() - - const stop = (() => { - const v = body.stop_sequences ?? body.stop - if (!v) return undefined - if (Array.isArray(v)) return v.length === 1 ? v[0] : v - if (typeof v === "string") return v - return undefined - })() - - return { - model: body.model, - max_tokens: body.max_output_tokens ?? body.max_tokens, - temperature: body.temperature, - top_p: body.top_p, - stop, - messages: msgs, - stream: !!body.stream, - tools: Array.isArray(body.tools) ? body.tools : undefined, - tool_choice: tc, - } -} - -export function toOpenaiRequest(body: CommonRequest) { - if (!body || typeof body !== "object") return body - - const msgsIn = Array.isArray(body.messages) ? body.messages : [] - const input: any[] = [] - - const toPart = (p: any) => { - if (!p || typeof p !== "object") return undefined - if ((p as any).type === "text" && typeof (p as any).text === "string") - return { type: "input_text", text: (p as any).text } - if ((p as any).type === "image_url" && (p as any).image_url) - return { type: "input_image", image_url: (p as any).image_url } - const s = (p as any).source - if (!s || typeof s !== "object") return undefined - if ((s as any).type === "url" && typeof (s as any).url === "string") - return { type: "input_image", image_url: { url: (s as any).url } } - if ( - (s as any).type === "base64" && - typeof (s as any).media_type === "string" && - typeof (s as any).data === "string" - ) - return { - type: "input_image", - image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` }, - } - return undefined - } - - for (const m of msgsIn) { - if (!m || !(m as any).role) continue - - if ((m as any).role === "system") { - const c = (m as any).content - if (typeof c === "string") input.push({ role: "system", content: c }) - continue - } - - if ((m as any).role === "user") { - const c = (m as any).content - if (typeof c === "string") { - input.push({ role: "user", content: [{ type: "input_text", text: c }] }) - } else if (Array.isArray(c)) { - const parts: any[] = [] - for (const p of c) { - const op = toPart(p) - if (op) parts.push(op) - } - if (parts.length > 0) input.push({ role: "user", content: parts }) - } - continue - } - - if ((m as any).role === "assistant") { - const c = (m as any).content - if (typeof c === "string" && c.length > 0) { - input.push({ role: "assistant", content: [{ type: "output_text", text: c }] }) - } - if (Array.isArray((m as any).tool_calls)) { - for (const tc of (m as any).tool_calls) { - if ((tc as any).type === "function" && (tc as any).function) { - const name = (tc as any).function.name - const a = (tc as any).function.arguments - const args = typeof a === "string" ? a : JSON.stringify(a) - input.push({ type: "function_call", call_id: (tc as any).id, name, arguments: args }) - } - } - } - continue - } - - if ((m as any).role === "tool") { - const out = typeof (m as any).content === "string" ? (m as any).content : JSON.stringify((m as any).content) - input.push({ type: "function_call_output", call_id: (m as any).tool_call_id, output: out }) - continue - } - } - - const stop_sequences = (() => { - const v = body.stop - if (!v) return undefined - if (Array.isArray(v)) return v - if (typeof v === "string") return [v] - return undefined - })() - - const tcIn = body.tool_choice - const tool_choice = (() => { - if (!tcIn) return undefined - if (tcIn === "auto") return "auto" - if (tcIn === "required") return "required" - if ((tcIn as any).type === "function" && (tcIn as any).function?.name) - return { type: "function", function: { name: (tcIn as any).function.name } } - return undefined - })() - - const tools = (() => { - if (!Array.isArray(body.tools)) return undefined - return body.tools.map((tool: any) => { - if (tool.type === "function") { - return { - type: "function", - name: tool.function?.name, - description: tool.function?.description, - parameters: tool.function?.parameters, - strict: tool.function?.strict, - } - } - return tool - }) - })() - - return { - model: body.model, - input, - max_output_tokens: body.max_tokens, - top_p: body.top_p, - stop_sequences, - stream: !!body.stream, - tools, - tool_choice, - include: Array.isArray((body as any).include) ? (body as any).include : undefined, - truncation: (body as any).truncation, - metadata: (body as any).metadata, - store: (body as any).store, - user: (body as any).user, - text: { verbosity: body.model === "gpt-5-codex" ? "medium" : "low" }, - reasoning: { effort: "medium" }, - } -} - -export function fromOpenaiResponse(resp: any): CommonResponse { - if (!resp || typeof resp !== "object") return resp - if (Array.isArray((resp as any).choices)) return resp - - const r = (resp as any).response ?? resp - if (!r || typeof r !== "object") return resp - - const idIn = (r as any).id - const id = - typeof idIn === "string" ? idIn.replace(/^resp_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}` - const model = (r as any).model ?? (resp as any).model - - const out = Array.isArray((r as any).output) ? (r as any).output : [] - const text = out - .filter((o: any) => o && o.type === "message" && Array.isArray((o as any).content)) - .flatMap((o: any) => (o as any).content) - .filter((p: any) => p && p.type === "output_text" && typeof p.text === "string") - .map((p: any) => p.text) - .join("") - - const tcs = out - .filter((o: any) => o && o.type === "function_call") - .map((o: any) => { - const name = (o as any).name - const a = (o as any).arguments - const args = typeof a === "string" ? a : JSON.stringify(a ?? {}) - const tid = - typeof (o as any).id === "string" && (o as any).id.length > 0 - ? (o as any).id - : `toolu_${Math.random().toString(36).slice(2)}` - return { id: tid, type: "function" as const, function: { name, arguments: args } } - }) - - const finish = (r: string | null) => { - if (r === "stop") return "stop" - if (r === "tool_call" || r === "tool_calls") return "tool_calls" - if (r === "length" || r === "max_output_tokens") return "length" - if (r === "content_filter") return "content_filter" - return null - } - - const u = (r as any).usage ?? (resp as any).usage - const usage = (() => { - if (!u) return undefined as any - const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined - const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined - const total = pt != null && ct != null ? pt + ct : undefined - const cached = (u as any).input_tokens_details?.cached_tokens - const details = typeof cached === "number" ? { cached_tokens: cached } : undefined - return { - prompt_tokens: pt, - completion_tokens: ct, - total_tokens: total, - ...(details ? { prompt_tokens_details: details } : {}), - } - })() - - return { - id, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model, - choices: [ - { - index: 0, - message: { - role: "assistant", - ...(text && text.length > 0 ? { content: text } : {}), - ...(tcs.length > 0 ? { tool_calls: tcs } : {}), - }, - finish_reason: finish((r as any).stop_reason ?? null), - }, - ], - ...(usage ? { usage } : {}), - } -} - -export function toOpenaiResponse(resp: CommonResponse) { - if (!resp || typeof resp !== "object") return resp - if (!Array.isArray((resp as any).choices)) return resp - - const choice = (resp as any).choices[0] - if (!choice) return resp - - const msg = choice.message - if (!msg) return resp - - const outputItems: any[] = [] - - if (typeof msg.content === "string" && msg.content.length > 0) { - outputItems.push({ - id: `msg_${Math.random().toString(36).slice(2)}`, - type: "message", - status: "completed", - role: "assistant", - content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }], - }) - } - - if (Array.isArray(msg.tool_calls)) { - for (const tc of msg.tool_calls) { - if ((tc as any).type === "function" && (tc as any).function) { - outputItems.push({ - id: (tc as any).id, - type: "function_call", - name: (tc as any).function.name, - call_id: (tc as any).id, - arguments: (tc as any).function.arguments, - }) - } - } - } - - const stop_reason = (() => { - const r = choice.finish_reason - if (r === "stop") return "stop" - if (r === "tool_calls") return "tool_call" - if (r === "length") return "max_output_tokens" - if (r === "content_filter") return "content_filter" - return null - })() - - const usage = (() => { - const u = (resp as any).usage - if (!u) return undefined - return { - input_tokens: u.prompt_tokens, - output_tokens: u.completion_tokens, - total_tokens: u.total_tokens, - ...(u.prompt_tokens_details?.cached_tokens - ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } - : {}), - } - })() - - return { - id: (resp as any).id?.replace(/^chatcmpl_/, "resp_") ?? `resp_${Math.random().toString(36).slice(2)}`, - object: "response", - model: (resp as any).model, - output: outputItems, - stop_reason, - usage, - } -} - -export function fromOpenaiChunk(chunk: string): CommonChunk | string { - const lines = chunk.split("\n") - const ev = lines[0] - const dl = lines[1] - if (!ev || !dl || !dl.startsWith("data: ")) return chunk - - let json: any - try { - json = JSON.parse(dl.slice(6)) - } catch { - return chunk - } - - const respObj = json.response ?? {} - - const out: CommonChunk = { - id: respObj.id ?? json.id ?? "", - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: respObj.model ?? json.model ?? "", - choices: [], - } - - const e = ev.replace("event: ", "").trim() - - if (e === "response.output_text.delta") { - const d = (json as any).delta ?? (json as any).text ?? (json as any).output_text_delta - if (typeof d === "string" && d.length > 0) - out.choices.push({ index: 0, delta: { content: d }, finish_reason: null }) - } - - if (e === "response.output_item.added" && (json as any).item?.type === "function_call") { - const name = (json as any).item?.name - const id = (json as any).item?.id - if (typeof name === "string" && name.length > 0) { - out.choices.push({ - index: 0, - delta: { - tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }], - }, - finish_reason: null, - }) - } - } - - if (e === "response.function_call_arguments.delta") { - const a = (json as any).delta ?? (json as any).arguments_delta - if (typeof a === "string" && a.length > 0) { - out.choices.push({ - index: 0, - delta: { tool_calls: [{ index: 0, function: { arguments: a } }] }, - finish_reason: null, - }) - } - } - - if (e === "response.completed") { - const fr = (() => { - const sr = (respObj as any).stop_reason ?? (json as any).stop_reason - if (sr === "stop") return "stop" - if (sr === "tool_call" || sr === "tool_calls") return "tool_calls" - if (sr === "length" || sr === "max_output_tokens") return "length" - if (sr === "content_filter") return "content_filter" - return null - })() - out.choices.push({ index: 0, delta: {}, finish_reason: fr }) - - const u = (respObj as any).usage ?? (json as any).response?.usage - if (u) { - out.usage = { - prompt_tokens: u.input_tokens, - completion_tokens: u.output_tokens, - total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0), - ...(u.input_tokens_details?.cached_tokens - ? { prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens } } - : {}), - } - } - } - - return out -} - -export function toOpenaiChunk(chunk: CommonChunk): string { - if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) { - return "" - } - - const choice = chunk.choices[0] - const d = choice.delta - if (!d) return "" - - const id = chunk.id - const model = chunk.model - - if (d.content) { - const data = { - id, - type: "response.output_text.delta", - delta: d.content, - response: { id, model }, - } - return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}` - } - - if (d.tool_calls) { - for (const tc of d.tool_calls) { - if (tc.function?.name) { - const data = { - type: "response.output_item.added", - output_index: 0, - item: { - id: tc.id, - type: "function_call", - name: tc.function.name, - call_id: tc.id, - arguments: "", - }, - } - return `event: response.output_item.added\ndata: ${JSON.stringify(data)}` - } - if (tc.function?.arguments) { - const data = { - type: "response.function_call_arguments.delta", - output_index: 0, - delta: tc.function.arguments, - } - return `event: response.function_call_arguments.delta\ndata: ${JSON.stringify(data)}` - } - } - } - - if (choice.finish_reason) { - const u = chunk.usage - const usage = u - ? { - input_tokens: u.prompt_tokens, - output_tokens: u.completion_tokens, - total_tokens: u.total_tokens, - ...(u.prompt_tokens_details?.cached_tokens - ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } } - : {}), - } - : undefined - - const data: any = { - id, - type: "response.completed", - response: { id, model, ...(usage ? { usage } : {}) }, - } - return `event: response.completed\ndata: ${JSON.stringify(data)}` - } - - return "" -} diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts deleted file mode 100644 index 99bce0a72df..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { ZenData } from "@opencode/console-core/model.js" -import { - fromAnthropicChunk, - fromAnthropicRequest, - fromAnthropicResponse, - toAnthropicChunk, - toAnthropicRequest, - toAnthropicResponse, -} from "./anthropic" -import { - fromOpenaiChunk, - fromOpenaiRequest, - fromOpenaiResponse, - toOpenaiChunk, - toOpenaiRequest, - toOpenaiResponse, -} from "./openai" -import { - fromOaCompatibleChunk, - fromOaCompatibleRequest, - fromOaCompatibleResponse, - toOaCompatibleChunk, - toOaCompatibleRequest, - toOaCompatibleResponse, -} from "./openai-compatible" - -export type UsageInfo = { - inputTokens: number - outputTokens: number - reasoningTokens?: number - cacheReadTokens?: number - cacheWrite5mTokens?: number - cacheWrite1hTokens?: number -} - -export type ProviderHelper = (input: { - reqModel: string - providerModel: string - adjustCacheUsage?: boolean - workspaceID?: string -}) => { - format: ZenData.Format - modifyUrl: (providerApi: string, isStream?: boolean) => string - modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void - modifyBody: (body: Record) => Record - createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined - createUsageParser: () => { - parse: (chunk: string) => void - retrieve: () => any - } - extractUsage: (response: any) => any - normalizeUsage: (usage: any) => UsageInfo -} - -export interface CommonMessage { - role: "system" | "user" | "assistant" | "tool" - content?: string | Array - tool_call_id?: string - tool_calls?: CommonToolCall[] -} - -export interface CommonContentPart { - type: "text" | "image_url" - text?: string - image_url?: { url: string } -} - -export interface CommonToolCall { - id: string - type: "function" - function: { - name: string - arguments: string - } -} - -export interface CommonTool { - type: "function" - function: { - name: string - description?: string - parameters?: Record - } -} - -export interface CommonUsage { - input_tokens?: number - output_tokens?: number - total_tokens?: number - prompt_tokens?: number - completion_tokens?: number - cache_read_input_tokens?: number - cache_creation?: { - ephemeral_5m_input_tokens?: number - ephemeral_1h_input_tokens?: number - } - input_tokens_details?: { - cached_tokens?: number - } - output_tokens_details?: { - reasoning_tokens?: number - } -} - -export interface CommonRequest { - model: string - max_tokens?: number - temperature?: number - top_p?: number - stop?: string | string[] - messages: CommonMessage[] - stream?: boolean - tools?: CommonTool[] - tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } } -} - -export interface CommonResponse { - id: string - object: "chat.completion" - created: number - model: string - choices: Array<{ - index: number - message: { - role: "assistant" - content?: string - tool_calls?: CommonToolCall[] - } - finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - prompt_tokens_details?: { cached_tokens?: number } - } -} - -export interface CommonChunk { - id: string - object: "chat.completion.chunk" - created: number - model: string - choices: Array<{ - index: number - delta: { - role?: "assistant" - content?: string - tool_calls?: Array<{ - index: number - id?: string - type?: "function" - function?: { - name?: string - arguments?: string - } - }> - } - finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null - }> - usage?: { - prompt_tokens?: number - completion_tokens?: number - total_tokens?: number - prompt_tokens_details?: { cached_tokens?: number } - } -} - -export function buildCostChunk(format: ZenData.Format, cost: string): string { - switch (format) { - case "anthropic": - return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n` - case "openai": - return `event: ping\ndata: ${JSON.stringify({ type: "ping", cost })}\n\n` - case "oa-compat": - return `data: ${JSON.stringify({ choices: [], cost })}\n\n` - default: - return `data: ${JSON.stringify({ type: "ping", cost })}\n\n` - } -} - -export function createBodyConverter(from: ZenData.Format, to: ZenData.Format) { - return (body: any): any => { - if (from === to) return body - - let raw: CommonRequest - if (from === "anthropic") raw = fromAnthropicRequest(body) - else if (from === "openai") raw = fromOpenaiRequest(body) - else raw = fromOaCompatibleRequest(body) - - if (to === "anthropic") return toAnthropicRequest(raw) - if (to === "openai") return toOpenaiRequest(raw) - if (to === "oa-compat") return toOaCompatibleRequest(raw) - } -} - -export function createStreamPartConverter(from: ZenData.Format, to: ZenData.Format) { - return (part: any): any => { - if (from === to) return part - - let raw: CommonChunk | string - if (from === "anthropic") raw = fromAnthropicChunk(part) - else if (from === "openai") raw = fromOpenaiChunk(part) - else raw = fromOaCompatibleChunk(part) - - // If result is a string (error case), pass it through - if (typeof raw === "string") return raw - - if (to === "anthropic") return toAnthropicChunk(raw) - if (to === "openai") return toOpenaiChunk(raw) - if (to === "oa-compat") return toOaCompatibleChunk(raw) - } -} - -export function createResponseConverter(from: ZenData.Format, to: ZenData.Format) { - return (response: any): any => { - if (from === to) return response - - let raw: CommonResponse - if (from === "anthropic") raw = fromAnthropicResponse(response) - else if (from === "openai") raw = fromOpenaiResponse(response) - else raw = fromOaCompatibleResponse(response) - - if (to === "anthropic") return toAnthropicResponse(raw) - if (to === "openai") return toOpenaiResponse(raw) - if (to === "oa-compat") return toOaCompatibleResponse(raw) - } -} diff --git a/packages/console/app/src/routes/zen/util/provider/systemone.ts b/packages/console/app/src/routes/zen/util/provider/systemone.ts deleted file mode 100644 index 7d0cd90ed1e..00000000000 --- a/packages/console/app/src/routes/zen/util/provider/systemone.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { ProviderHelper } from "./provider" - -type Usage = { - input_tokens?: number - output_tokens?: number -} - -export const systemoneHelper: ProviderHelper = () => ({ - format: "systemone", - modifyUrl: (providerApi: string) => providerApi.replace(/\/$/, "") + "/systemone", - modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => { - headers.set("authorization", `Bearer ${apiKey}`) - headers.set("x-session-affinity", stickyId) - }, - modifyBody: (body: Record) => body, - createBinaryStreamDecoder: () => undefined, - createUsageParser: () => ({ - parse: (_chunk: string) => {}, - retrieve: () => undefined, - }), - extractUsage: (response: any) => response.usage, - normalizeUsage: (usage: Usage) => ({ - inputTokens: usage.input_tokens ?? 0, - outputTokens: usage.output_tokens ?? 0, - reasoningTokens: undefined, - cacheReadTokens: undefined, - cacheWrite5mTokens: undefined, - cacheWrite1hTokens: undefined, - }), -}) diff --git a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts b/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts deleted file mode 100644 index e3c898216bb..00000000000 --- a/packages/console/app/src/routes/zen/util/providerBudgetTracker.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { centsToMicroCents } from "@opencode/console-core/util/price.js" -import { buildRateLimitKey, getRedis } from "./redis" -import { logger } from "./logger" - -// Per-provider, per-minute budget with priorities. The budget belongs to a -// provider and is shared across every model that routes to it. Each model's -// provider entry carries a `budgetPriority`: priority 1 ("always") routes -// unconditionally, while higher priorities ("fill") only route while the provider's -// current-minute spend through that priority is still under budget. -// -// Spend is tracked per (provider, priority, minute) so a fill priority can yield its -// leftover headroom to the next priority down. The previous minute is also read so -// higher priorities can reserve the next minute's budget first. -export function createProviderBudgetTracker( - providers: { - id: string - budget?: number - budgetContribution?: number - budgetPriority?: number - }[], -) { - const tracked = providers.filter( - (provider) => - provider.budget !== undefined && - provider.budgetContribution !== undefined && - provider.budgetPriority !== undefined, - ) - if (tracked.length === 0) return undefined - - const intervalAt = (date: Date) => - date - .toISOString() - .replace(/[^0-9]/g, "") - .substring(0, 12) - const now = new Date() - const currInterval = intervalAt(now) - const prevInterval = intervalAt(new Date(now.getTime() - 60_000)) - - const redis = getRedis() - const key = (providerId: string, priority: number, withInterval: string) => - buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval) - - const budgetByProvider = tracked.reduce>((acc, provider) => { - acc[provider.id] = provider.budget! - return acc - }, {}) - - const maxPriorityByProvider = tracked.reduce>((acc, provider) => { - acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!) - return acc - }, {}) - - // Effective budget in micro-cents per provider/priority, computed in check() - // from the configured budget minus previous-minute usage from higher priorities. - let effectiveBudget: Record> = {} - // Cumulative current-minute spend through each priority, per provider. - let spentThroughPriority: Record> = {} - let previousSpentThroughPriority: Record> = {} - - return { - // Returns whether a provider at a given priority still has budget headroom. - // Priority 1 always qualifies; higher priorities qualify only while everything through - // the current priority hasn't already filled the previous-minute adjusted - // budget. - check: async () => { - const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) => - Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [ - { providerId, priority, interval: currInterval, prev: false }, - { providerId, priority, interval: prevInterval, prev: true }, - ]), - ) - const values = await redis.mget<(string | number | null)[]>( - reads.map((r) => key(r.providerId, r.priority, r.interval)), - ) - - const current: Record> = {} - const previous: Record> = {} - reads.forEach((r, index) => { - const amount = Number(values[index] ?? 0) - if (r.prev) { - previous[r.providerId] ??= {} - previous[r.providerId][r.priority] = amount - return - } - current[r.providerId] ??= {} - current[r.providerId][r.priority] = amount - }) - - effectiveBudget = {} - spentThroughPriority = {} - previousSpentThroughPriority = {} - Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => { - const providerBudget = budgetByProvider[providerId] - if (providerBudget === undefined) return - const budget = centsToMicroCents(providerBudget * 100) - - let currentRunning = 0 - let previousRunning = 0 - effectiveBudget[providerId] = {} - spentThroughPriority[providerId] = {} - previousSpentThroughPriority[providerId] = {} - Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => { - currentRunning += current[providerId]?.[priority] ?? 0 - effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning) - previousRunning += previous[providerId]?.[priority] ?? 0 - spentThroughPriority[providerId][priority] = currentRunning - previousSpentThroughPriority[providerId][priority] = previousRunning - }) - }) - - return { - // Priority 1 is unconditional. Higher priorities gate on the spend through - // the current priority against the effective budget. - qualify: (providerId: string, priority: number) => { - if (priority <= 1) return true - const budget = effectiveBudget[providerId]?.[priority] - if (budget === undefined) return false - const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0 - return spentThroughCurrentPriority < budget - }, - prefer: (providerId: string, priority: number) => { - const providerBudget = budgetByProvider[providerId] - if (providerBudget === undefined) return false - const budget = centsToMicroCents(providerBudget * 100) - const previousUsage = previousSpentThroughPriority[providerId]?.[priority] - if (previousUsage === undefined) return false - return previousUsage < budget * 0.8 - }, - } - }, - track: async (provider: string, priority: number | undefined, costInCent: number) => { - if (priority === undefined) return - const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority) - if (!config) return - if (config.budgetContribution === undefined) return - const cost = centsToMicroCents(costInCent * config.budgetContribution) - if (cost <= 0) return - const redisKey = key(provider, priority, currInterval) - const pipeline = redis.pipeline() - pipeline.incrby(redisKey, cost) - // Keep two minutes so the previous interval is readable for budget adjustment. - pipeline.expire(redisKey, 120) - await pipeline.exec() - logger.metric({ - "provider.budget_usage": cost, - "provider.budget_priority": priority, - }) - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/redis.ts b/packages/console/app/src/routes/zen/util/redis.ts deleted file mode 100644 index 2548fb48610..00000000000 --- a/packages/console/app/src/routes/zen/util/redis.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Resource } from "@opencode/console-resource" -import { Redis } from "@upstash/redis/cloudflare" - -let redis: Redis | undefined - -export function getRedis() { - if (redis) return redis - redis = new Redis({ - url: Resource.UpstashRedisRestUrl.value, - token: Resource.UpstashRedisRestToken.value, - enableTelemetry: false, - }) - return redis -} - -export function buildRateLimitKey(kind: string, identifier: string, interval?: string) { - return `${Resource.App.stage}:ratelimit:${kind}:${identifier}${interval ? `:${interval}` : ""}` -} - -export async function checkCheckoutRateLimit(accountID: string) { - const redis = getRedis() - const key = buildRateLimitKey("checkout", accountID) - const count = await redis.incr(key) - if (count === 1) await redis.expire(key, 60 * 60) - if (count > 5) throw new Error("Too many payment attempts. Please try again later.") -} diff --git a/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts b/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts deleted file mode 100644 index 5a8645289ba..00000000000 --- a/packages/console/app/src/routes/zen/util/stickyProviderTracker.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Database, eq } from "@opencode/console-core/drizzle/index.js" -import { ModelStickyProviderTable } from "@opencode/console-core/schema/ip.sql.js" - -export function createStickyTracker( - modelId: string, - stickyProvider: "strict" | "prefer" | undefined, - stickyId: string, -) { - if (!stickyProvider) return - if (!stickyId) return - const id = `${modelId}/${stickyId}` - let _providerId: string | undefined - - return { - get: async () => { - const data = await Database.use((tx) => - tx - .select({ - providerId: ModelStickyProviderTable.providerId, - }) - .from(ModelStickyProviderTable) - .where(eq(ModelStickyProviderTable.id, id)) - .limit(1), - ) - _providerId = data[0]?.providerId - return _providerId - }, - set: async (providerId: string) => { - if (_providerId === providerId) return - - await Database.use((tx) => - tx - .insert(ModelStickyProviderTable) - .values({ - id, - providerId, - }) - .onDuplicateKeyUpdate({ - set: { - providerId, - }, - }), - ) - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/trainingConsent.ts b/packages/console/app/src/routes/zen/util/trainingConsent.ts deleted file mode 100644 index adb726f9786..00000000000 --- a/packages/console/app/src/routes/zen/util/trainingConsent.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function requiresGoTrainingConsent(model: string) { - return ["muse-spark-1.3-contributor", "muse-spark-1.2-contributor"].includes(model) -} diff --git a/packages/console/app/src/routes/zen/util/trialLimiter.ts b/packages/console/app/src/routes/zen/util/trialLimiter.ts deleted file mode 100644 index 06ee6e2f5fd..00000000000 --- a/packages/console/app/src/routes/zen/util/trialLimiter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js" -import { IpTable } from "@opencode/console-core/schema/ip.sql.js" -import { UsageInfo } from "./provider/provider" -import { Subscription } from "@opencode/console-core/subscription.js" - -export function createTrialLimiter(trialProviders: string[] | undefined, ip: string) { - if (!trialProviders) return - if (!ip) return - - const limit = Subscription.getFreeLimits().promoTokens - - let _isTrial: boolean - - return { - check: async () => { - const data = await Database.use((tx) => - tx - .select({ - usage: IpTable.usage, - }) - .from(IpTable) - .where(eq(IpTable.ip, ip)) - .then((rows) => rows[0]), - ) - - _isTrial = (data?.usage ?? 0) < limit - return _isTrial ? trialProviders : undefined - }, - track: async (usageInfo: UsageInfo) => { - if (!_isTrial) return - const usage = - usageInfo.inputTokens + - usageInfo.outputTokens + - (usageInfo.reasoningTokens ?? 0) + - (usageInfo.cacheReadTokens ?? 0) + - (usageInfo.cacheWrite5mTokens ?? 0) + - (usageInfo.cacheWrite1hTokens ?? 0) - await Database.use((tx) => - tx - .insert(IpTable) - .values({ ip, usage }) - .onDuplicateKeyUpdate({ set: { usage: sql`${IpTable.usage} + ${usage}` } }), - ) - }, - } -} diff --git a/packages/console/app/src/routes/zen/util/usageBatcher.ts b/packages/console/app/src/routes/zen/util/usageBatcher.ts deleted file mode 100644 index 741a10c2d3e..00000000000 --- a/packages/console/app/src/routes/zen/util/usageBatcher.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Resource } from "@opencode/console-resource" -import { getRedis } from "./redis" - -// Workspaces whose balance/usage updates should be batched in Redis to avoid -// row-level lock contention on BillingTable / UserTable. -export const HOT_WORKSPACES = new Set([ - "wrk_01KJ8PX5CH50Y4YNGNS9ZR8YDC", // invoice -]) - -// Probability that a given request flushes the accumulated totals to the DB. -// Lower = fewer DB writes, more staleness. ~1 in 100 -> ~1% of requests write. -const FLUSH_PROBABILITY = 1 / 100 - -export async function accumulateUsage(workspaceID: string, userID: string, workspaceCost: number, userCost: number) { - const redis = getRedis() - const wKey = `${Resource.App.stage}:usage:wrk:${workspaceID}` - const uKey = `${Resource.App.stage}:usage:usr:${workspaceID}:${userID}` - - await Promise.all([redis.incrby(wKey, workspaceCost), redis.incrby(uKey, userCost)]) - - if (Math.random() > FLUSH_PROBABILITY) return null - - // Atomically take the current totals and reset to 0 - const [workspaceTotal, userTotal] = await Promise.all([redis.getdel(wKey), redis.getdel(uKey)]) - - const workspaceFlush = Number(workspaceTotal ?? 0) - const userFlush = Number(userTotal ?? 0) - if (workspaceFlush === 0 && userFlush === 0) return null - - return { workspaceCost: workspaceFlush, userCost: userFlush } -} diff --git a/packages/console/app/src/routes/zen/util/variant.ts b/packages/console/app/src/routes/zen/util/variant.ts deleted file mode 100644 index 63464397f9c..00000000000 --- a/packages/console/app/src/routes/zen/util/variant.ts +++ /dev/null @@ -1,21 +0,0 @@ -export function parseAnthropicVariant(body: any) { - const effort = body.effort ?? body.output_config?.effort ?? body.outputConfig?.effort ?? body.thinking?.effort - if (effort) return effort - - const budget = body.thinking?.budget_tokens ?? body.thinking?.budgetTokens - if (body.thinking?.type !== "enabled" || typeof budget !== "number") return undefined - return budget > 16_000 ? "max" : "high" -} - -export function parseGoogleVariant(body: any) { - const thinkingConfig = body.generationConfig?.thinkingConfig ?? body.thinkingConfig - if (thinkingConfig?.thinkingLevel) return thinkingConfig.thinkingLevel - - const budget = thinkingConfig?.thinkingBudget ?? thinkingConfig?.thinking_budget - if (typeof budget !== "number" || budget <= 0) return undefined - return budget > 16_000 ? "max" : "high" -} - -export function parseOpenAiVariant(body: any) { - return body.reasoningEffort ?? body.reasoning_effort ?? body.reasoning?.effort -} diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index 745e0c2182d..cc82237eaf3 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "oa-compat", modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseOpenAiVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 876a16029ec..b65ccba11d9 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseAnthropicVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "anthropic", modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseAnthropicVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index 6b1a4f513ff..80de5209642 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -1,10 +1,5 @@ import type { APIEvent } from "@solidjs/start/server" -import { ZenData } from "@opencode/console-core/model.js" -import { and, Database, eq, isNull } from "@opencode/console-core/drizzle/index.js" -import { KeyTable } from "@opencode/console-core/schema/key.sql.js" -import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" -import { ModelTable } from "@opencode/console-core/schema/model.sql.js" -import { buildOptionsResponse, buildModelsResponse } from "~/routes/zen/util/modelsHandler" +import { buildOptionsResponse } from "~/routes/zen/util/modelsHandler" import { inferenceUnavailable, proxyInference } from "~/lib/inference-proxy" export async function OPTIONS(_input: APIEvent) { @@ -12,31 +7,7 @@ export async function OPTIONS(_input: APIEvent) { } export async function GET(input: APIEvent) { - const apiKey = input.request.headers.get("authorization")?.split(" ")[1] - if (apiKey && apiKey !== "public") { - const response = await proxyInference(input.request).catch(inferenceUnavailable) - if (response) return response - } - - const disabledModels = await (() => { - if (!apiKey) return [] as string[] - - return Database.use((tx) => - tx - .select({ - model: ModelTable.model, - }) - .from(KeyTable) - .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID)) - .innerJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted))) - .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) - .then((rows) => rows.map((row) => row.model)), - ) - })() - - const models = Object.keys(ZenData.list("full").models) - .filter((id) => !id.endsWith(":global")) - .filter((id) => !disabledModels.includes(id)) - - return buildModelsResponse(models) + const response = await proxyInference(input.request).catch(inferenceUnavailable) + if (response) return response + return Response.json({ type: "error", error: { type: "AuthError", message: "Invalid API key." } }, { status: 401 }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index 372f6667611..ea74f5958ff 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -1,16 +1,12 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseGoogleVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "google", modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, - parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", - parseVariant: (url: string, body: any) => parseGoogleVariant(body), - parseIsStream: (url: string, _body: any) => - // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' - url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, + // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse + parseModel: (url: string) => url.split("/").pop()?.split(":")?.[0] ?? "", }) } diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index b82735817f9..ebd9cdeb063 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -1,14 +1,10 @@ import type { APIEvent } from "@solidjs/start/server" import { handler } from "~/routes/zen/util/handler" -import { parseOpenAiVariant } from "~/routes/zen/util/variant" export function POST(input: APIEvent) { return handler(input, { format: "openai", modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: (url: string, body: any) => parseOpenAiVariant(body), - parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/systemone.ts b/packages/console/app/src/routes/zen/v1/systemone.ts index c29b8471664..d75a2c36068 100644 --- a/packages/console/app/src/routes/zen/v1/systemone.ts +++ b/packages/console/app/src/routes/zen/v1/systemone.ts @@ -6,8 +6,5 @@ export function POST(input: APIEvent) { format: "systemone", modelList: "full", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], - parseModel: (url: string, body: any) => body.model, - parseVariant: () => undefined, - parseIsStream: () => false, }) } diff --git a/packages/console/app/test/museSparkPolicy.test.ts b/packages/console/app/test/museSparkPolicy.test.ts deleted file mode 100644 index e1d92b704d7..00000000000 --- a/packages/console/app/test/museSparkPolicy.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { isModelCountryRestricted } from "../src/lib/request-country" -import { requiresGoTrainingConsent } from "../src/routes/zen/util/trainingConsent" - -describe("Muse Spark model policies", () => { - test.each([ - "muse-spark-1.3-contributor", - "muse-spark-1.3-contributor-free", - "muse-spark-1.2-contributor", - "muse-spark-1.2-contributor-free", - ])("restricts %s in blocked countries", (model) => { - expect(isModelCountryRestricted(model, "CN")).toBe(true) - expect(isModelCountryRestricted(model, "US")).toBe(false) - }) - - test("does not apply the country restriction to similar model IDs", () => { - expect(isModelCountryRestricted("muse-spark-1.3-contributor-preview", "CN")).toBe(false) - }) - - test.each(["muse-spark-1.3-contributor", "muse-spark-1.2-contributor"])( - "requires Go training consent for %s", - (model) => { - expect(requiresGoTrainingConsent(model)).toBe(true) - }, - ) - - test("does not require Go training consent for the free or similar model IDs", () => { - expect(requiresGoTrainingConsent("muse-spark-1.3-contributor-free")).toBe(false) - expect(requiresGoTrainingConsent("muse-spark-1.3-contributor-preview")).toBe(false) - }) -}) diff --git a/packages/console/app/test/pricing.test.ts b/packages/console/app/test/pricing.test.ts deleted file mode 100644 index 2955b3e0f8b..00000000000 --- a/packages/console/app/test/pricing.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { isPeakPricing } from "../src/routes/zen/util/pricing" - -describe("isPeakPricing", () => { - test.each([ - ["weekday 09:00 CN starts peak pricing", "2026-08-27T01:00:00.000Z", true], - ["weekday 12:00 CN ends peak pricing", "2026-08-27T04:00:00.000Z", false], - ["weekday 14:00 CN starts peak pricing", "2026-08-27T06:00:00.000Z", true], - ["weekday 18:00 CN ends peak pricing", "2026-08-27T10:00:00.000Z", false], - ["Saturday in Beijing", "2026-08-29T01:00:00.000Z", false], - ["Sunday in Beijing", "2026-08-30T06:00:00.000Z", false], - ["Monday in Beijing", "2026-08-31T01:00:00.000Z", true], - ] as const)("handles %s", (_name, timestamp, expected) => { - expect(isPeakPricing(new Date(timestamp))).toBe(expected) - }) -}) diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts deleted file mode 100644 index 32ec3db121d..00000000000 --- a/packages/console/app/test/providerUsage.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { ZenData } from "@opencode/console-core/model.js" -import type { ProviderHelper } from "../src/routes/zen/util/provider/provider" -import { anthropicHelper } from "../src/routes/zen/util/provider/anthropic" -import { googleHelper } from "../src/routes/zen/util/provider/google" -import { oaCompatHelper } from "../src/routes/zen/util/provider/openai-compatible" -import { openaiHelper } from "../src/routes/zen/util/provider/openai" -import { systemoneHelper } from "../src/routes/zen/util/provider/systemone" - -const providers = { - anthropic: anthropicHelper({ reqModel: "claude-haiku-4-5", providerModel: "claude-haiku-4-5" }), - google: googleHelper({ reqModel: "gemini-3-flash", providerModel: "gemini-3-flash" }), - openai: openaiHelper({ reqModel: "gpt-5", providerModel: "gpt-5" }), - "oa-compat": oaCompatHelper({ reqModel: "gpt-5-nano", providerModel: "gpt-5-nano" }), - systemone: systemoneHelper({ reqModel: "jev-1.13", providerModel: "jev-latest" }), -} satisfies Record> - -describe("provider usage extraction", () => { - test("prepares SystemOne requests", () => { - const headers = new Headers() - providers.systemone.modifyHeaders(headers, "secret", "session") - - expect(providers.systemone.modifyUrl("https://api.typesafe.ai/v1/")).toBe("https://api.typesafe.ai/v1/systemone") - expect(headers.get("authorization")).toBe("Bearer secret") - expect(headers.get("x-session-affinity")).toBe("session") - }) - - test("extracts Google non-stream usage metadata", () => { - const usage = providers.google.extractUsage({ - usageMetadata: { - promptTokenCount: 10, - candidatesTokenCount: 3, - thoughtsTokenCount: 2, - cachedContentTokenCount: 4, - }, - }) - - expect(providers.google.normalizeUsage(usage)).toEqual({ - inputTokens: 6, - outputTokens: 5, - reasoningTokens: 2, - cacheReadTokens: 4, - cacheWrite5mTokens: undefined, - cacheWrite1hTokens: undefined, - }) - }) - - test("parses Google stream usage metadata", () => { - const usageParser = providers.google.createUsageParser() - usageParser.parse( - 'data: {"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":2,"cachedContentTokenCount":4}}', - ) - - expect(providers.google.normalizeUsage(usageParser.retrieve())).toEqual({ - inputTokens: 6, - outputTokens: 5, - reasoningTokens: 2, - cacheReadTokens: 4, - cacheWrite5mTokens: undefined, - cacheWrite1hTokens: undefined, - }) - }) - - test("extracts nested OpenAI Responses usage", () => { - expect( - providers.openai.extractUsage({ - response: { - usage: { - input_tokens: 5, - output_tokens: 7, - }, - }, - }), - ).toEqual({ - input_tokens: 5, - output_tokens: 7, - }) - }) - - test("extracts SystemOne usage", () => { - expect( - providers.systemone.normalizeUsage( - providers.systemone.extractUsage({ usage: { input_tokens: 312, output_tokens: 48 } }), - ), - ).toEqual({ - inputTokens: 312, - outputTokens: 48, - reasoningTokens: undefined, - cacheReadTokens: undefined, - cacheWrite5mTokens: undefined, - cacheWrite1hTokens: undefined, - }) - }) - - test("parses OpenAI stream cache write usage", () => { - const usageParser = providers.openai.createUsageParser() - usageParser.parse( - 'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}', - ) - - expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ - inputTokens: 3, - outputTokens: 2, - reasoningTokens: undefined, - cacheReadTokens: 4, - cacheWrite5mTokens: 3, - cacheWrite1hTokens: undefined, - }) - }) - - test("clamps input tokens when detail fields overlap", () => { - const usageParser = providers.openai.createUsageParser() - usageParser.parse( - 'event: response.completed\ndata: {"response":{"usage":{"input_tokens":5,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}', - ) - - expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ - inputTokens: 0, - outputTokens: 2, - reasoningTokens: undefined, - cacheReadTokens: 4, - cacheWrite5mTokens: 3, - cacheWrite1hTokens: undefined, - }) - }) -}) diff --git a/packages/console/app/test/rateLimiter.test.ts b/packages/console/app/test/rateLimiter.test.ts deleted file mode 100644 index 6c962262788..00000000000 --- a/packages/console/app/test/rateLimiter.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { getRetryAfterDay } from "../src/routes/zen/util/ipRateLimiter" - -describe("getRetryAfterDay", () => { - test("returns full day at midnight UTC", () => { - const midnight = Date.UTC(2026, 0, 15, 0, 0, 0, 0) - expect(getRetryAfterDay(midnight)).toBe(86_400) - }) - - test("returns remaining seconds until next UTC day", () => { - const noon = Date.UTC(2026, 0, 15, 12, 0, 0, 0) - expect(getRetryAfterDay(noon)).toBe(43_200) - }) - - test("rounds up to nearest second", () => { - const almost = Date.UTC(2026, 0, 15, 23, 59, 59, 500) - expect(getRetryAfterDay(almost)).toBe(1) - }) -}) diff --git a/sst-env.d.ts b/sst-env.d.ts index 109cb6d81b5..8fe5c175b00 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -128,10 +128,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "STRIPE_WEBHOOK_SECRET": { - "type": "sst.sst.Linkable" - "value": string - } "SUPPORT_API_KEY": { "type": "sst.sst.Secret" "value": string @@ -141,14 +137,6 @@ declare module "sst" { "type": "sst.cloudflare.SolidStart" "url": string } - "UpstashRedisRestToken": { - "type": "sst.sst.Secret" - "value": string - } - "UpstashRedisRestUrl": { - "type": "sst.sst.Secret" - "value": string - } "Web": { "type": "sst.cloudflare.Astro" "url": string