summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock31
-rw-r--r--infra/app.ts12
-rw-r--r--opencode.json15
-rw-r--r--packages/function/package.json9
-rw-r--r--packages/function/src/gateway.ts499
-rw-r--r--packages/function/sst-env.d.ts17
-rw-r--r--packages/plugin/sst-env.d.ts9
-rw-r--r--packages/sdk/js/sst-env.d.ts9
-rw-r--r--sst-env.d.ts20
-rw-r--r--sst.config.ts3
10 files changed, 616 insertions, 8 deletions
diff --git a/bun.lock b/bun.lock
index ef76de154..43b012b63 100644
--- a/bun.lock
+++ b/bun.lock
@@ -12,14 +12,19 @@
"name": "@opencode/function",
"version": "0.3.128",
"dependencies": {
+ "@ai-sdk/anthropic": "2.0.0",
+ "@ai-sdk/openai": "2.0.2",
+ "@ai-sdk/openai-compatible": "1.0.1",
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
+ "ai": "catalog:",
"hono": "catalog:",
"jose": "6.0.11",
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250522.0",
"@types/node": "catalog:",
+ "openai": "5.11.0",
"typescript": "catalog:",
},
},
@@ -157,13 +162,17 @@
"@ai-sdk/amazon-bedrock": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-icLGO7Q0NinnHIPgT+y1QjHVwH4HwV+brWbvM+FfCG2Afpa89PyKa3Ret91kGjZpBgM/xnj1B7K5eM+rRlsXQA=="],
- "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
+ "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/gateway": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@ai-sdk/provider-utils": "3.0.0-beta.10" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-felWPMuECZRGx8xnmvH5dW3jywKTkGnw/tXN8szphGzEDr/BfxywuXijfPBG2WBUS6frPXsvSLDRdCm5W38PXA=="],
- "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="],
+ "@ai-sdk/openai": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
+
+ "@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
+
+ "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
- "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-e6WSsgM01au04/1L/v5daXHn00eKjPBQXl3jq3BfvQbQ1jo8Rls2pvrdkyVc25jBW4TV4Zm+tw+v6NAh5NPXMA=="],
+ "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ampproject/remapping": ["@ampproject/[email protected]", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
@@ -1367,6 +1376,8 @@
"open": ["[email protected]", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
+ "openai": ["[email protected]", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="],
+
"openapi-types": ["[email protected]", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"opencode": ["opencode@workspace:packages/opencode"],
@@ -1841,9 +1852,9 @@
"@ai-sdk/amazon-bedrock/aws4fetch": ["[email protected]", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
- "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+ "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="],
- "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+ "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-e6WSsgM01au04/1L/v5daXHn00eKjPBQXl3jq3BfvQbQ1jo8Rls2pvrdkyVc25jBW4TV4Zm+tw+v6NAh5NPXMA=="],
"@ampproject/remapping/@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
@@ -1875,6 +1886,10 @@
"@rollup/pluginutils/estree-walker": ["[email protected]", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
+ "ai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="],
+
+ "ai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-beta.2", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-e6WSsgM01au04/1L/v5daXHn00eKjPBQXl3jq3BfvQbQ1jo8Rls2pvrdkyVc25jBW4TV4Zm+tw+v6NAh5NPXMA=="],
+
"ansi-align/string-width": ["[email protected]", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["[email protected]", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1915,6 +1930,8 @@
"nypm/pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "opencode/@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
+
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/[email protected]", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/hono": ["[email protected]", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="],
@@ -2001,6 +2018,10 @@
"gray-matter/js-yaml/argparse": ["[email protected]", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
+ "opencode/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
+ "opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["[email protected]", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
"opencontrol/@modelcontextprotocol/sdk/zod": ["[email protected]", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="],
diff --git a/infra/app.ts b/infra/app.ts
index 5c646d97c..2b09516d7 100644
--- a/infra/app.ts
+++ b/infra/app.ts
@@ -46,3 +46,15 @@ new sst.cloudflare.x.Astro("Web", {
VITE_API_URL: api.url,
},
})
+
+const OPENCODE_API_KEY = new sst.Secret("OPENCODE_API_KEY")
+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")
+
+export const gateway = new sst.cloudflare.Worker("GatewayApi", {
+ domain: `api.gateway.${domain}`,
+ handler: "packages/function/src/gateway.ts",
+ url: true,
+ link: [OPENCODE_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, ZHIPU_API_KEY],
+})
diff --git a/opencode.json b/opencode.json
index 59f14ac75..6cba9ba6a 100644
--- a/opencode.json
+++ b/opencode.json
@@ -1,5 +1,20 @@
{
"$schema": "https://opencode.ai/config.json",
+ "model": "opencode/anthropic/claude-sonnet-4",
+ "provider": {
+ "opencode": {
+ "name": "opencode",
+ "npm": "@ai-sdk/openai-compatible",
+ "options": {
+ "baseURL": "https://api.gateway.frank.dev.opencode.ai/v1"
+ },
+ "models": {
+ "anthropic/claude-sonnet-4": {},
+ "openai/gpt-4.1": {},
+ "zhipu/glm-4.5-flash": {}
+ }
+ }
+ },
"mcp": {
"context7": {
"type": "remote",
diff --git a/packages/function/package.json b/packages/function/package.json
index 361055f04..82fac0700 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -6,12 +6,17 @@
"type": "module",
"devDependencies": {
"@cloudflare/workers-types": "4.20250522.0",
- "typescript": "catalog:",
- "@types/node": "catalog:"
+ "@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",
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
+ "ai": "catalog:",
"hono": "catalog:",
"jose": "6.0.11"
}
diff --git a/packages/function/src/gateway.ts b/packages/function/src/gateway.ts
new file mode 100644
index 000000000..17e9f5094
--- /dev/null
+++ b/packages/function/src/gateway.ts
@@ -0,0 +1,499 @@
+import { Hono, Context, Next } from "hono"
+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 LanguageModelV2Prompt } from "@ai-sdk/provider"
+import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions"
+
+type Env = {}
+
+const auth = async (c: Context, next: 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]
+
+ // Replace with your validation logic
+ if (apiKey !== Resource.OPENCODE_API_KEY.value) {
+ return c.json(
+ {
+ error: {
+ message: "Invalid API key.",
+ type: "invalid_request_error",
+ param: null,
+ code: "unauthorized",
+ },
+ },
+ 401,
+ )
+ }
+
+ await next()
+}
+export default new Hono<{ Bindings: Env }>()
+ .get("/", (c) => c.text("Hello, world!"))
+ .post("/v1/chat/completions", auth, async (c) => {
+ try {
+ const body = await c.req.json<ChatCompletionCreateParamsBase>()
+
+ console.log(body)
+
+ const model = (() => {
+ const [provider, ...parts] = body.model.split("/")
+ const model = parts.join("/")
+ if (provider === "anthropic" && model === "claude-sonnet-4") {
+ return createAnthropic({
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ })("claude-sonnet-4-20250514")
+ }
+ if (provider === "openai" && model === "gpt-4.1") {
+ return createOpenAI({
+ apiKey: Resource.OPENAI_API_KEY.value,
+ })("gpt-4.1")
+ }
+ if (provider === "zhipuai" && model === "glm-4.5-flash") {
+ return createOpenAICompatible({
+ name: "Zhipu AI",
+ baseURL: "https://api.z.ai/api/paas/v4",
+ apiKey: Resource.ZHIPU_API_KEY.value,
+ })("glm-4.5-flash")
+ }
+ throw new Error(`Unsupported provider: ${provider}`)
+ })()
+
+ 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,
+ })
+ 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
+ }
+ }
+ } catch (error: any) {
+ return c.json({ error: { message: error.message } }, 500)
+ }
+ })
+ .all("*", (c) => c.text("Not Found"))
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index dab7de3f3..7106662e3 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/function/sst-env.d.ts
@@ -6,6 +6,10 @@
import "sst"
declare module "sst" {
export interface Resource {
+ "ANTHROPIC_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
@@ -14,10 +18,22 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
+ "OPENAI_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
+ "OPENCODE_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
+ "ZHIPU_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
}
}
// cloudflare
@@ -26,6 +42,7 @@ declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"Bucket": cloudflare.R2Bucket
+ "GatewayApi": cloudflare.Service
}
}
diff --git a/packages/plugin/sst-env.d.ts b/packages/plugin/sst-env.d.ts
new file mode 100644
index 000000000..b6a7e9066
--- /dev/null
+++ b/packages/plugin/sst-env.d.ts
@@ -0,0 +1,9 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+/// <reference path="../../sst-env.d.ts" />
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/packages/sdk/js/sst-env.d.ts b/packages/sdk/js/sst-env.d.ts
new file mode 100644
index 000000000..9b9de7327
--- /dev/null
+++ b/packages/sdk/js/sst-env.d.ts
@@ -0,0 +1,9 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+
+/// <reference path="../../../sst-env.d.ts" />
+
+import "sst"
+export {} \ No newline at end of file
diff --git a/sst-env.d.ts b/sst-env.d.ts
index 2c3e3d5ad..8286f0938 100644
--- a/sst-env.d.ts
+++ b/sst-env.d.ts
@@ -5,6 +5,10 @@
declare module "sst" {
export interface Resource {
+ "ANTHROPIC_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
@@ -20,10 +24,26 @@ 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
+ }
+ "OPENCODE_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
}
+ "ZHIPU_API_KEY": {
+ "type": "sst.sst.Secret"
+ "value": string
+ }
}
}
/// <reference path="sst-env.d.ts" />
diff --git a/sst.config.ts b/sst.config.ts
index 4c36fea58..c15fdabb6 100644
--- a/sst.config.ts
+++ b/sst.config.ts
@@ -10,9 +10,10 @@ export default $config({
}
},
async run() {
- const { api } = await import("./infra/app.js")
+ const { api, gateway } = await import("./infra/app.js")
return {
api: api.url,
+ gateway: gateway.url,
}
},
})