From 40036abb9d6f4adc8ef4458742d508f984461c7f Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 4 Aug 2025 21:08:28 -0400 Subject: [PATCH] wip: gateway --- bun.lock | 31 +- infra/app.ts | 12 + opencode.json | 15 + packages/function/package.json | 9 +- packages/function/src/gateway.ts | 499 +++++++++++++++++++++++++++++++ packages/function/sst-env.d.ts | 17 ++ packages/plugin/sst-env.d.ts | 9 + packages/sdk/js/sst-env.d.ts | 9 + sst-env.d.ts | 20 ++ sst.config.ts | 3 +- 10 files changed, 616 insertions(+), 8 deletions(-) create mode 100644 packages/function/src/gateway.ts create mode 100644 packages/plugin/sst-env.d.ts create mode 100644 packages/sdk/js/sst-env.d.ts diff --git a/bun.lock b/bun.lock index ef76de15..43b012b6 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/amazon-bedrock@2.2.10", "", { "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/anthropic@1.2.12", "", { "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/anthropic@2.0.0", "", { "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/gateway@1.0.0-beta.19", "", { "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/provider@2.0.0-beta.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="], + "@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "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/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.10", "", { "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/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "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/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "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/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], @@ -1367,6 +1376,8 @@ "open": ["open@10.1.2", "", { "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": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "opencode": ["opencode@workspace:packages/opencode"], @@ -1841,9 +1852,9 @@ "@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "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/provider-utils@3.0.0-beta.10", "", { "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/trace-mapping@0.3.29", "", { "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": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0-beta.2", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-vqhtZA7R24q1XnmfmIb1fZSmHMIaJH1BVQ+0kFnNJgqWsc+V8i+yfetZ37gUc4fXATFmBuS/6O7+RPoHsZ2Fqg=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0-beta.10", "", { "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": ["string-width@4.2.3", "", { "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": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -1915,6 +1930,8 @@ "nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@1.2.12", "", { "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/sdk@1.6.1", "", { "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": ["hono@4.7.4", "", {}, "sha512-Pst8FuGqz3L7tFF+u9Pu70eI0xa5S3LPUmrNd5Jm8nTHze9FxLTK9Kaj5g/k4UcwuJSXTP65SyHOPLrffpcAJg=="], @@ -2001,6 +2018,10 @@ "gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "opencode/@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@1.1.3", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="], + + "opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@2.2.8", "", { "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": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], "opencontrol/@modelcontextprotocol/sdk/zod": ["zod@3.25.49", "", {}, "sha512-JMMPMy9ZBk3XFEdbM3iL1brx4NUSejd6xr3ELrrGEfGb355gjhiAWtG3K5o+AViV/3ZfkIrCzXsZn6SbLwTR8Q=="], diff --git a/infra/app.ts b/infra/app.ts index 5c646d97..2b09516d 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 59f14ac7..6cba9ba6 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 361055f0..82fac070 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 00000000..17e9f509 --- /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() + + 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, + ) + + 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 dab7de3f..7106662e 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 00000000..b6a7e906 --- /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 */ + +/// + +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 00000000..9b9de732 --- /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 */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/sst-env.d.ts b/sst-env.d.ts index 2c3e3d5a..8286f093 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 + } } } /// diff --git a/sst.config.ts b/sst.config.ts index 4c36fea5..c15fdabb 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, } }, })