From 4e629c5b64d52f6633e5e1a54d250b4b57c92604 Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 2 Sep 2025 20:01:11 -0400 Subject: [PATCH] wip: cloud --- .../src/routes/gateway/v1/chat/completions.ts | 811 ++++++------------ cloud/app/src/routes/stripe/webhook.ts | 7 +- cloud/core/src/billing.ts | 39 +- cloud/core/src/workspace.ts | 2 +- cloud/function/src/gateway.ts | 596 ------------- cloud/function/sst-env.d.ts | 11 +- cloud/resource/sst-env.d.ts | 11 +- infra/cloud.ts | 22 +- opencode.json | 19 +- packages/function/sst-env.d.ts | 11 +- sst-env.d.ts | 14 +- sst.config.ts | 11 +- 12 files changed, 324 insertions(+), 1230 deletions(-) delete mode 100644 cloud/function/src/gateway.ts diff --git a/cloud/app/src/routes/gateway/v1/chat/completions.ts b/cloud/app/src/routes/gateway/v1/chat/completions.ts index 671b589c..8a0b54b1 100644 --- a/cloud/app/src/routes/gateway/v1/chat/completions.ts +++ b/cloud/app/src/routes/gateway/v1/chat/completions.ts @@ -1,576 +1,305 @@ import { Resource } from "@opencode/cloud-resource" -import { Billing } from "@opencode/cloud-core/billing.js" import type { APIEvent } from "@solidjs/start/server" import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js" -import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js" -import { Identifier } from "@opencode/cloud-core/identifier.js" -import { centsToMicroCents } from "@opencode/cloud-core/util/price.js" -import { Actor } from "@opencode/cloud-core/actor.js" import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js" +import { BillingTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js" +import { centsToMicroCents } from "@opencode/cloud-core/util/price.js" +import { Identifier } from "@opencode/cloud-core/identifier.js" -const SUPPORTED_MODELS = { +const MODELS = { // "anthropic/claude-sonnet-4": { - // input: 0.0000015, - // output: 0.000006, - // reasoning: 0.0000015, - // cacheRead: 0.0000001, - // cacheWrite: 0.0000001, - // model: () => - // createAnthropic({ - // apiKey: Resource.ANTHROPIC_API_KEY.value, - // })("claude-sonnet-4-20250514"), - // }, - // "openai/gpt-4.1": { - // input: 0.0000015, - // output: 0.000006, - // reasoning: 0.0000015, - // cacheRead: 0.0000001, - // cacheWrite: 0.0000001, - // model: () => - // createOpenAI({ - // apiKey: Resource.OPENAI_API_KEY.value, - // })("gpt-4.1"), - // }, - // "zhipuai/glm-4.5-flash": { - // input: 0, - // output: 0, - // reasoning: 0, - // cacheRead: 0, - // cacheWrite: 0, - // model: () => - // createOpenAICompatible({ - // name: "Zhipu AI", - // baseURL: "https://api.z.ai/api/paas/v4", - // apiKey: Resource.ZHIPU_API_KEY.value, - // })("glm-4.5-flash"), + // auth: true, + // api: "https://api.anthropic.com", + // apiKey: Resource.ANTHROPIC_API_KEY.value, + // model: "claude-sonnet-4-20250514", + // cost: { + // input: 0.0000015, + // output: 0.000006, + // reasoning: 0.0000015, + // cacheRead: 0.0000001, + // cacheWrite: 0.0000001, + // }, + // headerMappings: {}, // }, + "qwen/qwen3-coder": { + id: "qwen/qwen3-coder", + auth: true, + api: "https://inference.baseten.co", + apiKey: Resource.BASETEN_API_KEY.value, + model: "Qwen/Qwen3-Coder-480B-A35B-Instruct", + cost: { + input: 0.00000038, + output: 0.00000153, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + headerMappings: {}, + }, + "x-ai/grok-code-fast-1": { + id: "x-ai/grok-code-fast-1", + auth: false, + api: "https://api.x.ai", + apiKey: Resource.XAI_API_KEY.value, + model: "grok-code", + cost: { + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }, + headerMappings: { + "x-grok-conv-id": "x-opencode-session", + "x-grok-req-id": "x-opencode-request", + }, + }, } +class AuthError extends Error {} +class CreditsError extends Error {} +class ModelError extends Error {} + export async function POST(input: APIEvent) { - // Check auth header - const authHeader = input.request.headers.get("authorization") - if (!authHeader || !authHeader.startsWith("Bearer ")) - return Response.json( - { - error: { - message: "Missing API key.", - type: "invalid_request_error", - param: null, - code: "unauthorized", - }, - }, - { status: 401 }, - ) - const apiKey = authHeader.split(" ")[1] + try { + const url = new URL(input.request.url) + const body = await input.request.json() + const MODEL = validateModel() + const apiKey = await authenticate() + await checkCredits() - // Check against KeyTable - const keyRecord = await Database.use((tx) => - tx - .select({ - id: KeyTable.id, - workspaceID: KeyTable.workspaceID, + // Request to model provider + const res = await fetch(new URL(url.pathname.replace(/^\/gateway/, "") + url.search, MODEL.api), { + method: "POST", + headers: (() => { + const headers = input.request.headers + headers.delete("host") + headers.delete("content-length") + headers.set("authorization", `Bearer ${MODEL.apiKey}`) + Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => { + headers.set(k, headers.get(v)!) + }) + return headers + })(), + body: JSON.stringify({ + ...body, + model: MODEL.model, + stream_options: { + include_usage: true, + }, + }), + }) + + // Scrub response headers + const resHeaders = new Headers() + const keepHeaders = ["content-type", "cache-control"] + for (const [k, v] of res.headers.entries()) { + if (keepHeaders.includes(k.toLowerCase())) { + resHeaders.set(k, v) + } + } + + // Handle non-streaming response + if (!body.stream) { + const body = await res.json() + await trackUsage(body) + return new Response(JSON.stringify(body), { + status: res.status, + statusText: res.statusText, + headers: resHeaders, }) - .from(KeyTable) - .where(eq(KeyTable.key, apiKey)) - .then((rows) => rows[0]), - ) + } - if (!keyRecord) - return Response.json( - { - error: { - message: "Invalid API key.", - type: "invalid_request_error", - param: null, - code: "unauthorized", - }, + // Handle streaming response + const stream = new ReadableStream({ + start(c) { + const reader = res.body?.getReader() + const decoder = new TextDecoder() + let buffer = "" + + function pump(): Promise { + return ( + reader?.read().then(async ({ done, value }) => { + if (done) { + c.close() + return + } + + buffer += decoder.decode(value, { stream: true }) + + const parts = buffer.split("\n\n") + buffer = parts.pop() ?? "" + + const usage = parts + .map((part) => part.trim()) + .filter((part) => part.startsWith("data: ")) + .map((part) => { + try { + return JSON.parse(part.slice(6)) + } catch (e) { + return {} + } + }) + .find((part) => part.usage) + if (usage) await trackUsage(usage) + + c.enqueue(value) + + return pump() + }) || Promise.resolve() + ) + } + + return pump() }, - { status: 401 }, - ) + }) - /* - return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => { - try { - // Check balance - const customer = await Billing.get() - if (customer.balance <= 0) { - return Response.json( - { - error: { - message: "Insufficient balance", - type: "insufficient_quota", - param: null, - code: "insufficient_quota", - }, - }, - { status: 401 }, + return new Response(stream, { + status: res.status, + statusText: res.statusText, + headers: resHeaders, + }) + + function validateModel() { + if (!(body.model in MODELS)) { + throw new ModelError(`Model ${body.model} not supported`) + } + return MODELS[body.model as keyof typeof MODELS] + } + + async function authenticate() { + try { + const authHeader = input.request.headers.get("authorization") + if (!authHeader || !authHeader.startsWith("Bearer ")) throw new AuthError("Missing API key.") + + const apiKey = authHeader.split(" ")[1] + const key = await Database.use((tx) => + tx + .select({ + id: KeyTable.id, + workspaceID: KeyTable.workspaceID, + }) + .from(KeyTable) + .where(eq(KeyTable.key, apiKey)) + .then((rows) => rows[0]), ) + + if (!key) throw new AuthError("Invalid API key.") + return key + } catch (e) { + console.log(e) + // ignore error if model does not require authentication + if (!MODEL.auth) return + throw e } + } - const body = await input.request.json() - const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model() - if (!model) throw new Error(`Unsupported model: ${body.model}`) + async function checkCredits() { + if (!apiKey || !MODEL.auth) return - const requestBody = transformOpenAIRequestToAiSDK() - - return body.stream ? await handleStream() : await handleGenerate() - - async function handleStream() { - const result = await model.doStream({ - ...requestBody, - }) - - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - const id = `chatcmpl-${Date.now()}` - const created = Math.floor(Date.now() / 1000) - - try { - for await (const chunk of result.stream) { - console.log("!!! CHUNK !!! : " + chunk.type) - switch (chunk.type) { - case "text-delta": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - content: chunk.delta, - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "reasoning-delta": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - reasoning_content: chunk.delta, - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "tool-call": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: chunk.toolCallId, - type: "function", - function: { - name: chunk.toolName, - arguments: chunk.input, - }, - }, - ], - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "error": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - error: { - message: typeof chunk.error === "string" ? chunk.error : chunk.error, - type: "server_error", - }, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - controller.enqueue(encoder.encode("data: [DONE]\n\n")) - controller.close() - break - } - - case "finish": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: - { - stop: "stop", - length: "length", - "content-filter": "content_filter", - "tool-calls": "tool_calls", - error: "stop", - other: "stop", - unknown: "stop", - }[chunk.finishReason] || "stop", - }, - ], - usage: { - prompt_tokens: chunk.usage.inputTokens, - completion_tokens: chunk.usage.outputTokens, - total_tokens: chunk.usage.totalTokens, - completion_tokens_details: { - reasoning_tokens: chunk.usage.reasoningTokens, - }, - prompt_tokens_details: { - cached_tokens: chunk.usage.cachedInputTokens, - }, - }, - } - await trackUsage(body.model, chunk.usage, chunk.providerMetadata) - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - controller.enqueue(encoder.encode("data: [DONE]\n\n")) - controller.close() - break - } - - //case "stream-start": - //case "response-metadata": - case "text-start": - case "text-end": - case "reasoning-start": - case "reasoning-end": - case "tool-input-start": - case "tool-input-delta": - case "tool-input-end": - case "raw": - default: - // Log unknown chunk types for debugging - console.warn(`Unknown chunk type: ${(chunk as any).type}`) - break - } - } - } catch (error) { - controller.error(error) - } - }, - }) - - return new Response(stream, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }) - } - - async function handleGenerate() { - const response = await model.doGenerate({ - ...requestBody, - }) - await trackUsage(body.model, response.usage, response.providerMetadata) - return c.json({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion" as const, - created: Math.floor(Date.now() / 1000), - model: body.model, - choices: [ - { - index: 0, - message: { - role: "assistant" as const, - content: response.content?.find((c) => c.type === "text")?.text ?? "", - reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text, - tool_calls: response.content - ?.filter((c) => c.type === "tool-call") - .map((toolCall) => ({ - id: toolCall.toolCallId, - type: "function" as const, - function: { - name: toolCall.toolName, - arguments: toolCall.input, - }, - })), - }, - finish_reason: - ( - { - stop: "stop", - length: "length", - "content-filter": "content_filter", - "tool-calls": "tool_calls", - error: "stop", - other: "stop", - unknown: "stop", - } as const - )[response.finishReason] || "stop", - }, - ], - usage: { - prompt_tokens: response.usage?.inputTokens, - completion_tokens: response.usage?.outputTokens, - total_tokens: response.usage?.totalTokens, - completion_tokens_details: { - reasoning_tokens: response.usage?.reasoningTokens, - }, - prompt_tokens_details: { - cached_tokens: response.usage?.cachedInputTokens, - }, - }, - }) - } - - function transformOpenAIRequestToAiSDK() { - const prompt = transformMessages() - const tools = transformTools() - - return { - prompt, - maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined, - temperature: body.temperature ?? undefined, - topP: body.top_p ?? undefined, - frequencyPenalty: body.frequency_penalty ?? undefined, - presencePenalty: body.presence_penalty ?? undefined, - providerOptions: body.reasoning_effort - ? { - anthropic: { - reasoningEffort: body.reasoning_effort, - }, - } - : undefined, - stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined, - responseFormat: (() => { - if (!body.response_format) return { type: "text" as const } - if (body.response_format.type === "json_schema") - return { - type: "json" as const, - schema: body.response_format.json_schema.schema, - name: body.response_format.json_schema.name, - description: body.response_format.json_schema.description, - } - if (body.response_format.type === "json_object") return { type: "json" as const } - throw new Error("Unsupported response format") - })(), - seed: body.seed ?? undefined, - tools: tools.tools, - toolChoice: tools.toolChoice, - } - - function transformTools() { - const { tools, tool_choice } = body - - if (!tools || tools.length === 0) { - return { tools: undefined, toolChoice: undefined } - } - - const aiSdkTools = tools.map((tool) => { - return { - type: tool.type, - name: tool.function.name, - description: tool.function.description, - inputSchema: tool.function.parameters!, - } + const billing = await Database.use((tx) => + tx + .select({ + balance: BillingTable.balance, }) + .from(BillingTable) + .where(eq(BillingTable.workspaceID, apiKey.workspaceID)) + .then((rows) => rows[0]), + ) - let aiSdkToolChoice - if (tool_choice == null) { - aiSdkToolChoice = undefined - } else if (tool_choice === "auto") { - aiSdkToolChoice = { type: "auto" as const } - } else if (tool_choice === "none") { - aiSdkToolChoice = { type: "none" as const } - } else if (tool_choice === "required") { - aiSdkToolChoice = { type: "required" as const } - } else if (tool_choice.type === "function") { - aiSdkToolChoice = { - type: "tool" as const, - toolName: tool_choice.function.name, - } - } + if (billing.balance <= 0) throw new CreditsError("Insufficient balance") + } - return { tools: aiSdkTools, toolChoice: aiSdkToolChoice } - } + async function trackUsage(chunk: any) { + console.log(`trackUsage ${apiKey}`) - function transformMessages() { - const { messages } = body - const prompt: LanguageModelV2Prompt = [] + if (!apiKey) return - for (const message of messages) { - switch (message.role) { - case "system": { - prompt.push({ - role: "system", - content: message.content as string, - }) - break - } + const usage = chunk.usage + const inputTokens = usage.prompt_tokens ?? 0 + const outputTokens = usage.completion_tokens ?? 0 + const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0 + const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0 + //const cacheWriteTokens = providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0 + const cacheWriteTokens = 0 - case "user": { - if (typeof message.content === "string") { - prompt.push({ - role: "user", - content: [{ type: "text", text: message.content }], - }) - } else { - const content = message.content.map((part) => { - switch (part.type) { - case "text": - return { type: "text" as const, text: part.text } - case "image_url": - return { - type: "file" as const, - mediaType: "image/jpeg" as const, - data: part.image_url.url, - } - default: - throw new Error(`Unsupported content part type: ${(part as any).type}`) - } - }) - prompt.push({ - role: "user", - content, - }) - } - break - } + const inputCost = MODEL.cost.input * inputTokens + const outputCost = MODEL.cost.output * outputTokens + const reasoningCost = MODEL.cost.reasoning * reasoningTokens + const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens + const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens + const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100 + const cost = centsToMicroCents(costInCents) - case "assistant": { - const content: Array< - | { type: "text"; text: string } - | { - type: "tool-call" - toolCallId: string - toolName: string - input: any - } - > = [] - - if (message.content) { - content.push({ - type: "text", - text: message.content as string, - }) - } - - if (message.tool_calls) { - for (const toolCall of message.tool_calls) { - content.push({ - type: "tool-call", - toolCallId: toolCall.id, - toolName: toolCall.function.name, - input: JSON.parse(toolCall.function.arguments), - }) - } - } - - prompt.push({ - role: "assistant", - content, - }) - break - } - - case "tool": { - prompt.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolName: "placeholder", - toolCallId: message.tool_call_id, - output: { - type: "text", - value: message.content as string, - }, - }, - ], - }) - break - } - - default: { - throw new Error(`Unsupported message role: ${message.role}`) - } - } - } - - return prompt - } - } - - async function trackUsage(model: string, usage: LanguageModelUsage, providerMetadata?: ProviderMetadata) { - const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS] - if (!modelData) throw new Error(`Unsupported model: ${model}`) - - const inputTokens = usage.inputTokens ?? 0 - const outputTokens = usage.outputTokens ?? 0 - const reasoningTokens = usage.reasoningTokens ?? 0 - const cacheReadTokens = usage.cachedInputTokens ?? 0 - const cacheWriteTokens = - providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - providerMetadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - 0 - - const inputCost = modelData.input * inputTokens - const outputCost = modelData.output * outputTokens - const reasoningCost = modelData.reasoning * reasoningTokens - const cacheReadCost = modelData.cacheRead * cacheReadTokens - const cacheWriteCost = modelData.cacheWrite * cacheWriteTokens - const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100 - - await Billing.consume({ - model, + await Database.transaction(async (tx) => { + await tx.insert(UsageTable).values({ + workspaceID: apiKey.workspaceID, + id: Identifier.create("usage"), + model: MODEL.id, inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWriteTokens, - costInCents, + cost, }) + await tx + .update(BillingTable) + .set({ + balance: sql`${BillingTable.balance} - ${cost}`, + }) + .where(eq(BillingTable.workspaceID, apiKey.workspaceID)) + }) - await Database.use((tx) => - tx - .update(KeyTable) - .set({ timeUsed: sql`now()` }) - .where(eq(KeyTable.id, keyRecord.id)), - ) - } - } catch (error: any) { - return Response.json({ error: { message: error.message } }, { status: 500 }) + await Database.use((tx) => + tx + .update(KeyTable) + .set({ timeUsed: sql`now()` }) + .where(eq(KeyTable.id, apiKey.id)), + ) } - }) - */ + } catch (error: any) { + if (error instanceof AuthError) { + return new Response( + JSON.stringify({ + error: { + message: error.message, + type: "invalid_request_error", + param: null, + code: "unauthorized", + }, + }), + { + status: 401, + }, + ) + } + + if (error instanceof CreditsError) { + return new Response( + JSON.stringify({ + error: { + message: error.message, + type: "insufficient_quota", + param: null, + code: "insufficient_quota", + }, + }), + { + status: 401, + }, + ) + } + + if (error instanceof ModelError) { + return new Response(JSON.stringify({ error: { message: error.message } }), { + status: 401, + }) + } + + console.log(error) + return new Response(JSON.stringify({ error: { message: error.message } }), { + status: 500, + }) + } } diff --git a/cloud/app/src/routes/stripe/webhook.ts b/cloud/app/src/routes/stripe/webhook.ts index 61d14a64..592f4568 100644 --- a/cloud/app/src/routes/stripe/webhook.ts +++ b/cloud/app/src/routes/stripe/webhook.ts @@ -24,8 +24,11 @@ export async function POST(input: APIEvent) { if (!workspaceID) throw new Error("Workspace ID not found") if (!customerID) throw new Error("Customer ID not found") if (!amount) throw new Error("Amount not found") + if (amount !== 2118) throw new Error("Amount mismatch") if (!paymentID) throw new Error("Payment ID not found") + const chargedAmount = 2000 + await Actor.provide("system", { workspaceID }, async () => { const customer = await Billing.get() if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") @@ -50,7 +53,7 @@ export async function POST(input: APIEvent) { await tx .update(BillingTable) .set({ - balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`, + balance: sql`${BillingTable.balance} + ${centsToMicroCents(chargedAmount)}`, customerID, paymentMethodID: paymentMethod.id, paymentMethodLast4: paymentMethod.card!.last4, @@ -59,7 +62,7 @@ export async function POST(input: APIEvent) { await tx.insert(PaymentTable).values({ workspaceID, id: Identifier.create("payment"), - amount: centsToMicroCents(amount), + amount: centsToMicroCents(chargedAmount), paymentID, customerID, }) diff --git a/cloud/core/src/billing.ts b/cloud/core/src/billing.ts index 298d151f..6f241ba0 100644 --- a/cloud/core/src/billing.ts +++ b/cloud/core/src/billing.ts @@ -52,43 +52,6 @@ export namespace Billing { ) } - export const consume = fn( - z.object({ - requestID: z.string().optional(), - model: z.string(), - inputTokens: z.number(), - outputTokens: z.number(), - reasoningTokens: z.number().optional(), - cacheReadTokens: z.number().optional(), - cacheWriteTokens: z.number().optional(), - costInCents: z.number(), - }), - async (input) => { - const workspaceID = Actor.workspace() - const cost = centsToMicroCents(input.costInCents) - - return await Database.transaction(async (tx) => { - await tx.insert(UsageTable).values({ - workspaceID, - id: Identifier.create("usage"), - model: input.model, - inputTokens: input.inputTokens, - outputTokens: input.outputTokens, - reasoningTokens: input.reasoningTokens, - cacheReadTokens: input.cacheReadTokens, - cacheWriteTokens: input.cacheWriteTokens, - cost, - }) - await tx - .update(BillingTable) - .set({ - balance: sql`${BillingTable.balance} - ${cost}`, - }) - .where(eq(BillingTable.workspaceID, workspaceID)) - }) - }, - ) - export const generateCheckoutUrl = fn( z.object({ successUrl: z.string(), @@ -109,7 +72,7 @@ export namespace Billing { product_data: { name: "opencode credits", }, - unit_amount: 2000, // $20 minimum + unit_amount: 2118, // $20 minimum + Stripe fee 4.4% + $0.30 }, quantity: 1, }, diff --git a/cloud/core/src/workspace.ts b/cloud/core/src/workspace.ts index 8646362a..a9fb923d 100644 --- a/cloud/core/src/workspace.ts +++ b/cloud/core/src/workspace.ts @@ -26,7 +26,7 @@ export namespace Workspace { await tx.insert(BillingTable).values({ workspaceID, id: Identifier.create("billing"), - balance: centsToMicroCents(100), + balance: 0, }) }) await Actor.provide( diff --git a/cloud/function/src/gateway.ts b/cloud/function/src/gateway.ts deleted file mode 100644 index ffbcc1b8..00000000 --- a/cloud/function/src/gateway.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { Hono, MiddlewareHandler } from "hono" -import { type ProviderMetadata, type LanguageModelUsage } from "ai" -import { createAnthropic } from "@ai-sdk/anthropic" -import { createOpenAI } from "@ai-sdk/openai" -import { createOpenAICompatible } from "@ai-sdk/openai-compatible" -import type { LanguageModelV2Prompt } from "@ai-sdk/provider" -import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions" -import { Actor } from "@opencode/cloud-core/actor.js" -import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js" -import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js" -import { Billing } from "@opencode/cloud-core/billing.js" -import { Resource } from "@opencode/cloud-resource" - -type Env = {} - -const SUPPORTED_MODELS = { - "anthropic/claude-sonnet-4": { - input: 0.0000015, - output: 0.000006, - reasoning: 0.0000015, - cacheRead: 0.0000001, - cacheWrite: 0.0000001, - model: () => - createAnthropic({ - apiKey: Resource.ANTHROPIC_API_KEY.value, - })("claude-sonnet-4-20250514"), - }, - "openai/gpt-4.1": { - input: 0.0000015, - output: 0.000006, - reasoning: 0.0000015, - cacheRead: 0.0000001, - cacheWrite: 0.0000001, - model: () => - createOpenAI({ - apiKey: Resource.OPENAI_API_KEY.value, - })("gpt-4.1"), - }, - "zhipuai/glm-4.5-flash": { - input: 0, - output: 0, - reasoning: 0, - cacheRead: 0, - cacheWrite: 0, - model: () => - createOpenAICompatible({ - name: "Zhipu AI", - baseURL: "https://api.z.ai/api/paas/v4", - apiKey: Resource.ZHIPU_API_KEY.value, - })("glm-4.5-flash"), - }, -} - -const GatewayAuth: MiddlewareHandler = async (c, next) => { - const authHeader = c.req.header("authorization") - - if (!authHeader || !authHeader.startsWith("Bearer ")) { - return c.json( - { - error: { - message: "Missing API key.", - type: "invalid_request_error", - param: null, - code: "unauthorized", - }, - }, - 401, - ) - } - - const apiKey = authHeader.split(" ")[1] - - // Check against KeyTable - const keyRecord = await Database.use((tx) => - tx - .select({ - id: KeyTable.id, - workspaceID: KeyTable.workspaceID, - }) - .from(KeyTable) - .where(eq(KeyTable.key, apiKey)) - .then((rows) => rows[0]), - ) - - if (!keyRecord) { - return c.json( - { - error: { - message: "Invalid API key.", - type: "invalid_request_error", - param: null, - code: "unauthorized", - }, - }, - 401, - ) - } - - c.set("keyRecord", keyRecord) - await next() -} - -const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>() - .get("/", (c) => c.text("Hello, world!")) - .post("/v1/chat/completions", GatewayAuth, async (c) => { - const keyRecord = c.get("keyRecord")! - - return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => { - try { - // Check balance - const customer = await Billing.get() - if (customer.balance <= 0) { - return c.json( - { - error: { - message: "Insufficient balance", - type: "insufficient_quota", - param: null, - code: "insufficient_quota", - }, - }, - 401, - ) - } - - const body = await c.req.json() - const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model() - if (!model) throw new Error(`Unsupported model: ${body.model}`) - - const requestBody = transformOpenAIRequestToAiSDK() - - return body.stream ? await handleStream() : await handleGenerate() - - async function handleStream() { - const result = await model.doStream({ - ...requestBody, - }) - - const encoder = new TextEncoder() - const stream = new ReadableStream({ - async start(controller) { - const id = `chatcmpl-${Date.now()}` - const created = Math.floor(Date.now() / 1000) - - try { - for await (const chunk of result.stream) { - console.log("!!! CHUNK !!! : " + chunk.type) - switch (chunk.type) { - case "text-delta": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - content: chunk.delta, - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "reasoning-delta": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - reasoning_content: chunk.delta, - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "tool-call": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: chunk.toolCallId, - type: "function", - function: { - name: chunk.toolName, - arguments: chunk.input, - }, - }, - ], - }, - finish_reason: null, - }, - ], - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - break - } - - case "error": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: "stop", - }, - ], - error: { - message: typeof chunk.error === "string" ? chunk.error : chunk.error, - type: "server_error", - }, - } - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - controller.enqueue(encoder.encode("data: [DONE]\n\n")) - controller.close() - break - } - - case "finish": { - const data = { - id, - object: "chat.completion.chunk", - created, - model: body.model, - choices: [ - { - index: 0, - delta: {}, - finish_reason: - { - stop: "stop", - length: "length", - "content-filter": "content_filter", - "tool-calls": "tool_calls", - error: "stop", - other: "stop", - unknown: "stop", - }[chunk.finishReason] || "stop", - }, - ], - usage: { - prompt_tokens: chunk.usage.inputTokens, - completion_tokens: chunk.usage.outputTokens, - total_tokens: chunk.usage.totalTokens, - completion_tokens_details: { - reasoning_tokens: chunk.usage.reasoningTokens, - }, - prompt_tokens_details: { - cached_tokens: chunk.usage.cachedInputTokens, - }, - }, - } - await trackUsage(body.model, chunk.usage, chunk.providerMetadata) - controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`)) - controller.enqueue(encoder.encode("data: [DONE]\n\n")) - controller.close() - break - } - - //case "stream-start": - //case "response-metadata": - case "text-start": - case "text-end": - case "reasoning-start": - case "reasoning-end": - case "tool-input-start": - case "tool-input-delta": - case "tool-input-end": - case "raw": - default: - // Log unknown chunk types for debugging - console.warn(`Unknown chunk type: ${(chunk as any).type}`) - break - } - } - } catch (error) { - controller.error(error) - } - }, - }) - - return new Response(stream, { - headers: { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }) - } - - async function handleGenerate() { - const response = await model.doGenerate({ - ...requestBody, - }) - await trackUsage(body.model, response.usage, response.providerMetadata) - return c.json({ - id: `chatcmpl-${Date.now()}`, - object: "chat.completion" as const, - created: Math.floor(Date.now() / 1000), - model: body.model, - choices: [ - { - index: 0, - message: { - role: "assistant" as const, - content: response.content?.find((c) => c.type === "text")?.text ?? "", - reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text, - tool_calls: response.content - ?.filter((c) => c.type === "tool-call") - .map((toolCall) => ({ - id: toolCall.toolCallId, - type: "function" as const, - function: { - name: toolCall.toolName, - arguments: toolCall.input, - }, - })), - }, - finish_reason: - ( - { - stop: "stop", - length: "length", - "content-filter": "content_filter", - "tool-calls": "tool_calls", - error: "stop", - other: "stop", - unknown: "stop", - } as const - )[response.finishReason] || "stop", - }, - ], - usage: { - prompt_tokens: response.usage?.inputTokens, - completion_tokens: response.usage?.outputTokens, - total_tokens: response.usage?.totalTokens, - completion_tokens_details: { - reasoning_tokens: response.usage?.reasoningTokens, - }, - prompt_tokens_details: { - cached_tokens: response.usage?.cachedInputTokens, - }, - }, - }) - } - - function transformOpenAIRequestToAiSDK() { - const prompt = transformMessages() - const tools = transformTools() - - return { - prompt, - maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined, - temperature: body.temperature ?? undefined, - topP: body.top_p ?? undefined, - frequencyPenalty: body.frequency_penalty ?? undefined, - presencePenalty: body.presence_penalty ?? undefined, - providerOptions: body.reasoning_effort - ? { - anthropic: { - reasoningEffort: body.reasoning_effort, - }, - } - : undefined, - stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined, - responseFormat: (() => { - if (!body.response_format) return { type: "text" as const } - if (body.response_format.type === "json_schema") - return { - type: "json" as const, - schema: body.response_format.json_schema.schema, - name: body.response_format.json_schema.name, - description: body.response_format.json_schema.description, - } - if (body.response_format.type === "json_object") return { type: "json" as const } - throw new Error("Unsupported response format") - })(), - seed: body.seed ?? undefined, - tools: tools.tools, - toolChoice: tools.toolChoice, - } - - function transformTools() { - const { tools, tool_choice } = body - - if (!tools || tools.length === 0) { - return { tools: undefined, toolChoice: undefined } - } - - const aiSdkTools = tools.map((tool) => { - return { - type: tool.type, - name: tool.function.name, - description: tool.function.description, - inputSchema: tool.function.parameters!, - } - }) - - let aiSdkToolChoice - if (tool_choice == null) { - aiSdkToolChoice = undefined - } else if (tool_choice === "auto") { - aiSdkToolChoice = { type: "auto" as const } - } else if (tool_choice === "none") { - aiSdkToolChoice = { type: "none" as const } - } else if (tool_choice === "required") { - aiSdkToolChoice = { type: "required" as const } - } else if (tool_choice.type === "function") { - aiSdkToolChoice = { - type: "tool" as const, - toolName: tool_choice.function.name, - } - } - - return { tools: aiSdkTools, toolChoice: aiSdkToolChoice } - } - - function transformMessages() { - const { messages } = body - const prompt: LanguageModelV2Prompt = [] - - for (const message of messages) { - switch (message.role) { - case "system": { - prompt.push({ - role: "system", - content: message.content as string, - }) - break - } - - case "user": { - if (typeof message.content === "string") { - prompt.push({ - role: "user", - content: [{ type: "text", text: message.content }], - }) - } else { - const content = message.content.map((part) => { - switch (part.type) { - case "text": - return { type: "text" as const, text: part.text } - case "image_url": - return { - type: "file" as const, - mediaType: "image/jpeg" as const, - data: part.image_url.url, - } - default: - throw new Error(`Unsupported content part type: ${(part as any).type}`) - } - }) - prompt.push({ - role: "user", - content, - }) - } - break - } - - case "assistant": { - const content: Array< - | { type: "text"; text: string } - | { - type: "tool-call" - toolCallId: string - toolName: string - input: any - } - > = [] - - if (message.content) { - content.push({ - type: "text", - text: message.content as string, - }) - } - - if (message.tool_calls) { - for (const toolCall of message.tool_calls) { - content.push({ - type: "tool-call", - toolCallId: toolCall.id, - toolName: toolCall.function.name, - input: JSON.parse(toolCall.function.arguments), - }) - } - } - - prompt.push({ - role: "assistant", - content, - }) - break - } - - case "tool": { - prompt.push({ - role: "tool", - content: [ - { - type: "tool-result", - toolName: "placeholder", - toolCallId: message.tool_call_id, - output: { - type: "text", - value: message.content as string, - }, - }, - ], - }) - break - } - - default: { - throw new Error(`Unsupported message role: ${message.role}`) - } - } - } - - return prompt - } - } - - async function trackUsage(model: string, usage: LanguageModelUsage, providerMetadata?: ProviderMetadata) { - const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS] - if (!modelData) throw new Error(`Unsupported model: ${model}`) - - const inputTokens = usage.inputTokens ?? 0 - const outputTokens = usage.outputTokens ?? 0 - const reasoningTokens = usage.reasoningTokens ?? 0 - const cacheReadTokens = usage.cachedInputTokens ?? 0 - const cacheWriteTokens = - providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - providerMetadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - 0 - - const inputCost = modelData.input * inputTokens - const outputCost = modelData.output * outputTokens - const reasoningCost = modelData.reasoning * reasoningTokens - const cacheReadCost = modelData.cacheRead * cacheReadTokens - const cacheWriteCost = modelData.cacheWrite * cacheWriteTokens - const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100 - - await Billing.consume({ - model, - inputTokens, - outputTokens, - reasoningTokens, - cacheReadTokens, - cacheWriteTokens, - costInCents, - }) - - await Database.use((tx) => - tx - .update(KeyTable) - .set({ timeUsed: sql`now()` }) - .where(eq(KeyTable.id, keyRecord.id)), - ) - } - } catch (error: any) { - return c.json({ error: { message: error.message } }, 500) - } - }) - }) - .all("*", (c) => c.text("Not Found")) - -export type ApiType = typeof app - -export default app diff --git a/cloud/function/sst-env.d.ts b/cloud/function/sst-env.d.ts index d0b2bb4a..5478b433 100644 --- a/cloud/function/sst-env.d.ts +++ b/cloud/function/sst-env.d.ts @@ -14,6 +14,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "BASETEN_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string @@ -46,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "OPENAI_API_KEY": { - "type": "sst.sst.Secret" - "value": string - } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -62,7 +62,7 @@ declare module "sst" { "type": "sst.cloudflare.Astro" "url": string } - "ZHIPU_API_KEY": { + "XAI_API_KEY": { "type": "sst.sst.Secret" "value": string } @@ -76,7 +76,6 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket - "GatewayApi": cloudflare.Service } } diff --git a/cloud/resource/sst-env.d.ts b/cloud/resource/sst-env.d.ts index d0b2bb4a..5478b433 100644 --- a/cloud/resource/sst-env.d.ts +++ b/cloud/resource/sst-env.d.ts @@ -14,6 +14,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "BASETEN_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string @@ -46,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "OPENAI_API_KEY": { - "type": "sst.sst.Secret" - "value": string - } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -62,7 +62,7 @@ declare module "sst" { "type": "sst.cloudflare.Astro" "url": string } - "ZHIPU_API_KEY": { + "XAI_API_KEY": { "type": "sst.sst.Secret" "value": string } @@ -76,7 +76,6 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket - "GatewayApi": cloudflare.Service } } diff --git a/infra/cloud.ts b/infra/cloud.ts index 9dbfcd2c..b628cde7 100644 --- a/infra/cloud.ts +++ b/infra/cloud.ts @@ -102,8 +102,8 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhook", { }) const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY") -const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY") -const ZHIPU_API_KEY = new sst.Secret("ZHIPU_API_KEY") +const XAI_API_KEY = new sst.Secret("XAI_API_KEY") +const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { properties: { value: auth.url.apply((url) => url!) }, @@ -111,20 +111,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { properties: { value: stripeWebhook.secret }, }) -export const gateway = new sst.cloudflare.Worker("GatewayApi", { - domain: `api.gateway.${domain}`, - handler: "cloud/function/src/gateway.ts", - url: true, - link: [ - database, - AUTH_API_URL, - STRIPE_WEBHOOK_SECRET, - STRIPE_SECRET_KEY, - ANTHROPIC_API_KEY, - OPENAI_API_KEY, - ZHIPU_API_KEY, - ], -}) //////////////// // CONSOLE @@ -139,8 +125,8 @@ new sst.cloudflare.x.SolidStart("Console", { STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, ANTHROPIC_API_KEY, - OPENAI_API_KEY, - ZHIPU_API_KEY, + XAI_API_KEY, + BASETEN_API_KEY, ], environment: { //VITE_DOCS_URL: web.url.apply((url) => url!), diff --git a/opencode.json b/opencode.json index f416e91b..6226069a 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,23 @@ { "$schema": "https://opencode.ai/config.json", - + "provider": { + "frank": { + "npm": "@ai-sdk/openai-compatible", + "name": "My AI ProviderDisplay Name", + "env": ["OPENCODE_API_KEY"], + "options": { + "baseURL": "https://console.frank.dev.opencode.ai/gateway/v1" + }, + "models": { + "x-ai/grok-code-fast-1": { + "name": "Grok Code Fast 1" + }, + "qwen/qwen3-coder": { + "name": "Qwen 3 Coder" + } + } + } + }, "mcp": { "weather": { "type": "local", diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index d0b2bb4a..5478b433 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -14,6 +14,10 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "BASETEN_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string @@ -46,10 +50,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "OPENAI_API_KEY": { - "type": "sst.sst.Secret" - "value": string - } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -62,7 +62,7 @@ declare module "sst" { "type": "sst.cloudflare.Astro" "url": string } - "ZHIPU_API_KEY": { + "XAI_API_KEY": { "type": "sst.sst.Secret" "value": string } @@ -76,7 +76,6 @@ declare module "sst" { "AuthApi": cloudflare.Service "AuthStorage": cloudflare.KVNamespace "Bucket": cloudflare.R2Bucket - "GatewayApi": cloudflare.Service } } diff --git a/sst-env.d.ts b/sst-env.d.ts index a9276330..534b20a6 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -24,6 +24,10 @@ declare module "sst" { "AuthStorage": { "type": "sst.cloudflare.Kv" } + "BASETEN_API_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Bucket": { "name": string "type": "sst.cloudflare.Bucket" @@ -60,14 +64,6 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } - "GatewayApi": { - "type": "sst.cloudflare.Worker" - "url": string - } - "OPENAI_API_KEY": { - "type": "sst.sst.Secret" - "value": string - } "STRIPE_SECRET_KEY": { "type": "sst.sst.Secret" "value": string @@ -80,7 +76,7 @@ declare module "sst" { "type": "sst.cloudflare.Astro" "url": string } - "ZHIPU_API_KEY": { + "XAI_API_KEY": { "type": "sst.sst.Secret" "value": string } diff --git a/sst.config.ts b/sst.config.ts index 06cce7f3..f9ffa579 100644 --- a/sst.config.ts +++ b/sst.config.ts @@ -12,15 +12,14 @@ export default $config({ }, planetscale: "0.4.1", }, - }; + } }, async run() { - const { api } = await import("./infra/app.js"); - const { auth, gateway } = await import("./infra/cloud.js"); + const { api } = await import("./infra/app.js") + const { auth } = await import("./infra/cloud.js") return { api: api.url, - gateway: gateway.url, auth: auth.url, - }; + } }, -}); +})