wip: cloud

This commit is contained in:
Frank
2025-09-02 20:01:11 -04:00
parent 4624f0a260
commit 4e629c5b64
12 changed files with 324 additions and 1230 deletions

View File

@@ -1,576 +1,305 @@
import { Resource } from "@opencode/cloud-resource" import { Resource } from "@opencode/cloud-resource"
import { Billing } from "@opencode/cloud-core/billing.js"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js" 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 { 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": { // "anthropic/claude-sonnet-4": {
// input: 0.0000015, // auth: true,
// output: 0.000006, // api: "https://api.anthropic.com",
// reasoning: 0.0000015, // apiKey: Resource.ANTHROPIC_API_KEY.value,
// cacheRead: 0.0000001, // model: "claude-sonnet-4-20250514",
// cacheWrite: 0.0000001, // cost: {
// model: () => // input: 0.0000015,
// createAnthropic({ // output: 0.000006,
// apiKey: Resource.ANTHROPIC_API_KEY.value, // reasoning: 0.0000015,
// })("claude-sonnet-4-20250514"), // cacheRead: 0.0000001,
// }, // cacheWrite: 0.0000001,
// "openai/gpt-4.1": { // },
// input: 0.0000015, // headerMappings: {},
// 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"),
// }, // },
"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",
},
},
} }
class AuthError extends Error {}
class CreditsError extends Error {}
class ModelError extends Error {}
export async function POST(input: APIEvent) { export async function POST(input: APIEvent) {
// Check auth header try {
const authHeader = input.request.headers.get("authorization") const url = new URL(input.request.url)
if (!authHeader || !authHeader.startsWith("Bearer ")) const body = await input.request.json()
return Response.json( const MODEL = validateModel()
{ const apiKey = await authenticate()
error: { await checkCredits()
message: "Missing API key.",
type: "invalid_request_error",
param: null,
code: "unauthorized",
},
},
{ status: 401 },
)
const apiKey = authHeader.split(" ")[1]
// Check against KeyTable // Request to model provider
const keyRecord = await Database.use((tx) => const res = await fetch(new URL(url.pathname.replace(/^\/gateway/, "") + url.search, MODEL.api), {
tx method: "POST",
.select({ headers: (() => {
id: KeyTable.id, const headers = input.request.headers
workspaceID: KeyTable.workspaceID, 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)
}
}
// 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,
}) })
.from(KeyTable) }
.where(eq(KeyTable.key, apiKey))
.then((rows) => rows[0]),
)
if (!keyRecord) // Handle streaming response
return Response.json( const stream = new ReadableStream({
{ start(c) {
error: { const reader = res.body?.getReader()
message: "Invalid API key.", const decoder = new TextDecoder()
type: "invalid_request_error", let buffer = ""
param: null,
code: "unauthorized", function pump(): Promise<void> {
}, return (
reader?.read().then(async ({ done, value }) => {
if (done) {
c.close()
return
}
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
const usage = parts
.map((part) => part.trim())
.filter((part) => part.startsWith("data: "))
.map((part) => {
try {
return JSON.parse(part.slice(6))
} catch (e) {
return {}
}
})
.find((part) => part.usage)
if (usage) await trackUsage(usage)
c.enqueue(value)
return pump()
}) || Promise.resolve()
)
}
return pump()
}, },
{ status: 401 }, })
)
/* return new Response(stream, {
return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => { status: res.status,
try { statusText: res.statusText,
// Check balance headers: resHeaders,
const customer = await Billing.get() })
if (customer.balance <= 0) {
return Response.json( function validateModel() {
{ if (!(body.model in MODELS)) {
error: { throw new ModelError(`Model ${body.model} not supported`)
message: "Insufficient balance", }
type: "insufficient_quota", return MODELS[body.model as keyof typeof MODELS]
param: null, }
code: "insufficient_quota",
}, async function authenticate() {
}, try {
{ status: 401 }, const authHeader = input.request.headers.get("authorization")
if (!authHeader || !authHeader.startsWith("Bearer ")) throw new AuthError("Missing API key.")
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]),
) )
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
} }
}
const body = await input.request.json<ChatCompletionCreateParamsBase>() async function checkCredits() {
const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model() if (!apiKey || !MODEL.auth) return
if (!model) throw new Error(`Unsupported model: ${body.model}`)
const requestBody = transformOpenAIRequestToAiSDK() const billing = await Database.use((tx) =>
tx
return body.stream ? await handleStream() : await handleGenerate() .select({
balance: BillingTable.balance,
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
}
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,
},
},
})
}
function transformOpenAIRequestToAiSDK() {
const prompt = transformMessages()
const tools = transformTools()
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" 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 }
}
const aiSdkTools = tools.map((tool) => {
return {
type: tool.type,
name: tool.function.name,
description: tool.function.description,
inputSchema: tool.function.parameters!,
}
}) })
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
.then((rows) => rows[0]),
)
let aiSdkToolChoice if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
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 } async function trackUsage(chunk: any) {
} console.log(`trackUsage ${apiKey}`)
function transformMessages() { if (!apiKey) return
const { messages } = body
const prompt: LanguageModelV2Prompt = []
for (const message of messages) { const usage = chunk.usage
switch (message.role) { const inputTokens = usage.prompt_tokens ?? 0
case "system": { const outputTokens = usage.completion_tokens ?? 0
prompt.push({ const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? 0
role: "system", const cacheReadTokens = usage.prompt_tokens_details?.cached_tokens ?? 0
content: message.content as string, //const cacheWriteTokens = providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? 0
}) const cacheWriteTokens = 0
break
}
case "user": { const inputCost = MODEL.cost.input * inputTokens
if (typeof message.content === "string") { const outputCost = MODEL.cost.output * outputTokens
prompt.push({ const reasoningCost = MODEL.cost.reasoning * reasoningTokens
role: "user", const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens
content: [{ type: "text", text: message.content }], const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens
}) const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
} else { const cost = centsToMicroCents(costInCents)
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": { await Database.transaction(async (tx) => {
const content: Array< await tx.insert(UsageTable).values({
| { type: "text"; text: string } workspaceID: apiKey.workspaceID,
| { id: Identifier.create("usage"),
type: "tool-call" model: MODEL.id,
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
}
}
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}`)
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 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
await Billing.consume({
model,
inputTokens, inputTokens,
outputTokens, outputTokens,
reasoningTokens, reasoningTokens,
cacheReadTokens, cacheReadTokens,
cacheWriteTokens, cacheWriteTokens,
costInCents, cost,
}) })
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
await Database.use((tx) => await Database.use((tx) =>
tx tx
.update(KeyTable) .update(KeyTable)
.set({ timeUsed: sql`now()` }) .set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, keyRecord.id)), .where(eq(KeyTable.id, apiKey.id)),
) )
}
} catch (error: any) {
return Response.json({ error: { message: error.message } }, { status: 500 })
} }
}) } 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,
})
}
} }

View File

@@ -24,8 +24,11 @@ export async function POST(input: APIEvent) {
if (!workspaceID) throw new Error("Workspace ID not found") if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found") if (!customerID) throw new Error("Customer ID not found")
if (!amount) throw new Error("Amount 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") if (!paymentID) throw new Error("Payment ID not found")
const chargedAmount = 2000
await Actor.provide("system", { workspaceID }, async () => { await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get() const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch") if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
@@ -50,7 +53,7 @@ export async function POST(input: APIEvent) {
await tx await tx
.update(BillingTable) .update(BillingTable)
.set({ .set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`, balance: sql`${BillingTable.balance} + ${centsToMicroCents(chargedAmount)}`,
customerID, customerID,
paymentMethodID: paymentMethod.id, paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card!.last4, paymentMethodLast4: paymentMethod.card!.last4,
@@ -59,7 +62,7 @@ export async function POST(input: APIEvent) {
await tx.insert(PaymentTable).values({ await tx.insert(PaymentTable).values({
workspaceID, workspaceID,
id: Identifier.create("payment"), id: Identifier.create("payment"),
amount: centsToMicroCents(amount), amount: centsToMicroCents(chargedAmount),
paymentID, paymentID,
customerID, customerID,
}) })

View File

@@ -52,43 +52,6 @@ export namespace Billing {
) )
} }
export const consume = fn(
z.object({
requestID: z.string().optional(),
model: z.string(),
inputTokens: z.number(),
outputTokens: z.number(),
reasoningTokens: z.number().optional(),
cacheReadTokens: z.number().optional(),
cacheWriteTokens: z.number().optional(),
costInCents: z.number(),
}),
async (input) => {
const workspaceID = Actor.workspace()
const cost = centsToMicroCents(input.costInCents)
return await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID,
id: Identifier.create("usage"),
model: input.model,
inputTokens: input.inputTokens,
outputTokens: input.outputTokens,
reasoningTokens: input.reasoningTokens,
cacheReadTokens: input.cacheReadTokens,
cacheWriteTokens: input.cacheWriteTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
},
)
export const generateCheckoutUrl = fn( export const generateCheckoutUrl = fn(
z.object({ z.object({
successUrl: z.string(), successUrl: z.string(),
@@ -109,7 +72,7 @@ export namespace Billing {
product_data: { product_data: {
name: "opencode credits", name: "opencode credits",
}, },
unit_amount: 2000, // $20 minimum unit_amount: 2118, // $20 minimum + Stripe fee 4.4% + $0.30
}, },
quantity: 1, quantity: 1,
}, },

View File

@@ -26,7 +26,7 @@ export namespace Workspace {
await tx.insert(BillingTable).values({ await tx.insert(BillingTable).values({
workspaceID, workspaceID,
id: Identifier.create("billing"), id: Identifier.create("billing"),
balance: centsToMicroCents(100), balance: 0,
}) })
}) })
await Actor.provide( await Actor.provide(

View File

@@ -1,596 +0,0 @@
import { Hono, MiddlewareHandler } from "hono"
import { type ProviderMetadata, type LanguageModelUsage } 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"
import { Actor } from "@opencode/cloud-core/actor.js"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
import { Billing } from "@opencode/cloud-core/billing.js"
import { Resource } from "@opencode/cloud-resource"
type Env = {}
const SUPPORTED_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"),
},
}
const GatewayAuth: MiddlewareHandler = async (c, 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]
// 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 c.json(
{
error: {
message: "Invalid API key.",
type: "invalid_request_error",
param: null,
code: "unauthorized",
},
},
401,
)
}
c.set("keyRecord", keyRecord)
await next()
}
const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>()
.get("/", (c) => c.text("Hello, world!"))
.post("/v1/chat/completions", GatewayAuth, async (c) => {
const keyRecord = c.get("keyRecord")!
return await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => {
try {
// Check balance
const customer = await Billing.get()
if (customer.balance <= 0) {
return c.json(
{
error: {
message: "Insufficient balance",
type: "insufficient_quota",
param: null,
code: "insufficient_quota",
},
},
401,
)
}
const body = await c.req.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
}
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,
},
},
})
}
function transformOpenAIRequestToAiSDK() {
const prompt = transformMessages()
const tools = transformTools()
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" 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 }
}
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 = []
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
}
}
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}`)
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 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
await Billing.consume({
model,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWriteTokens,
costInCents,
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, keyRecord.id)),
)
}
} catch (error: any) {
return c.json({ error: { message: error.message } }, 500)
}
})
})
.all("*", (c) => c.text("Not Found"))
export type ApiType = typeof app
export default app

View File

@@ -14,6 +14,10 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "value": string
} }
"BASETEN_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Console": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string
@@ -46,10 +50,6 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": { "STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
@@ -62,7 +62,7 @@ declare module "sst" {
"type": "sst.cloudflare.Astro" "type": "sst.cloudflare.Astro"
"url": string "url": string
} }
"ZHIPU_API_KEY": { "XAI_API_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
@@ -76,7 +76,6 @@ declare module "sst" {
"AuthApi": cloudflare.Service "AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace "AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket "Bucket": cloudflare.R2Bucket
"GatewayApi": cloudflare.Service
} }
} }

View File

@@ -14,6 +14,10 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "value": string
} }
"BASETEN_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Console": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string
@@ -46,10 +50,6 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": { "STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
@@ -62,7 +62,7 @@ declare module "sst" {
"type": "sst.cloudflare.Astro" "type": "sst.cloudflare.Astro"
"url": string "url": string
} }
"ZHIPU_API_KEY": { "XAI_API_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
@@ -76,7 +76,6 @@ declare module "sst" {
"AuthApi": cloudflare.Service "AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace "AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket "Bucket": cloudflare.R2Bucket
"GatewayApi": cloudflare.Service
} }
} }

View File

@@ -102,8 +102,8 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhook", {
}) })
const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY") const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY")
const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY") const XAI_API_KEY = new sst.Secret("XAI_API_KEY")
const ZHIPU_API_KEY = new sst.Secret("ZHIPU_API_KEY") const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", { const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) }, properties: { value: auth.url.apply((url) => url!) },
@@ -111,20 +111,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret }, properties: { value: stripeWebhook.secret },
}) })
export const gateway = new sst.cloudflare.Worker("GatewayApi", {
domain: `api.gateway.${domain}`,
handler: "cloud/function/src/gateway.ts",
url: true,
link: [
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ANTHROPIC_API_KEY,
OPENAI_API_KEY,
ZHIPU_API_KEY,
],
})
//////////////// ////////////////
// CONSOLE // CONSOLE
@@ -139,8 +125,8 @@ new sst.cloudflare.x.SolidStart("Console", {
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
ANTHROPIC_API_KEY, ANTHROPIC_API_KEY,
OPENAI_API_KEY, XAI_API_KEY,
ZHIPU_API_KEY, BASETEN_API_KEY,
], ],
environment: { environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_DOCS_URL: web.url.apply((url) => url!),

View File

@@ -1,6 +1,23 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"provider": {
"frank": {
"npm": "@ai-sdk/openai-compatible",
"name": "My AI ProviderDisplay Name",
"env": ["OPENCODE_API_KEY"],
"options": {
"baseURL": "https://console.frank.dev.opencode.ai/gateway/v1"
},
"models": {
"x-ai/grok-code-fast-1": {
"name": "Grok Code Fast 1"
},
"qwen/qwen3-coder": {
"name": "Qwen 3 Coder"
}
}
}
},
"mcp": { "mcp": {
"weather": { "weather": {
"type": "local", "type": "local",

View File

@@ -14,6 +14,10 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "value": string
} }
"BASETEN_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Console": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string
@@ -46,10 +50,6 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": { "STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
@@ -62,7 +62,7 @@ declare module "sst" {
"type": "sst.cloudflare.Astro" "type": "sst.cloudflare.Astro"
"url": string "url": string
} }
"ZHIPU_API_KEY": { "XAI_API_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
@@ -76,7 +76,6 @@ declare module "sst" {
"AuthApi": cloudflare.Service "AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace "AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket "Bucket": cloudflare.R2Bucket
"GatewayApi": cloudflare.Service
} }
} }

14
sst-env.d.ts vendored
View File

@@ -24,6 +24,10 @@ declare module "sst" {
"AuthStorage": { "AuthStorage": {
"type": "sst.cloudflare.Kv" "type": "sst.cloudflare.Kv"
} }
"BASETEN_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"Bucket": { "Bucket": {
"name": string "name": string
"type": "sst.cloudflare.Bucket" "type": "sst.cloudflare.Bucket"
@@ -60,14 +64,6 @@ declare module "sst" {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }
"GatewayApi": {
"type": "sst.cloudflare.Worker"
"url": string
}
"OPENAI_API_KEY": {
"type": "sst.sst.Secret"
"value": string
}
"STRIPE_SECRET_KEY": { "STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
@@ -80,7 +76,7 @@ declare module "sst" {
"type": "sst.cloudflare.Astro" "type": "sst.cloudflare.Astro"
"url": string "url": string
} }
"ZHIPU_API_KEY": { "XAI_API_KEY": {
"type": "sst.sst.Secret" "type": "sst.sst.Secret"
"value": string "value": string
} }

View File

@@ -12,15 +12,14 @@ export default $config({
}, },
planetscale: "0.4.1", planetscale: "0.4.1",
}, },
}; }
}, },
async run() { async run() {
const { api } = await import("./infra/app.js"); const { api } = await import("./infra/app.js")
const { auth, gateway } = await import("./infra/cloud.js"); const { auth } = await import("./infra/cloud.js")
return { return {
api: api.url, api: api.url,
gateway: gateway.url,
auth: auth.url, auth: auth.url,
}; }
}, },
}); })