summaryrefslogtreecommitdiffhomepage
path: root/cloud/function
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-08-08 13:22:54 -0400
committerFrank <[email protected]>2025-08-08 13:24:32 -0400
commit183e0911b76025a1f2a82e979d9834fec2131d0e (patch)
tree9987c1753bd64d1ce1d174ab397f1a8c681f642c /cloud/function
parentc7bb19ad0712469063eab35589aa5d3602b0c5b1 (diff)
downloadopencode-183e0911b76025a1f2a82e979d9834fec2131d0e.tar.gz
opencode-183e0911b76025a1f2a82e979d9834fec2131d0e.zip
wip: gateway
Diffstat (limited to 'cloud/function')
-rw-r--r--cloud/function/package.json23
-rw-r--r--cloud/function/src/auth.ts68
-rw-r--r--cloud/function/src/gateway.ts887
-rw-r--r--cloud/function/sst-env.d.ts88
-rw-r--r--cloud/function/tsconfig.json9
5 files changed, 1075 insertions, 0 deletions
diff --git a/cloud/function/package.json b/cloud/function/package.json
new file mode 100644
index 000000000..b48bcd741
--- /dev/null
+++ b/cloud/function/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "@opencode/cloud-function",
+ "version": "0.3.130",
+ "$schema": "https://json.schemastore.org/package.json",
+ "private": true,
+ "type": "module",
+ "devDependencies": {
+ "@cloudflare/workers-types": "4.20250522.0",
+ "@types/node": "catalog:",
+ "openai": "5.11.0",
+ "typescript": "catalog:"
+ },
+ "dependencies": {
+ "@ai-sdk/anthropic": "2.0.0",
+ "@ai-sdk/openai": "2.0.2",
+ "@ai-sdk/openai-compatible": "1.0.1",
+ "@hono/zod-validator": "catalog:",
+ "@openauthjs/openauth": "0.0.0-20250322224806",
+ "ai": "catalog:",
+ "hono": "catalog:",
+ "zod": "catalog:"
+ }
+}
diff --git a/cloud/function/src/auth.ts b/cloud/function/src/auth.ts
new file mode 100644
index 000000000..5eacb7a72
--- /dev/null
+++ b/cloud/function/src/auth.ts
@@ -0,0 +1,68 @@
+import { Resource } from "sst"
+import { z } from "zod"
+import { issuer } from "@openauthjs/openauth"
+import { createSubjects } from "@openauthjs/openauth/subject"
+import { GithubProvider } from "@openauthjs/openauth/provider/github"
+import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
+import { Account } from "@opencode/cloud-core/account.js"
+
+type Env = {
+ AuthStorage: KVNamespace
+}
+
+export const subjects = createSubjects({
+ account: z.object({
+ accountID: z.string(),
+ email: z.string(),
+ }),
+ user: z.object({
+ userID: z.string(),
+ workspaceID: z.string(),
+ }),
+})
+
+export default {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
+ return issuer({
+ providers: {
+ github: GithubProvider({
+ clientID: Resource.GITHUB_CLIENT_ID_CONSOLE.value,
+ clientSecret: Resource.GITHUB_CLIENT_SECRET_CONSOLE.value,
+ scopes: ["read:user", "user:email"],
+ }),
+ },
+ storage: CloudflareStorage({
+ namespace: env.AuthStorage,
+ }),
+ subjects,
+ async success(ctx, response) {
+ console.log(response)
+
+ let email: string | undefined
+
+ if (response.provider === "github") {
+ const userResponse = await fetch("https://api.github.com/user", {
+ headers: {
+ Authorization: `Bearer ${response.tokenset.access}`,
+ "User-Agent": "opencode",
+ Accept: "application/vnd.github+json",
+ },
+ })
+ const user = (await userResponse.json()) as { email: string }
+ email = user.email
+ } else throw new Error("Unsupported provider")
+
+ if (!email) throw new Error("No email found")
+
+ let accountID = await Account.fromEmail(email).then((x) => x?.id)
+ if (!accountID) {
+ console.log("creating account for", email)
+ accountID = await Account.create({
+ email: email!,
+ })
+ }
+ return ctx.subject("account", accountID, { accountID, email })
+ },
+ }).fetch(request, env, ctx)
+ },
+}
diff --git a/cloud/function/src/gateway.ts b/cloud/function/src/gateway.ts
new file mode 100644
index 000000000..aefea7ac4
--- /dev/null
+++ b/cloud/function/src/gateway.ts
@@ -0,0 +1,887 @@
+import { z } from "zod"
+import { Hono, MiddlewareHandler } from "hono"
+import { cors } from "hono/cors"
+import { HTTPException } from "hono/http-exception"
+import { zValidator } from "@hono/zod-validator"
+import { Resource } from "sst"
+import { generateText, streamText } from "ai"
+import { createAnthropic } from "@ai-sdk/anthropic"
+import { createOpenAI } from "@ai-sdk/openai"
+import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
+import type { LanguageModelV2Usage, LanguageModelV2Prompt } from "@ai-sdk/provider"
+import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions"
+import { Actor } from "@opencode/cloud-core/actor.js"
+import { and, Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
+import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
+import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
+import { createClient } from "@openauthjs/openauth/client"
+import { Log } from "@opencode/cloud-core/util/log.js"
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { Workspace } from "@opencode/cloud-core/workspace.js"
+import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
+import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
+import { Identifier } from "../../core/src/identifier"
+
+type Env = {}
+
+let _client: ReturnType<typeof createClient>
+const client = () => {
+ if (_client) return _client
+ _client = createClient({
+ clientID: "api",
+ issuer: Resource.AUTH_API_URL.value,
+ })
+ return _client
+}
+
+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 log = Log.create({
+ namespace: "api",
+})
+
+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 RestAuth: MiddlewareHandler = async (c, next) => {
+ const authorization = c.req.header("authorization")
+ if (!authorization) {
+ return Actor.provide("public", {}, next)
+ }
+ const token = authorization.split(" ")[1]
+ if (!token)
+ throw new HTTPException(403, {
+ message: "Bearer token is required.",
+ })
+
+ const verified = await client().verify(token)
+ if (verified.err) {
+ throw new HTTPException(403, {
+ message: "Invalid token.",
+ })
+ }
+ let subject = verified.subject as Actor.Info
+ if (subject.type === "account") {
+ const workspaceID = c.req.header("x-opencode-workspace")
+ const email = subject.properties.email
+ if (workspaceID) {
+ const user = await Database.use((tx) =>
+ tx
+ .select({
+ id: UserTable.id,
+ workspaceID: UserTable.workspaceID,
+ email: UserTable.email,
+ })
+ .from(UserTable)
+ .where(and(eq(UserTable.email, email), eq(UserTable.workspaceID, workspaceID)))
+ .then((rows) => rows[0]),
+ )
+ if (!user)
+ throw new HTTPException(403, {
+ message: "You do not have access to this workspace.",
+ })
+ subject = {
+ type: "user",
+ properties: {
+ userID: user.id,
+ workspaceID: workspaceID,
+ email: user.email,
+ },
+ }
+ }
+ }
+ await Actor.provide(subject.type, subject.properties, 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) => {
+ try {
+ const body = await c.req.json<ChatCompletionCreateParamsBase>()
+
+ console.log(body)
+
+ 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 streamText({
+ model,
+ ...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.fullStream) {
+ // TODO
+ //console.log("!!! CHUCK !!!", chunk);
+ switch (chunk.type) {
+ case "text-delta": {
+ const data = {
+ id,
+ object: "chat.completion.chunk",
+ created,
+ model: body.model,
+ choices: [
+ {
+ index: 0,
+ delta: {
+ content: chunk.text,
+ },
+ 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.text,
+ },
+ 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: [
+ {
+ id: chunk.toolCallId,
+ type: "function",
+ function: {
+ name: chunk.toolName,
+ arguments: JSON.stringify(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,
+ error: {
+ message: 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 finishReason =
+ {
+ stop: "stop",
+ length: "length",
+ "content-filter": "content_filter",
+ "tool-calls": "tool_calls",
+ error: "stop",
+ other: "stop",
+ unknown: "stop",
+ }[chunk.finishReason] || "stop"
+
+ const data = {
+ id,
+ object: "chat.completion.chunk",
+ created,
+ model: body.model,
+ choices: [
+ {
+ index: 0,
+ delta: {},
+ finish_reason: finishReason,
+ },
+ ],
+ usage: {
+ prompt_tokens: chunk.totalUsage.inputTokens,
+ completion_tokens: chunk.totalUsage.outputTokens,
+ total_tokens: chunk.totalUsage.totalTokens,
+ completion_tokens_details: {
+ reasoning_tokens: chunk.totalUsage.reasoningTokens,
+ },
+ prompt_tokens_details: {
+ cached_tokens: chunk.totalUsage.cachedInputTokens,
+ },
+ },
+ }
+ 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 "start-step":
+ case "finish-step":
+ 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 generateText({
+ model,
+ ...requestBody,
+ })
+ await trackUsage(body.model, response.usage)
+ 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()
+
+ 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" }
+ if (body.response_format.type === "json_schema")
+ return {
+ type: "json",
+ 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" }
+ throw new Error("Unsupported response format")
+ })(),
+ seed: body.seed ?? undefined,
+ }
+
+ function transformTools() {
+ const { tools, tool_choice } = body
+
+ if (!tools || tools.length === 0) {
+ return { tools: undefined, toolChoice: undefined }
+ }
+
+ const aiSdkTools = tools.reduce(
+ (acc, tool) => {
+ acc[tool.function.name] = {
+ type: "function" as const,
+ name: tool.function.name,
+ description: tool.function.description,
+ inputSchema: tool.function.parameters,
+ }
+ return acc
+ },
+ {} as Record<string, any>,
+ )
+
+ let aiSdkToolChoice
+ if (tool_choice == null) {
+ aiSdkToolChoice = undefined
+ } else if (tool_choice === "auto") {
+ aiSdkToolChoice = "auto"
+ } else if (tool_choice === "none") {
+ aiSdkToolChoice = "none"
+ } else if (tool_choice === "required") {
+ aiSdkToolChoice = "required"
+ } else if (tool_choice.type === "function") {
+ aiSdkToolChoice = {
+ type: "tool",
+ 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: LanguageModelV2Usage) {
+ const keyRecord = c.get("keyRecord")
+ if (!keyRecord) return
+
+ const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS]
+ if (!modelData) throw new Error(`Unsupported model: ${model}`)
+
+ const inputCost = modelData.input * (usage.inputTokens ?? 0)
+ const outputCost = modelData.output * (usage.outputTokens ?? 0)
+ const reasoningCost = modelData.reasoning * (usage.reasoningTokens ?? 0)
+ const cacheReadCost = modelData.cacheRead * (usage.cachedInputTokens ?? 0)
+ const cacheWriteCost = modelData.cacheWrite * (usage.outputTokens ?? 0)
+
+ const totalCost = inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost
+
+ await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => {
+ await Billing.consume({
+ model,
+ inputTokens: usage.inputTokens ?? 0,
+ outputTokens: usage.outputTokens ?? 0,
+ reasoningTokens: usage.reasoningTokens ?? 0,
+ cacheReadTokens: usage.cachedInputTokens ?? 0,
+ cacheWriteTokens: usage.outputTokens ?? 0,
+ costInCents: totalCost * 100,
+ })
+ })
+
+ 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)
+ }
+ })
+ .use("/*", cors())
+ .use(RestAuth)
+ .get("/rest/account", async (c) => {
+ const account = Actor.assert("account")
+ let workspaces = await Workspace.list()
+ if (workspaces.length === 0) {
+ await Workspace.create()
+ workspaces = await Workspace.list()
+ }
+ return c.json({
+ id: account.properties.accountID,
+ email: account.properties.email,
+ workspaces,
+ })
+ })
+ .get("/billing/info", async (c) => {
+ const billing = await Billing.get()
+ const payments = await Database.use((tx) =>
+ tx
+ .select()
+ .from(PaymentTable)
+ .where(eq(PaymentTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${PaymentTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ const usage = await Database.use((tx) =>
+ tx
+ .select()
+ .from(UsageTable)
+ .where(eq(UsageTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${UsageTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ return c.json({ billing, payments, usage })
+ })
+ .post(
+ "/billing/checkout",
+ zValidator(
+ "json",
+ z.custom<{
+ success_url: string
+ cancel_url: string
+ }>(),
+ ),
+ async (c) => {
+ const account = Actor.assert("user")
+
+ const body = await c.req.json()
+
+ const customer = await Billing.get()
+ const session = await Billing.stripe().checkout.sessions.create({
+ mode: "payment",
+ line_items: [
+ {
+ price_data: {
+ currency: "usd",
+ product_data: {
+ name: "OpenControl credits",
+ },
+ unit_amount: 2000, // $20 minimum
+ },
+ quantity: 1,
+ },
+ ],
+ payment_intent_data: {
+ setup_future_usage: "on_session",
+ },
+ ...(customer.customerID
+ ? { customer: customer.customerID }
+ : {
+ customer_email: account.properties.email,
+ customer_creation: "always",
+ }),
+ metadata: {
+ workspaceID: Actor.workspace(),
+ },
+ currency: "usd",
+ payment_method_types: ["card"],
+ success_url: body.success_url,
+ cancel_url: body.cancel_url,
+ })
+
+ return c.json({
+ url: session.url,
+ })
+ },
+ )
+ .post("/billing/portal", async (c) => {
+ const body = await c.req.json()
+
+ const customer = await Billing.get()
+ if (!customer?.customerID) {
+ throw new Error("No stripe customer ID")
+ }
+
+ const session = await Billing.stripe().billingPortal.sessions.create({
+ customer: customer.customerID,
+ return_url: body.return_url,
+ })
+
+ return c.json({
+ url: session.url,
+ })
+ })
+ .post("/stripe/webhook", async (c) => {
+ const body = await Billing.stripe().webhooks.constructEventAsync(
+ await c.req.text(),
+ c.req.header("stripe-signature")!,
+ Resource.STRIPE_WEBHOOK_SECRET.value,
+ )
+
+ console.log(body.type, JSON.stringify(body, null, 2))
+ if (body.type === "checkout.session.completed") {
+ const workspaceID = body.data.object.metadata?.workspaceID
+ const customerID = body.data.object.customer as string
+ const paymentID = body.data.object.payment_intent as string
+ const amount = body.data.object.amount_total
+
+ 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 (!paymentID) throw new Error("Payment ID not found")
+
+ await Actor.provide("system", { workspaceID }, async () => {
+ const customer = await Billing.get()
+ if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // set customer metadata
+ if (!customer?.customerID) {
+ await Billing.stripe().customers.update(customerID, {
+ metadata: {
+ workspaceID,
+ },
+ })
+ }
+
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
+ customerID,
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ await tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(amount),
+ paymentID,
+ customerID,
+ })
+ })
+ })
+ }
+
+ console.log("finished handling")
+
+ return c.json("ok", 200)
+ })
+ .get("/keys", async (c) => {
+ const user = Actor.assert("user")
+
+ const keys = await Database.use((tx) =>
+ tx
+ .select({
+ id: KeyTable.id,
+ name: KeyTable.name,
+ key: KeyTable.key,
+ userID: KeyTable.userID,
+ timeCreated: KeyTable.timeCreated,
+ timeUsed: KeyTable.timeUsed,
+ })
+ .from(KeyTable)
+ .where(eq(KeyTable.workspaceID, user.properties.workspaceID))
+ .orderBy(sql`${KeyTable.timeCreated} DESC`),
+ )
+
+ return c.json({ keys })
+ })
+ .post("/keys", zValidator("json", z.object({ name: z.string().min(1).max(255) })), async (c) => {
+ const user = Actor.assert("user")
+ const { name } = c.req.valid("json")
+
+ // Generate secret key: sk- + 64 random characters (upper, lower, numbers)
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ let randomPart = ""
+ for (let i = 0; i < 64; i++) {
+ randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ const secretKey = `sk-${randomPart}`
+
+ const keyRecord = await Database.use((tx) =>
+ tx
+ .insert(KeyTable)
+ .values({
+ id: Identifier.create("key"),
+ workspaceID: user.properties.workspaceID,
+ userID: user.properties.userID,
+ name,
+ key: secretKey,
+ timeUsed: null,
+ })
+ .returning(),
+ )
+
+ return c.json({
+ key: secretKey,
+ id: keyRecord[0].id,
+ name: keyRecord[0].name,
+ created: keyRecord[0].timeCreated,
+ })
+ })
+ .delete("/keys/:id", async (c) => {
+ const user = Actor.assert("user")
+ const keyId = c.req.param("id")
+
+ const result = await Database.use((tx) =>
+ tx
+ .delete(KeyTable)
+ .where(and(eq(KeyTable.id, keyId), eq(KeyTable.workspaceID, user.properties.workspaceID)))
+ .returning({ id: KeyTable.id }),
+ )
+
+ if (result.length === 0) {
+ return c.json({ error: "Key not found" }, 404)
+ }
+
+ return c.json({ success: true, id: result[0].id })
+ })
+ .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
new file mode 100644
index 000000000..4e2b15921
--- /dev/null
+++ b/cloud/function/sst-env.d.ts
@@ -0,0 +1,88 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+import "sst"
+declare module "sst" {
+ export interface Resource {
+ "ANTHROPIC_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "AUTH_API_URL": {
+ "type": "sst.sst.Linkable"
+ "value": string
+ }
+ "Console": {
+ "type": "sst.cloudflare.StaticSite"
+ "url": string
+ }
+ "DATABASE_PASSWORD": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "DATABASE_USERNAME": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "Database": {
+ "database": string
+ "host": string
+ "password": string
+ "port": number
+ "type": "sst.sst.Linkable"
+ "username": string
+ }
+ "GITHUB_APP_ID": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "GITHUB_APP_PRIVATE_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "GITHUB_CLIENT_ID_CONSOLE": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "GITHUB_CLIENT_SECRET_CONSOLE": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "OPENAI_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "STRIPE_SECRET_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "STRIPE_WEBHOOK_SECRET": {
+ "type": "sst.sst.Linkable"
+ "value": string
+ }
+ "Web": {
+ "type": "sst.cloudflare.Astro"
+ "url": string
+ }
+ "ZHIPU_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ }
+}
+// cloudflare
+import * as cloudflare from "@cloudflare/workers-types";
+declare module "sst" {
+ export interface Resource {
+ "Api": cloudflare.Service
+ "AuthApi": cloudflare.Service
+ "AuthStorage": cloudflare.KVNamespace
+ "Bucket": cloudflare.R2Bucket
+ "GatewayApi": cloudflare.Service
+ }
+}
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/cloud/function/tsconfig.json b/cloud/function/tsconfig.json
new file mode 100644
index 000000000..0faf16aab
--- /dev/null
+++ b/cloud/function/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@tsconfig/node22/tsconfig.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["@cloudflare/workers-types", "node"]
+ }
+}