|
|
@@ -1,37 +1,15 @@
|
|
|
+import { z } from "zod"
|
|
|
import type { APIEvent } from "@solidjs/start/server"
|
|
|
import path from "node:path"
|
|
|
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
|
|
|
import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
|
|
|
-import { BillingTable, PaymentTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
|
|
|
+import { BillingTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
|
|
|
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
|
|
|
import { Identifier } from "@opencode/console-core/identifier.js"
|
|
|
import { Resource } from "@opencode/console-resource"
|
|
|
import { Billing } from "../../../../core/src/billing"
|
|
|
import { Actor } from "@opencode/console-core/actor.js"
|
|
|
-
|
|
|
-type ModelCost = {
|
|
|
- input: number
|
|
|
- output: number
|
|
|
- cacheRead?: number
|
|
|
- cacheWrite5m?: number
|
|
|
- cacheWrite1h?: number
|
|
|
-}
|
|
|
-
|
|
|
-type Model = {
|
|
|
- id: string
|
|
|
- auth: boolean
|
|
|
- cost: ModelCost | ((usage: any) => ModelCost)
|
|
|
- headerMappings: Record<string, string>
|
|
|
- providers: Record<
|
|
|
- string,
|
|
|
- {
|
|
|
- api: string
|
|
|
- apiKey: string
|
|
|
- model: string
|
|
|
- weight?: number
|
|
|
- }
|
|
|
- >
|
|
|
-}
|
|
|
+import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
|
|
|
|
|
export async function handler(
|
|
|
input: APIEvent,
|
|
|
@@ -56,184 +34,32 @@ export async function handler(
|
|
|
class MonthlyLimitError extends Error {}
|
|
|
class ModelError extends Error {}
|
|
|
|
|
|
- const MODELS: Record<string, Model> = {
|
|
|
- "claude-opus-4-1": {
|
|
|
- id: "claude-opus-4-1" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.000015,
|
|
|
- output: 0.000075,
|
|
|
- cacheRead: 0.0000015,
|
|
|
- cacheWrite5m: 0.00001875,
|
|
|
- cacheWrite1h: 0.00003,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- anthropic: {
|
|
|
- api: "https://api.anthropic.com",
|
|
|
- apiKey: Resource.ANTHROPIC_API_KEY.value,
|
|
|
- model: "claude-opus-4-1-20250805",
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- "claude-sonnet-4": {
|
|
|
- id: "claude-sonnet-4" as const,
|
|
|
- auth: true,
|
|
|
- cost: (usage: any) => {
|
|
|
- const totalInputTokens =
|
|
|
- usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
|
|
|
- return totalInputTokens <= 200_000
|
|
|
- ? {
|
|
|
- input: 0.000003,
|
|
|
- output: 0.000015,
|
|
|
- cacheRead: 0.0000003,
|
|
|
- cacheWrite5m: 0.00000375,
|
|
|
- cacheWrite1h: 0.000006,
|
|
|
- }
|
|
|
- : {
|
|
|
- input: 0.000006,
|
|
|
- output: 0.0000225,
|
|
|
- cacheRead: 0.0000006,
|
|
|
- cacheWrite5m: 0.0000075,
|
|
|
- cacheWrite1h: 0.000012,
|
|
|
- }
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- anthropic: {
|
|
|
- api: "https://api.anthropic.com",
|
|
|
- apiKey: Resource.ANTHROPIC_API_KEY.value,
|
|
|
- model: "claude-sonnet-4-20250514",
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- "claude-3-5-haiku": {
|
|
|
- id: "claude-3-5-haiku" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.0000008,
|
|
|
- output: 0.000004,
|
|
|
- cacheRead: 0.00000008,
|
|
|
- cacheWrite5m: 0.000001,
|
|
|
- cacheWrite1h: 0.0000016,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- anthropic: {
|
|
|
- api: "https://api.anthropic.com",
|
|
|
- apiKey: Resource.ANTHROPIC_API_KEY.value,
|
|
|
- model: "claude-3-5-haiku-20241022",
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- "gpt-5": {
|
|
|
- id: "gpt-5" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.00000125,
|
|
|
- output: 0.00001,
|
|
|
- cacheRead: 0.000000125,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- openai: {
|
|
|
- api: "https://api.openai.com",
|
|
|
- apiKey: Resource.OPENAI_API_KEY.value,
|
|
|
- model: "gpt-5",
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- "qwen3-coder": {
|
|
|
- id: "qwen3-coder" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.00000045,
|
|
|
- output: 0.0000018,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- baseten: {
|
|
|
- api: "https://inference.baseten.co",
|
|
|
- apiKey: Resource.BASETEN_API_KEY.value,
|
|
|
- model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
|
|
- weight: 4,
|
|
|
- },
|
|
|
- fireworks: {
|
|
|
- api: "https://api.fireworks.ai/inference",
|
|
|
- apiKey: Resource.FIREWORKS_API_KEY.value,
|
|
|
- model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
|
|
- weight: 1,
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- "kimi-k2": {
|
|
|
- id: "kimi-k2" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.0000006,
|
|
|
- output: 0.0000025,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- baseten: {
|
|
|
- api: "https://inference.baseten.co",
|
|
|
- apiKey: Resource.BASETEN_API_KEY.value,
|
|
|
- model: "moonshotai/Kimi-K2-Instruct-0905",
|
|
|
- //weight: 4,
|
|
|
- },
|
|
|
- //fireworks: {
|
|
|
- // api: "https://api.fireworks.ai/inference",
|
|
|
- // apiKey: Resource.FIREWORKS_API_KEY.value,
|
|
|
- // model: "accounts/fireworks/models/kimi-k2-instruct-0905",
|
|
|
- // weight: 1,
|
|
|
- //},
|
|
|
- },
|
|
|
- },
|
|
|
- "grok-code": {
|
|
|
- id: "grok-code" as const,
|
|
|
- auth: false,
|
|
|
- cost: {
|
|
|
- input: 0,
|
|
|
- output: 0,
|
|
|
- cacheRead: 0,
|
|
|
- },
|
|
|
- headerMappings: {
|
|
|
- "x-grok-conv-id": "x-opencode-session",
|
|
|
- "x-grok-req-id": "x-opencode-request",
|
|
|
- },
|
|
|
- providers: {
|
|
|
- xai: {
|
|
|
- api: "https://api.x.ai",
|
|
|
- apiKey: Resource.XAI_API_KEY.value,
|
|
|
- model: "grok-code",
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- // deprecated
|
|
|
- "qwen/qwen3-coder": {
|
|
|
- id: "qwen/qwen3-coder" as const,
|
|
|
- auth: true,
|
|
|
- cost: {
|
|
|
- input: 0.00000038,
|
|
|
- output: 0.00000153,
|
|
|
- },
|
|
|
- headerMappings: {},
|
|
|
- providers: {
|
|
|
- baseten: {
|
|
|
- api: "https://inference.baseten.co",
|
|
|
- apiKey: Resource.BASETEN_API_KEY.value,
|
|
|
- model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
|
|
- weight: 5,
|
|
|
- },
|
|
|
- fireworks: {
|
|
|
- api: "https://api.fireworks.ai/inference",
|
|
|
- apiKey: Resource.FIREWORKS_API_KEY.value,
|
|
|
- model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
|
|
- weight: 1,
|
|
|
- },
|
|
|
- },
|
|
|
- },
|
|
|
- }
|
|
|
+ const ModelCostSchema = z.object({
|
|
|
+ input: z.number(),
|
|
|
+ output: z.number(),
|
|
|
+ cacheRead: z.number().optional(),
|
|
|
+ cacheWrite5m: z.number().optional(),
|
|
|
+ cacheWrite1h: z.number().optional(),
|
|
|
+ })
|
|
|
+
|
|
|
+ const ModelSchema = z.object({
|
|
|
+ cost: ModelCostSchema,
|
|
|
+ cost200K: ModelCostSchema.optional(),
|
|
|
+ providers: z.array(
|
|
|
+ z.object({
|
|
|
+ id: z.string(),
|
|
|
+ api: z.string(),
|
|
|
+ apiKey: z.string(),
|
|
|
+ model: z.string(),
|
|
|
+ weight: z.number().optional(),
|
|
|
+ allowAnonymous: z.boolean().optional(),
|
|
|
+ headerMappings: z.record(z.string(), z.string()).optional(),
|
|
|
+ disabled: z.boolean().optional(),
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ })
|
|
|
+
|
|
|
+ type Model = z.infer<typeof ModelSchema>
|
|
|
|
|
|
const FREE_WORKSPACES = [
|
|
|
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
|
|
@@ -259,31 +85,28 @@ export async function handler(
|
|
|
session: input.request.headers.get("x-opencode-session"),
|
|
|
request: input.request.headers.get("x-opencode-request"),
|
|
|
})
|
|
|
- const MODEL = validateModel()
|
|
|
- const apiKey = await authenticate()
|
|
|
- const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
|
|
|
- await checkCreditsAndLimit()
|
|
|
- const providerName = selectProvider()
|
|
|
- const providerData = MODEL.providers[providerName]
|
|
|
- logger.metric({ provider: providerName })
|
|
|
+ const authInfo = await authenticate()
|
|
|
+ const modelInfo = validateModel(body.model, authInfo)
|
|
|
+ const providerInfo = selectProvider(modelInfo, authInfo)
|
|
|
+ logger.metric({ provider: providerInfo.id })
|
|
|
|
|
|
// Request to model provider
|
|
|
const startTimestamp = Date.now()
|
|
|
- const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
|
|
|
+ const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen/, "") + url.search), {
|
|
|
method: "POST",
|
|
|
headers: (() => {
|
|
|
const headers = input.request.headers
|
|
|
headers.delete("host")
|
|
|
headers.delete("content-length")
|
|
|
- opts.setAuthHeader(headers, providerData.apiKey)
|
|
|
- Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
|
|
|
+ opts.setAuthHeader(headers, providerInfo.apiKey)
|
|
|
+ Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
|
|
headers.set(k, headers.get(v)!)
|
|
|
})
|
|
|
return headers
|
|
|
})(),
|
|
|
body: JSON.stringify({
|
|
|
...(opts.modifyBody?.(body) ?? body),
|
|
|
- model: providerData.model,
|
|
|
+ model: providerInfo.model,
|
|
|
}),
|
|
|
})
|
|
|
|
|
|
@@ -302,8 +125,8 @@ export async function handler(
|
|
|
const body = JSON.stringify(json)
|
|
|
logger.metric({ response_length: body.length })
|
|
|
logger.debug(body)
|
|
|
- await trackUsage(json.usage)
|
|
|
- await reload()
|
|
|
+ await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
|
|
|
+ await reload(authInfo)
|
|
|
return new Response(body, {
|
|
|
status: res.status,
|
|
|
statusText: res.statusText,
|
|
|
@@ -326,8 +149,8 @@ export async function handler(
|
|
|
logger.metric({ response_length: responseLength })
|
|
|
const usage = opts.getStreamUsage()
|
|
|
if (usage) {
|
|
|
- await trackUsage(usage)
|
|
|
- await reload()
|
|
|
+ await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
|
|
|
+ await reload(authInfo)
|
|
|
}
|
|
|
c.close()
|
|
|
return
|
|
|
@@ -337,6 +160,7 @@ export async function handler(
|
|
|
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
|
|
|
}
|
|
|
responseLength += value.length
|
|
|
+ console.log(decoder.decode(value, { stream: true }))
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
|
|
|
|
const parts = buffer.split("\n\n")
|
|
|
@@ -363,202 +187,6 @@ export async function handler(
|
|
|
statusText: res.statusText,
|
|
|
headers: resHeaders,
|
|
|
})
|
|
|
-
|
|
|
- function validateModel() {
|
|
|
- if (!(body.model in MODELS)) {
|
|
|
- throw new ModelError(`Model ${body.model} not supported`)
|
|
|
- }
|
|
|
- const model = MODELS[body.model as keyof typeof MODELS]
|
|
|
- logger.metric({ model: model.id })
|
|
|
- return model
|
|
|
- }
|
|
|
-
|
|
|
- async function authenticate() {
|
|
|
- try {
|
|
|
- const apiKey = opts.parseApiKey(input.request.headers)
|
|
|
- if (!apiKey) throw new AuthError("Missing API key.")
|
|
|
-
|
|
|
- const key = await Database.use((tx) =>
|
|
|
- tx
|
|
|
- .select({
|
|
|
- id: KeyTable.id,
|
|
|
- workspaceID: KeyTable.workspaceID,
|
|
|
- })
|
|
|
- .from(KeyTable)
|
|
|
- .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
|
|
- .then((rows) => rows[0]),
|
|
|
- )
|
|
|
-
|
|
|
- if (!key) throw new AuthError("Invalid API key.")
|
|
|
- logger.metric({
|
|
|
- api_key: key.id,
|
|
|
- workspace: key.workspaceID,
|
|
|
- })
|
|
|
- return key
|
|
|
- } catch (e) {
|
|
|
- // ignore error if model does not require authentication
|
|
|
- if (!MODEL.auth) return
|
|
|
- throw e
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- async function checkCreditsAndLimit() {
|
|
|
- if (!apiKey || !MODEL.auth || isFree) return
|
|
|
-
|
|
|
- const billing = await Database.use((tx) =>
|
|
|
- tx
|
|
|
- .select({
|
|
|
- balance: BillingTable.balance,
|
|
|
- paymentMethodID: BillingTable.paymentMethodID,
|
|
|
- monthlyLimit: BillingTable.monthlyLimit,
|
|
|
- monthlyUsage: BillingTable.monthlyUsage,
|
|
|
- timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
|
|
- })
|
|
|
- .from(BillingTable)
|
|
|
- .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
|
|
- .then((rows) => rows[0]),
|
|
|
- )
|
|
|
-
|
|
|
- if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
|
|
- if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
|
|
- if (
|
|
|
- billing.monthlyLimit &&
|
|
|
- billing.monthlyUsage &&
|
|
|
- billing.timeMonthlyUsageUpdated &&
|
|
|
- billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
|
|
- ) {
|
|
|
- const now = new Date()
|
|
|
- const currentYear = now.getUTCFullYear()
|
|
|
- const currentMonth = now.getUTCMonth()
|
|
|
- const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
|
|
- const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
|
|
- if (currentYear === dateYear && currentMonth === dateMonth)
|
|
|
- throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- function selectProvider() {
|
|
|
- const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
|
|
|
- Array<string>(provider.weight ?? 1).fill(name),
|
|
|
- )
|
|
|
- return picks[Math.floor(Math.random() * picks.length)]
|
|
|
- }
|
|
|
-
|
|
|
- async function trackUsage(usage: any) {
|
|
|
- const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
|
|
- opts.normalizeUsage(usage)
|
|
|
-
|
|
|
- const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
|
|
|
-
|
|
|
- const inputCost = modelCost.input * inputTokens * 100
|
|
|
- const outputCost = modelCost.output * outputTokens * 100
|
|
|
- const reasoningCost = (() => {
|
|
|
- if (!reasoningTokens) return undefined
|
|
|
- return modelCost.output * reasoningTokens * 100
|
|
|
- })()
|
|
|
- const cacheReadCost = (() => {
|
|
|
- if (!cacheReadTokens) return undefined
|
|
|
- if (!modelCost.cacheRead) return undefined
|
|
|
- return modelCost.cacheRead * cacheReadTokens * 100
|
|
|
- })()
|
|
|
- const cacheWrite5mCost = (() => {
|
|
|
- if (!cacheWrite5mTokens) return undefined
|
|
|
- if (!modelCost.cacheWrite5m) return undefined
|
|
|
- return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
|
|
- })()
|
|
|
- const cacheWrite1hCost = (() => {
|
|
|
- if (!cacheWrite1hTokens) return undefined
|
|
|
- if (!modelCost.cacheWrite1h) return undefined
|
|
|
- return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
|
|
- })()
|
|
|
- const totalCostInCent =
|
|
|
- inputCost +
|
|
|
- outputCost +
|
|
|
- (reasoningCost ?? 0) +
|
|
|
- (cacheReadCost ?? 0) +
|
|
|
- (cacheWrite5mCost ?? 0) +
|
|
|
- (cacheWrite1hCost ?? 0)
|
|
|
-
|
|
|
- logger.metric({
|
|
|
- "tokens.input": inputTokens,
|
|
|
- "tokens.output": outputTokens,
|
|
|
- "tokens.reasoning": reasoningTokens,
|
|
|
- "tokens.cache_read": cacheReadTokens,
|
|
|
- "tokens.cache_write_5m": cacheWrite5mTokens,
|
|
|
- "tokens.cache_write_1h": cacheWrite1hTokens,
|
|
|
- "cost.input": Math.round(inputCost),
|
|
|
- "cost.output": Math.round(outputCost),
|
|
|
- "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
|
|
- "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
|
|
- "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
|
|
- "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
|
|
- "cost.total": Math.round(totalCostInCent),
|
|
|
- })
|
|
|
-
|
|
|
- if (!apiKey) return
|
|
|
-
|
|
|
- const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
|
|
|
- await Database.transaction(async (tx) => {
|
|
|
- await tx.insert(UsageTable).values({
|
|
|
- workspaceID: apiKey.workspaceID,
|
|
|
- id: Identifier.create("usage"),
|
|
|
- model: MODEL.id,
|
|
|
- provider: providerName,
|
|
|
- inputTokens,
|
|
|
- outputTokens,
|
|
|
- reasoningTokens,
|
|
|
- cacheReadTokens,
|
|
|
- cacheWrite5mTokens,
|
|
|
- cacheWrite1hTokens,
|
|
|
- cost,
|
|
|
- })
|
|
|
- await tx
|
|
|
- .update(BillingTable)
|
|
|
- .set({
|
|
|
- balance: sql`${BillingTable.balance} - ${cost}`,
|
|
|
- monthlyUsage: sql`
|
|
|
- CASE
|
|
|
- WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
|
|
- ELSE ${cost}
|
|
|
- END
|
|
|
- `,
|
|
|
- timeMonthlyUsageUpdated: sql`now()`,
|
|
|
- })
|
|
|
- .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
|
|
- })
|
|
|
-
|
|
|
- await Database.use((tx) =>
|
|
|
- tx
|
|
|
- .update(KeyTable)
|
|
|
- .set({ timeUsed: sql`now()` })
|
|
|
- .where(eq(KeyTable.id, apiKey.id)),
|
|
|
- )
|
|
|
- }
|
|
|
-
|
|
|
- async function reload() {
|
|
|
- if (!apiKey) return
|
|
|
-
|
|
|
- const lock = await Database.use((tx) =>
|
|
|
- tx
|
|
|
- .update(BillingTable)
|
|
|
- .set({
|
|
|
- timeReloadLockedTill: sql`now() + interval 1 minute`,
|
|
|
- })
|
|
|
- .where(
|
|
|
- and(
|
|
|
- eq(BillingTable.workspaceID, apiKey.workspaceID),
|
|
|
- eq(BillingTable.reload, true),
|
|
|
- lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
|
|
- or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
|
|
- ),
|
|
|
- ),
|
|
|
- )
|
|
|
- if (lock.rowsAffected === 0) return
|
|
|
-
|
|
|
- await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
|
|
|
- await Billing.reload()
|
|
|
- })
|
|
|
- }
|
|
|
} catch (error: any) {
|
|
|
logger.metric({
|
|
|
"error.type": error.constructor.name,
|
|
|
@@ -591,4 +219,222 @@ export async function handler(
|
|
|
{ status: 500 },
|
|
|
)
|
|
|
}
|
|
|
+
|
|
|
+ async function authenticate() {
|
|
|
+ const apiKey = opts.parseApiKey(input.request.headers)
|
|
|
+ if (!apiKey) return
|
|
|
+
|
|
|
+ const data = await Database.use((tx) =>
|
|
|
+ tx
|
|
|
+ .select({
|
|
|
+ apiKey: KeyTable.id,
|
|
|
+ workspaceID: KeyTable.workspaceID,
|
|
|
+ dataShare: WorkspaceTable.dataShare,
|
|
|
+ balance: BillingTable.balance,
|
|
|
+ paymentMethodID: BillingTable.paymentMethodID,
|
|
|
+ monthlyLimit: BillingTable.monthlyLimit,
|
|
|
+ monthlyUsage: BillingTable.monthlyUsage,
|
|
|
+ timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
|
|
+ })
|
|
|
+ .from(KeyTable)
|
|
|
+ .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
|
|
+ .innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
|
|
+ .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
|
|
+ .then((rows) => rows[0]),
|
|
|
+ )
|
|
|
+
|
|
|
+ if (!data) throw new AuthError("Invalid API key.")
|
|
|
+ logger.metric({
|
|
|
+ api_key: data.apiKey,
|
|
|
+ workspace: data.workspaceID,
|
|
|
+ })
|
|
|
+
|
|
|
+ const isFree = FREE_WORKSPACES.includes(data.workspaceID)
|
|
|
+ if (!isFree) {
|
|
|
+ if (!data.paymentMethodID) throw new CreditsError("No payment method")
|
|
|
+ if (data.balance <= 0) throw new CreditsError("Insufficient balance")
|
|
|
+ if (
|
|
|
+ data.monthlyLimit &&
|
|
|
+ data.monthlyUsage &&
|
|
|
+ data.timeMonthlyUsageUpdated &&
|
|
|
+ data.monthlyUsage >= centsToMicroCents(data.monthlyLimit * 100)
|
|
|
+ ) {
|
|
|
+ const now = new Date()
|
|
|
+ const currentYear = now.getUTCFullYear()
|
|
|
+ const currentMonth = now.getUTCMonth()
|
|
|
+ const dateYear = data.timeMonthlyUsageUpdated.getUTCFullYear()
|
|
|
+ const dateMonth = data.timeMonthlyUsageUpdated.getUTCMonth()
|
|
|
+ if (currentYear === dateYear && currentMonth === dateMonth)
|
|
|
+ throw new MonthlyLimitError(`You have reached your monthly spending limit of $${data.monthlyLimit}.`)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ apiKeyId: data.apiKey,
|
|
|
+ workspaceID: data.workspaceID,
|
|
|
+ dataShare: data.dataShare,
|
|
|
+ isFree,
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function validateModel(reqModel: string, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
|
|
+ const json = JSON.parse(Resource.ZEN_MODELS.value)
|
|
|
+
|
|
|
+ const allModels = z
|
|
|
+ .record(
|
|
|
+ z.string(),
|
|
|
+ z.object({
|
|
|
+ standard: ModelSchema,
|
|
|
+ dataShare: ModelSchema.optional(),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .parse(json)
|
|
|
+
|
|
|
+ if (!(reqModel in allModels)) {
|
|
|
+ throw new ModelError(`Model ${reqModel} not supported`)
|
|
|
+ }
|
|
|
+ const modelId = reqModel as keyof typeof allModels
|
|
|
+ const modelData = authInfo?.dataShare
|
|
|
+ ? (allModels[modelId].dataShare ?? allModels[modelId].standard)
|
|
|
+ : allModels[modelId].standard
|
|
|
+ logger.metric({ model: modelId })
|
|
|
+ return { id: modelId, ...modelData }
|
|
|
+ }
|
|
|
+
|
|
|
+ function selectProvider(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
|
|
+ let providers = model.providers.filter((provider) => !provider.disabled)
|
|
|
+
|
|
|
+ if (!authInfo) {
|
|
|
+ providers = providers.filter((provider) => provider.allowAnonymous)
|
|
|
+ if (providers.length === 0) throw new AuthError("Missing API key.")
|
|
|
+ }
|
|
|
+
|
|
|
+ const picks = providers.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
|
|
+ return picks[Math.floor(Math.random() * picks.length)]
|
|
|
+ }
|
|
|
+
|
|
|
+ async function trackUsage(
|
|
|
+ authInfo: Awaited<ReturnType<typeof authenticate>>,
|
|
|
+ modelInfo: ReturnType<typeof validateModel>,
|
|
|
+ providerId: string,
|
|
|
+ usage: any,
|
|
|
+ ) {
|
|
|
+ const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
|
|
+ opts.normalizeUsage(usage)
|
|
|
+
|
|
|
+ const modelCost =
|
|
|
+ modelInfo.cost200K &&
|
|
|
+ usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens > 200_000
|
|
|
+ ? modelInfo.cost200K
|
|
|
+ : modelInfo.cost
|
|
|
+
|
|
|
+ const inputCost = modelCost.input * inputTokens * 100
|
|
|
+ const outputCost = modelCost.output * outputTokens * 100
|
|
|
+ const reasoningCost = (() => {
|
|
|
+ if (!reasoningTokens) return undefined
|
|
|
+ return modelCost.output * reasoningTokens * 100
|
|
|
+ })()
|
|
|
+ const cacheReadCost = (() => {
|
|
|
+ if (!cacheReadTokens) return undefined
|
|
|
+ if (!modelCost.cacheRead) return undefined
|
|
|
+ return modelCost.cacheRead * cacheReadTokens * 100
|
|
|
+ })()
|
|
|
+ const cacheWrite5mCost = (() => {
|
|
|
+ if (!cacheWrite5mTokens) return undefined
|
|
|
+ if (!modelCost.cacheWrite5m) return undefined
|
|
|
+ return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
|
|
+ })()
|
|
|
+ const cacheWrite1hCost = (() => {
|
|
|
+ if (!cacheWrite1hTokens) return undefined
|
|
|
+ if (!modelCost.cacheWrite1h) return undefined
|
|
|
+ return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
|
|
+ })()
|
|
|
+ const totalCostInCent =
|
|
|
+ inputCost +
|
|
|
+ outputCost +
|
|
|
+ (reasoningCost ?? 0) +
|
|
|
+ (cacheReadCost ?? 0) +
|
|
|
+ (cacheWrite5mCost ?? 0) +
|
|
|
+ (cacheWrite1hCost ?? 0)
|
|
|
+
|
|
|
+ logger.metric({
|
|
|
+ "tokens.input": inputTokens,
|
|
|
+ "tokens.output": outputTokens,
|
|
|
+ "tokens.reasoning": reasoningTokens,
|
|
|
+ "tokens.cache_read": cacheReadTokens,
|
|
|
+ "tokens.cache_write_5m": cacheWrite5mTokens,
|
|
|
+ "tokens.cache_write_1h": cacheWrite1hTokens,
|
|
|
+ "cost.input": Math.round(inputCost),
|
|
|
+ "cost.output": Math.round(outputCost),
|
|
|
+ "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
|
|
+ "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
|
|
+ "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
|
|
+ "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
|
|
+ "cost.total": Math.round(totalCostInCent),
|
|
|
+ })
|
|
|
+
|
|
|
+ if (!authInfo) return
|
|
|
+
|
|
|
+ const cost = authInfo.isFree ? 0 : centsToMicroCents(totalCostInCent)
|
|
|
+ await Database.transaction(async (tx) => {
|
|
|
+ await tx.insert(UsageTable).values({
|
|
|
+ workspaceID: authInfo.workspaceID,
|
|
|
+ id: Identifier.create("usage"),
|
|
|
+ model: modelInfo.id,
|
|
|
+ provider: providerId,
|
|
|
+ inputTokens,
|
|
|
+ outputTokens,
|
|
|
+ reasoningTokens,
|
|
|
+ cacheReadTokens,
|
|
|
+ cacheWrite5mTokens,
|
|
|
+ cacheWrite1hTokens,
|
|
|
+ cost,
|
|
|
+ })
|
|
|
+ await tx
|
|
|
+ .update(BillingTable)
|
|
|
+ .set({
|
|
|
+ balance: sql`${BillingTable.balance} - ${cost}`,
|
|
|
+ monthlyUsage: sql`
|
|
|
+ CASE
|
|
|
+ WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
|
|
+ ELSE ${cost}
|
|
|
+ END
|
|
|
+ `,
|
|
|
+ timeMonthlyUsageUpdated: sql`now()`,
|
|
|
+ })
|
|
|
+ .where(eq(BillingTable.workspaceID, authInfo.workspaceID))
|
|
|
+ })
|
|
|
+
|
|
|
+ await Database.use((tx) =>
|
|
|
+ tx
|
|
|
+ .update(KeyTable)
|
|
|
+ .set({ timeUsed: sql`now()` })
|
|
|
+ .where(eq(KeyTable.id, authInfo.apiKeyId)),
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
|
|
+ if (!authInfo) return
|
|
|
+
|
|
|
+ const lock = await Database.use((tx) =>
|
|
|
+ tx
|
|
|
+ .update(BillingTable)
|
|
|
+ .set({
|
|
|
+ timeReloadLockedTill: sql`now() + interval 1 minute`,
|
|
|
+ })
|
|
|
+ .where(
|
|
|
+ and(
|
|
|
+ eq(BillingTable.workspaceID, authInfo.workspaceID),
|
|
|
+ eq(BillingTable.reload, true),
|
|
|
+ lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
|
|
+ or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if (lock.rowsAffected === 0) return
|
|
|
+
|
|
|
+ await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => {
|
|
|
+ await Billing.reload()
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|