diff options
| author | Frank <[email protected]> | 2025-09-02 20:01:11 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-09-02 20:01:13 -0400 |
| commit | 4e629c5b64d52f6633e5e1a54d250b4b57c92604 (patch) | |
| tree | a786a64484e161b4d2f0ac25a97bb3fa874cb861 /cloud/app | |
| parent | 4624f0a260c36d55756b6f53641daacb878f9d92 (diff) | |
| download | opencode-4e629c5b64d52f6633e5e1a54d250b4b57c92604.tar.gz opencode-4e629c5b64d52f6633e5e1a54d250b4b57c92604.zip | |
wip: cloud
Diffstat (limited to 'cloud/app')
| -rw-r--r-- | cloud/app/src/routes/gateway/v1/chat/completions.ts | 799 | ||||
| -rw-r--r-- | cloud/app/src/routes/stripe/webhook.ts | 7 |
2 files changed, 269 insertions, 537 deletions
diff --git a/cloud/app/src/routes/gateway/v1/chat/completions.ts b/cloud/app/src/routes/gateway/v1/chat/completions.ts index 671b589c5..8a0b54b14 100644 --- a/cloud/app/src/routes/gateway/v1/chat/completions.ts +++ b/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, + }) + } } diff --git a/cloud/app/src/routes/stripe/webhook.ts b/cloud/app/src/routes/stripe/webhook.ts index 61d14a645..592f4568d 100644 --- a/cloud/app/src/routes/stripe/webhook.ts +++ b/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, }) |
