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