mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 12:41:37 +03:00
refactor(console): make Zen routes a thin inference proxy (#54563)
This commit is contained in:
committed by
GitHub
Unverified
parent
d21b0790a6
commit
83f2b6f67a
@@ -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=="],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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_")
|
||||
|
||||
@@ -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())
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
|
||||
export const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
@@ -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<string, number>,
|
||||
)
|
||||
},
|
||||
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}` } }),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string, { qualify: number; unqualify: number }>,
|
||||
)
|
||||
},
|
||||
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}`,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<string, any>) => ({
|
||||
...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: <type>\n" + "data: <json>"
|
||||
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)
|
||||
}
|
||||
@@ -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<string, any>) => {
|
||||
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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<string, any>, _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)}`
|
||||
}
|
||||
@@ -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<string, any>) => 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 ""
|
||||
}
|
||||
@@ -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<string, any>) => Record<string, any>
|
||||
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<CommonContentPart>
|
||||
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<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<string, any>) => 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,
|
||||
}),
|
||||
})
|
||||
@@ -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<Record<string, number>>((acc, provider) => {
|
||||
acc[provider.id] = provider.budget!
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((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<string, Record<number, number>> = {}
|
||||
// Cumulative current-minute spend through each priority, per provider.
|
||||
let spentThroughPriority: Record<string, Record<number, number>> = {}
|
||||
let previousSpentThroughPriority: Record<string, Record<number, number>> = {}
|
||||
|
||||
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<string, Record<number, number>> = {}
|
||||
const previous: Record<string, Record<number, number>> = {}
|
||||
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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function requiresGoTrainingConsent(model: string) {
|
||||
return ["muse-spark-1.3-contributor", "muse-spark-1.2-contributor"].includes(model)
|
||||
}
|
||||
@@ -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}` } }),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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<string>([
|
||||
"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<number>(wKey), redis.getdel<number>(uKey)])
|
||||
|
||||
const workspaceFlush = Number(workspaceTotal ?? 0)
|
||||
const userFlush = Number(userTotal ?? 0)
|
||||
if (workspaceFlush === 0 && userFlush === 0) return null
|
||||
|
||||
return { workspaceCost: workspaceFlush, userCost: userFlush }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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] ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<ZenData.Format, ReturnType<ProviderHelper>>
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Vendored
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user