diff options
| author | Frank <[email protected]> | 2025-08-04 21:08:28 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-08-04 21:08:29 -0400 |
| commit | 40036abb9d6f4adc8ef4458742d508f984461c7f (patch) | |
| tree | ddb125398fa255a711e1dd01f964046a98e03ced /packages/function | |
| parent | 2970ba6416286fc78598d99a93cbb5fae1bfd4a6 (diff) | |
| download | opencode-40036abb9d6f4adc8ef4458742d508f984461c7f.tar.gz opencode-40036abb9d6f4adc8ef4458742d508f984461c7f.zip | |
wip: gateway
Diffstat (limited to 'packages/function')
| -rw-r--r-- | packages/function/package.json | 9 | ||||
| -rw-r--r-- | packages/function/src/gateway.ts | 499 | ||||
| -rw-r--r-- | packages/function/sst-env.d.ts | 17 |
3 files changed, 523 insertions, 2 deletions
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 } } |
