diff options
| author | Frank <[email protected]> | 2025-09-19 14:08:02 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-09-19 14:16:53 -0400 |
| commit | 9420d80b73add9f65d5ae7469e5eb82bdc710894 (patch) | |
| tree | 880405e710c063740df9f653db9ac78f910056d7 /packages/console/app/src | |
| parent | c21161b75e627ec1af22947f465ee72027918d31 (diff) | |
| download | opencode-9420d80b73add9f65d5ae7469e5eb82bdc710894.tar.gz opencode-9420d80b73add9f65d5ae7469e5eb82bdc710894.zip | |
zen: data share
Diffstat (limited to 'packages/console/app/src')
5 files changed, 491 insertions, 415 deletions
diff --git a/packages/console/app/src/component/workspace/privacy-section.module.css b/packages/console/app/src/component/workspace/privacy-section.module.css new file mode 100644 index 000000000..0bb5709cb --- /dev/null +++ b/packages/console/app/src/component/workspace/privacy-section.module.css @@ -0,0 +1,114 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + } + + [data-slot="reload-error"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + p { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + margin: 0; + flex: 1; + } + + [data-slot="create-form"] { + display: flex; + gap: var(--space-2); + margin: 0; + flex-shrink: 0; + } + } + [data-slot="payment"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + min-width: 14.5rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + } + + [data-slot="credit-card"] { + padding: var(--space-3-5) var(--space-4); + background-color: var(--color-bg-surface); + border-radius: var(--border-radius-sm); + display: flex; + align-items: center; + justify-content: space-between; + + [data-slot="card-icon"] { + display: flex; + align-items: center; + color: var(--color-text-muted); + } + + [data-slot="card-details"] { + display: flex; + align-items: baseline; + gap: var(--space-1); + + [data-slot="secret"] { + position: relative; + bottom: 2px; + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: 400; + } + + [data-slot="number"] { + font-size: var(--font-size-3xl); + font-weight: 500; + color: var(--color-text); + } + } + } + + [data-slot="button-row"] { + display: flex; + gap: var(--space-2); + align-items: center; + + @media (max-width: 30rem) { + flex-direction: column; + + > button { + width: 100%; + } + } + + [data-slot="create-form"] { + margin: 0; + } + + /* Make Enable Billing button full width when it's the only button */ + > button { + flex: 1; + } + } + } + [data-slot="usage"] { + p { + font-size: var(--font-size-sm); + line-height: 1.5; + color: var(--color-text-secondary); + b { + font-weight: 600; + } + } + } +} diff --git a/packages/console/app/src/component/workspace/privacy-section.tsx b/packages/console/app/src/component/workspace/privacy-section.tsx new file mode 100644 index 000000000..12dd3b203 --- /dev/null +++ b/packages/console/app/src/component/workspace/privacy-section.tsx @@ -0,0 +1,98 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { withActor } from "~/context/auth.withActor" +import styles from "./billing-section.module.css" +import { Database, eq } from "@opencode/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" +import { Show } from "solid-js" + +const updateShare = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + const dataShare = form.get("dataShare")?.toString() === "true" ? true : null + return json( + await withActor(() => { + return Database.use((tx) => + tx + .update(WorkspaceTable) + .set({ + dataShare, + }) + .where(eq(WorkspaceTable.id, workspaceID)), + ) + }, workspaceID), + { revalidate: getWorkspaceInfo.key }, + ) +}, "workspace.disableShare") + +const getWorkspaceInfo = query(async (workspaceID: string) => { + "use server" + return withActor(() => { + return Database.use((tx) => + tx + .select({ + dataShare: WorkspaceTable.dataShare, + }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, workspaceID)) + .then((r) => r[0]), + ) + }, workspaceID) +}, "workspace.get") + +export function PrivacySection() { + const params = useParams() + const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id)) + const updateShareSubmission = useSubmission(updateShare) + + return ( + <section class={styles.root}> + <div data-slot="section-title"> + <h2>Privacy controls</h2> + <p> + Some providers offer data-sharing programs. If you opt in, you voluntarily <b>share your usage data</b> with + them, which they may use to improve their services, including <b>model training</b>. + </p> + <br /> + <p> + By opting in, you gain access to <b>discounted pricing</b> from the provider. You can opt in or out at any + time. + </p> + <br /> + <p> + <a target="_blank" href="/docs/zen"> + Learn more + </a> + </p> + </div> + <Show when={workspaceInfo()?.dataShare}> + <div data-slot="payment"> + <div data-slot="credit-card"> + <div data-slot="card-details"> + <span data-slot="number">You are currently opted in to the data-sharing program.</span> + </div> + </div> + </div> + </Show> + <div data-slot="section-content"> + <div data-slot="payment"> + <div data-slot="button-row"> + <form action={updateShare} method="post" data-slot="create-form"> + <input type="hidden" name="workspaceID" value={params.id} /> + <input type="hidden" name="dataShare" value={workspaceInfo()?.dataShare ? "false" : "true"} /> + <button data-color="ghost" type="submit" disabled={updateShareSubmission.pending}> + {workspaceInfo()?.dataShare + ? updateShareSubmission.pending + ? "Opting out..." + : "Opt out" + : updateShareSubmission.pending + ? "Opting in..." + : "Opt in"} + </button> + </form> + </div> + </div> + </div> + </section> + ) +} diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index ec21126c8..dc8e28cdb 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -2,11 +2,15 @@ import "./[id].css" import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section" import { NewUserSection } from "~/component/workspace/new-user-section" import { BillingSection } from "~/component/workspace/billing-section" +import { PrivacySection } from "~/component/workspace/privacy-section" import { PaymentSection } from "~/component/workspace/payment-section" import { UsageSection } from "~/component/workspace/usage-section" import { KeySection } from "~/component/workspace/key-section" +import { Show } from "solid-js" +import { useParams } from "@solidjs/router" export default function () { + const params = useParams() return ( <div data-page="workspace-[id]"> <section data-component="title-section"> @@ -25,9 +29,20 @@ export default function () { <KeySection /> <BillingSection /> <MonthlyLimitSection /> + <Show when={isBeta(params.id)}> + <PrivacySection /> + </Show> <UsageSection /> <PaymentSection /> </div> </div> ) } + +export function isBeta(workspaceID: string) { + return [ + "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production + "wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev + "wrk_01K4PJRKJ2WPQZN3FFYRV4673F", // frank + ].includes(workspaceID) +} diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts index 6065e2f76..a60abeb70 100644 --- a/packages/console/app/src/routes/zen/handler.ts +++ b/packages/console/app/src/routes/zen/handler.ts @@ -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() + }) + } } diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index 801557324..0db14c7ad 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -5,6 +5,9 @@ type Usage = { prompt_tokens?: number completion_tokens?: number total_tokens?: number + // used by moonshot + cached_tokens?: number + // used by xai prompt_tokens_details?: { text_tokens?: number audio_tokens?: number @@ -48,7 +51,7 @@ export function POST(input: APIEvent) { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0, reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined, - cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined, + cacheReadTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined, }), }) } |
