mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-20 01:04:22 +01:00
wip: cloud
This commit is contained in:
@@ -1,70 +1,179 @@
|
|||||||
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,
|
|
||||||
// cacheRead: 0.0000001,
|
|
||||||
// cacheWrite: 0.0000001,
|
|
||||||
// model: () =>
|
|
||||||
// createAnthropic({
|
|
||||||
// apiKey: Resource.ANTHROPIC_API_KEY.value,
|
// apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||||
// })("claude-sonnet-4-20250514"),
|
// model: "claude-sonnet-4-20250514",
|
||||||
// },
|
// cost: {
|
||||||
// "openai/gpt-4.1": {
|
|
||||||
// input: 0.0000015,
|
// input: 0.0000015,
|
||||||
// output: 0.000006,
|
// output: 0.000006,
|
||||||
// reasoning: 0.0000015,
|
// reasoning: 0.0000015,
|
||||||
// cacheRead: 0.0000001,
|
// cacheRead: 0.0000001,
|
||||||
// cacheWrite: 0.0000001,
|
// cacheWrite: 0.0000001,
|
||||||
// model: () =>
|
|
||||||
// createOpenAI({
|
|
||||||
// apiKey: Resource.OPENAI_API_KEY.value,
|
|
||||||
// })("gpt-4.1"),
|
|
||||||
// },
|
// },
|
||||||
// "zhipuai/glm-4.5-flash": {
|
// headerMappings: {},
|
||||||
// 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
class AuthError extends Error {}
|
||||||
// Check auth header
|
class CreditsError extends Error {}
|
||||||
const authHeader = input.request.headers.get("authorization")
|
class ModelError extends Error {}
|
||||||
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
|
export async function POST(input: APIEvent) {
|
||||||
const keyRecord = await Database.use((tx) =>
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
headers: resHeaders,
|
||||||
|
})
|
||||||
|
|
||||||
|
function validateModel() {
|
||||||
|
if (!(body.model in MODELS)) {
|
||||||
|
throw new ModelError(`Model ${body.model} not supported`)
|
||||||
|
}
|
||||||
|
return MODELS[body.model as keyof typeof MODELS]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
try {
|
||||||
|
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
|
tx
|
||||||
.select({
|
.select({
|
||||||
id: KeyTable.id,
|
id: KeyTable.id,
|
||||||
@@ -75,502 +184,122 @@ export async function POST(input: APIEvent) {
|
|||||||
.then((rows) => rows[0]),
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!keyRecord)
|
if (!key) throw new AuthError("Invalid API key.")
|
||||||
return Response.json(
|
return key
|
||||||
{
|
} catch (e) {
|
||||||
error: {
|
console.log(e)
|
||||||
message: "Invalid API key.",
|
// ignore error if model does not require authentication
|
||||||
type: "invalid_request_error",
|
if (!MODEL.auth) return
|
||||||
param: null,
|
throw e
|
||||||
code: "unauthorized",
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
{ status: 401 },
|
async function checkCredits() {
|
||||||
|
if (!apiKey || !MODEL.auth) return
|
||||||
|
|
||||||
|
const billing = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select({
|
||||||
|
balance: BillingTable.balance,
|
||||||
|
})
|
||||||
|
.from(BillingTable)
|
||||||
|
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||||
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>()
|
async function trackUsage(chunk: any) {
|
||||||
const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model()
|
console.log(`trackUsage ${apiKey}`)
|
||||||
if (!model) throw new Error(`Unsupported model: ${body.model}`)
|
|
||||||
|
|
||||||
const requestBody = transformOpenAIRequestToAiSDK()
|
if (!apiKey) return
|
||||||
|
|
||||||
return body.stream ? await handleStream() : await handleGenerate()
|
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
|
||||||
|
|
||||||
async function handleStream() {
|
const inputCost = MODEL.cost.input * inputTokens
|
||||||
const result = await model.doStream({
|
const outputCost = MODEL.cost.output * outputTokens
|
||||||
...requestBody,
|
const reasoningCost = MODEL.cost.reasoning * reasoningTokens
|
||||||
})
|
const cacheReadCost = MODEL.cost.cacheRead * cacheReadTokens
|
||||||
|
const cacheWriteCost = MODEL.cost.cacheWrite * cacheWriteTokens
|
||||||
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
|
const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
|
||||||
|
const cost = centsToMicroCents(costInCents)
|
||||||
|
|
||||||
await Billing.consume({
|
await Database.transaction(async (tx) => {
|
||||||
model,
|
await tx.insert(UsageTable).values({
|
||||||
|
workspaceID: apiKey.workspaceID,
|
||||||
|
id: Identifier.create("usage"),
|
||||||
|
model: MODEL.id,
|
||||||
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) {
|
} catch (error: any) {
|
||||||
return Response.json({ error: { message: error.message } }, { status: 500 })
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
|
||||||
11
cloud/function/sst-env.d.ts
vendored
11
cloud/function/sst-env.d.ts
vendored
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
cloud/resource/sst-env.d.ts
vendored
11
cloud/resource/sst-env.d.ts
vendored
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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!),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
11
packages/function/sst-env.d.ts
vendored
11
packages/function/sst-env.d.ts
vendored
@@ -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
14
sst-env.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
}
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user