2
0
Frank 5 сар өмнө
parent
commit
4e629c5b64

+ 264 - 535
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",
+    },
+  },
 }
 
-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]
-
-  // 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 Response.json(
-      {
-        error: {
-          message: "Invalid API key.",
-          type: "invalid_request_error",
-          param: null,
-          code: "unauthorized",
-        },
-      },
-      { 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 },
-        )
-      }
-
-      const body = await input.request.json<ChatCompletionCreateParamsBase>()
-      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
-                  }
+class AuthError extends Error {}
+class CreditsError extends Error {}
+class ModelError extends Error {}
 
-                  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,
-            },
-          },
+export async function POST(input: APIEvent) {
+  try {
+    const url = new URL(input.request.url)
+    const body = await input.request.json()
+    const MODEL = validateModel()
+    const apiKey = await authenticate()
+    await checkCredits()
+
+    // 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)
       }
+    }
 
-      function transformOpenAIRequestToAiSDK() {
-        const prompt = transformMessages()
-        const tools = transformTools()
+    // 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,
+      })
+    }
 
-        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,
-                },
+    // Handle streaming response
+    const stream = new ReadableStream({
+      start(c) {
+        const reader = res.body?.getReader()
+        const decoder = new TextDecoder()
+        let buffer = ""
+
+        function pump(): Promise<void> {
+          return (
+            reader?.read().then(async ({ done, value }) => {
+              if (done) {
+                c.close()
+                return
               }
-            : 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 }
-          }
+              buffer += decoder.decode(value, { stream: true })
 
-          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 = []
+              const parts = buffer.split("\n\n")
+              buffer = parts.pop() ?? ""
 
-          for (const message of messages) {
-            switch (message.role) {
-              case "system": {
-                prompt.push({
-                  role: "system",
-                  content: message.content as string,
+              const usage = parts
+                .map((part) => part.trim())
+                .filter((part) => part.startsWith("data: "))
+                .map((part) => {
+                  try {
+                    return JSON.parse(part.slice(6))
+                  } catch (e) {
+                    return {}
+                  }
                 })
-                break
-              }
+                .find((part) => part.usage)
+              if (usage) await trackUsage(usage)
 
-              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
-              }
+              c.enqueue(value)
 
-              case "assistant": {
-                const content: Array<
-                  | { type: "text"; text: string }
-                  | {
-                      type: "tool-call"
-                      toolCallId: string
-                      toolName: string
-                      input: any
-                    }
-                > = []
+              return pump()
+            }) || Promise.resolve()
+          )
+        }
 
-                if (message.content) {
-                  content.push({
-                    type: "text",
-                    text: message.content as string,
-                  })
-                }
+        return pump()
+      },
+    })
 
-                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),
-                    })
-                  }
-                }
+    return new Response(stream, {
+      status: res.status,
+      statusText: res.statusText,
+      headers: resHeaders,
+    })
 
-                prompt.push({
-                  role: "assistant",
-                  content,
-                })
-                break
-              }
+    function validateModel() {
+      if (!(body.model in MODELS)) {
+        throw new ModelError(`Model ${body.model} not supported`)
+      }
+      return MODELS[body.model as keyof typeof MODELS]
+    }
 
-              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
-              }
+    async function authenticate() {
+      try {
+        const authHeader = input.request.headers.get("authorization")
+        if (!authHeader || !authHeader.startsWith("Bearer ")) throw new AuthError("Missing API key.")
 
-              default: {
-                throw new Error(`Unsupported message role: ${message.role}`)
-              }
-            }
-          }
+        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]),
+        )
 
-          return prompt
-        }
+        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
       }
+    }
 
-      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}`)
+    async function checkCredits() {
+      if (!apiKey || !MODEL.auth) return
 
-        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 billing = await Database.use((tx) =>
+        tx
+          .select({
+            balance: BillingTable.balance,
+          })
+          .from(BillingTable)
+          .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+          .then((rows) => rows[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
+      if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+    }
 
-        await Billing.consume({
-          model,
+    async function trackUsage(chunk: any) {
+      console.log(`trackUsage ${apiKey}`)
+
+      if (!apiKey) return
+
+      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
+
+      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)
+
+      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,
+    })
+  }
 }

+ 5 - 2
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,
         })

+ 1 - 38
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,
           },

+ 1 - 1
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(

+ 0 - 596
cloud/function/src/gateway.ts

@@ -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<ChatCompletionCreateParamsBase>()
-        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

+ 5 - 6
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
   }
 }
 

+ 5 - 6
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
   }
 }
 

+ 4 - 18
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!),

+ 18 - 1
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",

+ 5 - 6
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
   }
 }
 

+ 5 - 9
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
     }

+ 5 - 6
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,
-    };
+    }
   },
-});
+})