Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e12ba65c8a | ||
|
|
7a91769792 | ||
|
|
3437f3bc6f | ||
|
|
65a18700fa | ||
|
|
31a3c0082c | ||
|
|
c1940d1d2c | ||
|
|
869f629c14 | ||
|
|
a55943e469 | ||
|
|
84d95a0d2a | ||
|
|
c7462c39a5 | ||
|
|
e107f0c341 | ||
|
|
7dfed8ca35 | ||
|
|
04c8aa1d7c | ||
|
|
38ea0fc051 | ||
|
|
395f306fef | ||
|
|
ed2c8666f1 | ||
|
|
0cc3d9229e | ||
|
|
9223b6ed8f | ||
|
|
f8528c52d9 | ||
|
|
173c5f1bd6 | ||
|
|
d63ce40af2 | ||
|
|
5acdd70587 | ||
|
|
b04df6c0d2 | ||
|
|
98307ee091 | ||
|
|
02848a350c | ||
|
|
f1cbdf441c | ||
|
|
9420d80b73 | ||
|
|
c21161b75e | ||
|
|
aaff066457 | ||
|
|
c7fbf9de44 | ||
|
|
d88c17dad0 | ||
|
|
f57c3f7cf6 | ||
|
|
2460108223 | ||
|
|
84e8eea52e | ||
|
|
9efc2eaf2e | ||
|
|
37e2644452 | ||
|
|
22a78cf13f | ||
|
|
2e9806b320 | ||
|
|
ba839d4446 | ||
|
|
2bec21d81d | ||
|
|
e5271f3d1a | ||
|
|
1edb23c2c7 | ||
|
|
b1e6b9c7c9 | ||
|
|
20cb5a7c56 | ||
|
|
b1d44482bc | ||
|
|
e11102c9df | ||
|
|
7be9dc8e49 | ||
|
|
824e035815 | ||
|
|
d652b94a14 | ||
|
|
ebef2ea2d0 | ||
|
|
b5b8a0555d | ||
|
|
ae6154e1c3 | ||
|
|
0e19ca21ed | ||
|
|
baaff81a06 | ||
|
|
ffa5689885 | ||
|
|
0e409842e8 | ||
|
|
5a7a725787 | ||
|
|
f277512938 | ||
|
|
4ceabdffa0 | ||
|
|
c87480cf93 | ||
|
|
0df6fc1226 | ||
|
|
32ba2e02aa | ||
|
|
1ffc8be2b6 | ||
|
|
5f2945ae71 | ||
|
|
65baf76df6 | ||
|
|
3b6c0ec0b3 | ||
|
|
e9d902d844 | ||
|
|
e8b4f593a6 | ||
|
|
fc4f281408 | ||
|
|
f8c4f713a5 | ||
|
|
63c8874d2d | ||
|
|
71076d5c68 | ||
|
|
0319043b49 | ||
|
|
e0334d5569 | ||
|
|
ff6a93f355 | ||
|
|
733b21e22b | ||
|
|
3c3d6b65c2 |
@@ -0,0 +1,11 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
|
||||
export default tool({
|
||||
description: "call this tool when you want to give up",
|
||||
args: {
|
||||
message: tool.schema.string().describe("give up message"),
|
||||
},
|
||||
async execute(args) {
|
||||
return "Hey fuck you!"
|
||||
},
|
||||
})
|
||||
@@ -82,3 +82,6 @@
|
||||
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
|
||||
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
|
||||
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
|
||||
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
|
||||
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
|
||||
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
|
||||
|
||||
@@ -1,594 +0,0 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import path from "node:path"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
|
||||
import { BillingTable, PaymentTable, 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"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
|
||||
type ModelCost = {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead?: number
|
||||
cacheWrite5m?: number
|
||||
cacheWrite1h?: number
|
||||
}
|
||||
|
||||
type Model = {
|
||||
id: string
|
||||
auth: boolean
|
||||
cost: ModelCost | ((usage: any) => ModelCost)
|
||||
headerMappings: Record<string, string>
|
||||
providers: Record<
|
||||
string,
|
||||
{
|
||||
api: string
|
||||
apiKey: string
|
||||
model: string
|
||||
weight?: number
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
modifyBody?: (body: any) => any
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => void
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
onStreamPart: (chunk: string) => void
|
||||
getStreamUsage: () => any
|
||||
normalizeUsage: (body: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
},
|
||||
) {
|
||||
class AuthError extends Error {}
|
||||
class CreditsError extends Error {}
|
||||
class MonthlyLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
const MODELS: Record<string, Model> = {
|
||||
"claude-opus-4-1": {
|
||||
id: "claude-opus-4-1" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.000015,
|
||||
output: 0.000075,
|
||||
cacheRead: 0.0000015,
|
||||
cacheWrite5m: 0.00001875,
|
||||
cacheWrite1h: 0.00003,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-opus-4-1-20250805",
|
||||
},
|
||||
},
|
||||
},
|
||||
"claude-sonnet-4": {
|
||||
id: "claude-sonnet-4" as const,
|
||||
auth: true,
|
||||
cost: (usage: any) => {
|
||||
const totalInputTokens =
|
||||
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
|
||||
return totalInputTokens <= 200_000
|
||||
? {
|
||||
input: 0.000003,
|
||||
output: 0.000015,
|
||||
cacheRead: 0.0000003,
|
||||
cacheWrite5m: 0.00000375,
|
||||
cacheWrite1h: 0.000006,
|
||||
}
|
||||
: {
|
||||
input: 0.000006,
|
||||
output: 0.0000225,
|
||||
cacheRead: 0.0000006,
|
||||
cacheWrite5m: 0.0000075,
|
||||
cacheWrite1h: 0.000012,
|
||||
}
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
},
|
||||
},
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
id: "claude-3-5-haiku" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.0000008,
|
||||
output: 0.000004,
|
||||
cacheRead: 0.00000008,
|
||||
cacheWrite5m: 0.000001,
|
||||
cacheWrite1h: 0.0000016,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
anthropic: {
|
||||
api: "https://api.anthropic.com",
|
||||
apiKey: Resource.ANTHROPIC_API_KEY.value,
|
||||
model: "claude-3-5-haiku-20241022",
|
||||
},
|
||||
},
|
||||
},
|
||||
"gpt-5": {
|
||||
id: "gpt-5" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000125,
|
||||
output: 0.00001,
|
||||
cacheRead: 0.000000125,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
openai: {
|
||||
api: "https://api.openai.com",
|
||||
apiKey: Resource.OPENAI_API_KEY.value,
|
||||
model: "gpt-5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"qwen3-coder": {
|
||||
id: "qwen3-coder" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000045,
|
||||
output: 0.0000018,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
weight: 4,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"kimi-k2": {
|
||||
id: "kimi-k2" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.0000006,
|
||||
output: 0.0000025,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "moonshotai/Kimi-K2-Instruct-0905",
|
||||
weight: 4,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/kimi-k2-instruct-0905",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
"grok-code": {
|
||||
id: "grok-code" as const,
|
||||
auth: false,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
},
|
||||
headerMappings: {
|
||||
"x-grok-conv-id": "x-opencode-session",
|
||||
"x-grok-req-id": "x-opencode-request",
|
||||
},
|
||||
providers: {
|
||||
xai: {
|
||||
api: "https://api.x.ai",
|
||||
apiKey: Resource.XAI_API_KEY.value,
|
||||
model: "grok-code",
|
||||
},
|
||||
},
|
||||
},
|
||||
// deprecated
|
||||
"qwen/qwen3-coder": {
|
||||
id: "qwen/qwen3-coder" as const,
|
||||
auth: true,
|
||||
cost: {
|
||||
input: 0.00000038,
|
||||
output: 0.00000153,
|
||||
},
|
||||
headerMappings: {},
|
||||
providers: {
|
||||
baseten: {
|
||||
api: "https://inference.baseten.co",
|
||||
apiKey: Resource.BASETEN_API_KEY.value,
|
||||
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
|
||||
weight: 5,
|
||||
},
|
||||
fireworks: {
|
||||
api: "https://api.fireworks.ai/inference",
|
||||
apiKey: Resource.FIREWORKS_API_KEY.value,
|
||||
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
|
||||
weight: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
]
|
||||
|
||||
const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input.request.url)
|
||||
const body = await input.request.json()
|
||||
logger.debug(JSON.stringify(body))
|
||||
logger.metric({
|
||||
is_tream: !!body.stream,
|
||||
session: input.request.headers.get("x-opencode-session"),
|
||||
request: input.request.headers.get("x-opencode-request"),
|
||||
})
|
||||
const MODEL = validateModel()
|
||||
const apiKey = await authenticate()
|
||||
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
|
||||
await checkCreditsAndLimit()
|
||||
const providerName = selectProvider()
|
||||
const providerData = MODEL.providers[providerName]
|
||||
logger.metric({ provider: providerName })
|
||||
|
||||
// Request to model provider
|
||||
const startTimestamp = Date.now()
|
||||
const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = input.request.headers
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
opts.setAuthHeader(headers, providerData.apiKey)
|
||||
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify({
|
||||
...(opts.modifyBody?.(body) ?? body),
|
||||
model: providerData.model,
|
||||
}),
|
||||
})
|
||||
|
||||
// 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 json = await res.json()
|
||||
const body = JSON.stringify(json)
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug(body)
|
||||
await trackUsage(json.usage)
|
||||
await reload()
|
||||
return new Response(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 = ""
|
||||
let responseLength = 0
|
||||
|
||||
function pump(): Promise<void> {
|
||||
return (
|
||||
reader?.read().then(async ({ done, value }) => {
|
||||
if (done) {
|
||||
logger.metric({ response_length: responseLength })
|
||||
const usage = opts.getStreamUsage()
|
||||
if (usage) {
|
||||
await trackUsage(usage)
|
||||
await reload()
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (responseLength === 0) {
|
||||
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
|
||||
}
|
||||
responseLength += value.length
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() ?? ""
|
||||
|
||||
for (const part of parts) {
|
||||
logger.debug(part)
|
||||
opts.onStreamPart(part.trim())
|
||||
}
|
||||
|
||||
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`)
|
||||
}
|
||||
const model = MODELS[body.model as keyof typeof MODELS]
|
||||
logger.metric({ model: model.id })
|
||||
return model
|
||||
}
|
||||
|
||||
async function authenticate() {
|
||||
try {
|
||||
const apiKey = opts.parseApiKey(input.request.headers)
|
||||
if (!apiKey) throw new AuthError("Missing API key.")
|
||||
|
||||
const key = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
workspaceID: KeyTable.workspaceID,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!key) throw new AuthError("Invalid API key.")
|
||||
logger.metric({
|
||||
api_key: key.id,
|
||||
workspace: key.workspaceID,
|
||||
})
|
||||
return key
|
||||
} catch (e) {
|
||||
// ignore error if model does not require authentication
|
||||
if (!MODEL.auth) return
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCreditsAndLimit() {
|
||||
if (!apiKey || !MODEL.auth || isFree) return
|
||||
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
balance: BillingTable.balance,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
monthlyLimit: BillingTable.monthlyLimit,
|
||||
monthlyUsage: BillingTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
if (
|
||||
billing.monthlyLimit &&
|
||||
billing.monthlyUsage &&
|
||||
billing.timeMonthlyUsageUpdated &&
|
||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
||||
) {
|
||||
const now = new Date()
|
||||
const currentYear = now.getUTCFullYear()
|
||||
const currentMonth = now.getUTCMonth()
|
||||
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
|
||||
}
|
||||
}
|
||||
|
||||
function selectProvider() {
|
||||
const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
|
||||
Array<string>(provider.weight ?? 1).fill(name),
|
||||
)
|
||||
return picks[Math.floor(Math.random() * picks.length)]
|
||||
}
|
||||
|
||||
async function trackUsage(usage: any) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
opts.normalizeUsage(usage)
|
||||
|
||||
const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
return modelCost.cacheRead * cacheReadTokens * 100
|
||||
})()
|
||||
const cacheWrite5mCost = (() => {
|
||||
if (!cacheWrite5mTokens) return undefined
|
||||
if (!modelCost.cacheWrite5m) return undefined
|
||||
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
||||
})()
|
||||
const cacheWrite1hCost = (() => {
|
||||
if (!cacheWrite1hTokens) return undefined
|
||||
if (!modelCost.cacheWrite1h) return undefined
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
"tokens.output": outputTokens,
|
||||
"tokens.reasoning": reasoningTokens,
|
||||
"tokens.cache_read": cacheReadTokens,
|
||||
"tokens.cache_write_5m": cacheWrite5mTokens,
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
"cost.total": Math.round(totalCostInCent),
|
||||
})
|
||||
|
||||
if (!apiKey) return
|
||||
|
||||
const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: apiKey.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: MODEL.id,
|
||||
provider: providerName,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
})
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(eq(KeyTable.id, apiKey.id)),
|
||||
)
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
if (!apiKey) return
|
||||
|
||||
const lock = await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
timeReloadLockedTill: sql`now() + interval 1 minute`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(BillingTable.workspaceID, apiKey.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (lock.rowsAffected === 0) return
|
||||
|
||||
await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
|
||||
await Billing.reload()
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
})
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
error instanceof AuthError ||
|
||||
error instanceof CreditsError ||
|
||||
error instanceof MonthlyLimitError ||
|
||||
error instanceof ModelError
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
}),
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ new sst.x.DevCommand("Studio", {
|
||||
link: [database],
|
||||
dev: {
|
||||
command: "bun db studio",
|
||||
directory: "cloud/core",
|
||||
directory: "packages/console/core",
|
||||
autostart: true,
|
||||
},
|
||||
})
|
||||
@@ -59,7 +59,7 @@ const GOOGLE_CLIENT_ID = new sst.Secret("GOOGLE_CLIENT_ID")
|
||||
const authStorage = new sst.cloudflare.Kv("AuthStorage")
|
||||
export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
domain: `auth.${domain}`,
|
||||
handler: "cloud/function/src/auth.ts",
|
||||
handler: "packages/console/function/src/auth.ts",
|
||||
url: true,
|
||||
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
|
||||
})
|
||||
@@ -99,11 +99,7 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
],
|
||||
})
|
||||
|
||||
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 BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
|
||||
const FIREWORKS_API_KEY = new sst.Secret("FIREWORKS_API_KEY")
|
||||
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
@@ -120,25 +116,15 @@ let logProcessor
|
||||
if ($app.stage === "production" || $app.stage === "frank") {
|
||||
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
|
||||
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "cloud/function/src/log-processor.ts",
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
link: [HONEYCOMB_API_KEY],
|
||||
})
|
||||
}
|
||||
|
||||
new sst.cloudflare.x.SolidStart("Console", {
|
||||
domain,
|
||||
path: "cloud/app",
|
||||
link: [
|
||||
database,
|
||||
AUTH_API_URL,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
STRIPE_SECRET_KEY,
|
||||
ANTHROPIC_API_KEY,
|
||||
OPENAI_API_KEY,
|
||||
XAI_API_KEY,
|
||||
BASETEN_API_KEY,
|
||||
FIREWORKS_API_KEY,
|
||||
],
|
||||
path: "packages/console/app",
|
||||
link: [database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, ZEN_MODELS],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
//VITE_API_URL: gateway.url.apply((url) => url!),
|
||||
@@ -4,7 +4,7 @@ new sst.cloudflare.StaticSite("Desktop", {
|
||||
domain: "desktop." + domain,
|
||||
path: "packages/app",
|
||||
build: {
|
||||
command: "bun run build",
|
||||
command: "bun turbo build",
|
||||
output: "./dist",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -12,17 +12,21 @@
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"cloud/*",
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/sdk/js"
|
||||
],
|
||||
"catalog": {
|
||||
"@types/bun": "1.2.21",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"ai": "5.0.8",
|
||||
"hono": "4.7.10",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"typescript": "5.8.2",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
|
||||
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="/src/assets/theme.css" />
|
||||
<title>opencode</title>
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "0.9.10",
|
||||
"version": "0.10.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -29,7 +29,8 @@
|
||||
"@solidjs/router": "0.15.3",
|
||||
"@thisbeyond/solid-dnd": "0.7.5",
|
||||
"diff": "8.0.2",
|
||||
"luxon": "3.7.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -35,18 +35,19 @@ class ColorResolver {
|
||||
|
||||
try {
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("#") || value === "none") {
|
||||
return { dark: value, light: value }
|
||||
if (value === "none") return { dark: value, light: value }
|
||||
if (value.startsWith("#")) {
|
||||
return { dark: value.toUpperCase(), light: value.toUpperCase() }
|
||||
}
|
||||
const resolved = this.resolveReference(value)
|
||||
return { dark: resolved, light: resolved }
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const dark = this.resolveColorValue(value.dark || value.light || "#000000")
|
||||
const light = this.resolveColorValue(value.light || value.dark || "#ffffff")
|
||||
const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
|
||||
return { dark, light }
|
||||
}
|
||||
return { dark: "#000000", light: "#ffffff" }
|
||||
return { dark: "#000000", light: "#FFFFFF" }
|
||||
} finally {
|
||||
this.visited.delete(key)
|
||||
}
|
||||
@@ -54,8 +55,9 @@ class ColorResolver {
|
||||
|
||||
private resolveColorValue(value: any): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.startsWith("#") || value === "none") {
|
||||
return value
|
||||
if (value === "none") return value
|
||||
if (value.startsWith("#")) {
|
||||
return value.toUpperCase()
|
||||
}
|
||||
return this.resolveReference(value)
|
||||
}
|
||||
@@ -68,8 +70,9 @@ class ColorResolver {
|
||||
throw new Error(`Color reference '${ref}' not found`)
|
||||
}
|
||||
if (typeof colorValue === "string") {
|
||||
if (colorValue.startsWith("#") || colorValue === "none") {
|
||||
return colorValue
|
||||
if (colorValue === "none") return colorValue
|
||||
if (colorValue.startsWith("#")) {
|
||||
return colorValue.toUpperCase()
|
||||
}
|
||||
return this.resolveReference(colorValue)
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 377 B After Width: | Height: | Size: 377 B |
@@ -0,0 +1,188 @@
|
||||
import { Select as KobalteSelect } from "@kobalte/core/select"
|
||||
import { createEffect, createMemo, Show } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { Icon } from "@/ui/icon"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export interface SelectProps<T> {
|
||||
variant?: "default" | "outline"
|
||||
size?: "sm" | "md" | "lg"
|
||||
placeholder?: string
|
||||
filter?:
|
||||
| false
|
||||
| {
|
||||
placeholder?: string
|
||||
keys: string[]
|
||||
}
|
||||
options: T[]
|
||||
current?: T
|
||||
value?: (x: T) => string
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (value: T | undefined) => void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T>) {
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
let listboxRef: HTMLUListElement | undefined = undefined
|
||||
const [store, setStore] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
const grouped = createMemo(() => {
|
||||
const needle = store.filter.toLowerCase()
|
||||
const result = pipe(
|
||||
props.options,
|
||||
(x) =>
|
||||
!needle || !props.filter
|
||||
? x
|
||||
: fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
|
||||
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
entries(),
|
||||
map(([k, v]) => ({ category: k, options: v })),
|
||||
)
|
||||
return result
|
||||
})
|
||||
// const flat = createMemo(() => {
|
||||
// return pipe(
|
||||
// grouped(),
|
||||
// flatMap(({ options }) => options),
|
||||
// )
|
||||
// })
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
listboxRef?.scrollTo(0, 0)
|
||||
// setStore("selected", 0)
|
||||
// scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
return (
|
||||
<KobalteSelect<T, { category: string; options: T[] }>
|
||||
value={props.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
|
||||
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
|
||||
optionGroupChildren="options"
|
||||
placeholder={props.placeholder}
|
||||
sectionComponent={(props) => (
|
||||
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
|
||||
{props.section.rawValue.category}
|
||||
</KobalteSelect.Section>
|
||||
)}
|
||||
itemComponent={(itemProps) => (
|
||||
<KobalteSelect.Item
|
||||
classList={{
|
||||
"relative flex cursor-pointer select-none items-center": true,
|
||||
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
|
||||
"transition-colors data-[disabled]:pointer-events-none": true,
|
||||
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
{...itemProps}
|
||||
>
|
||||
<KobalteSelect.ItemLabel>
|
||||
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
|
||||
</KobalteSelect.ItemLabel>
|
||||
<KobalteSelect.ItemIndicator
|
||||
classList={{
|
||||
"ml-auto": true,
|
||||
}}
|
||||
>
|
||||
<Icon name="checkmark" size={16} />
|
||||
</KobalteSelect.ItemIndicator>
|
||||
</KobalteSelect.Item>
|
||||
)}
|
||||
onChange={(v) => {
|
||||
if (props.onSelect) props.onSelect(v ?? undefined)
|
||||
if (v !== null) {
|
||||
// close the select
|
||||
}
|
||||
}}
|
||||
onOpenChange={(v) => v || setStore("filter", "")}
|
||||
>
|
||||
<KobalteSelect.Trigger
|
||||
classList={{
|
||||
...(props.classList ?? {}),
|
||||
"flex w-full items-center justify-between rounded-md transition-colors": true,
|
||||
"focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
|
||||
"disabled:cursor-not-allowed disabled:opacity-50": true,
|
||||
"data-[placeholder-shown]:text-text-muted cursor-pointer": true,
|
||||
"hover:bg-background-element focus-visible:ring-border-active": true,
|
||||
"bg-background-element text-text": props.variant === "default" || !props.variant,
|
||||
"border-2 border-border bg-transparent text-text": props.variant === "outline",
|
||||
"h-6 pl-2 text-xs": props.size === "sm",
|
||||
"h-8 pl-3 text-sm": props.size === "md" || !props.size,
|
||||
"h-10 pl-4 text-base": props.size === "lg",
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
<KobalteSelect.Value<T>>
|
||||
{(state) => {
|
||||
const selected = state.selectedOption() ?? props.current
|
||||
if (!selected) return props.placeholder || ""
|
||||
if (props.label) return props.label(selected)
|
||||
return selected as string
|
||||
}}
|
||||
</KobalteSelect.Value>
|
||||
<KobalteSelect.Icon
|
||||
classList={{
|
||||
"size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size={24} />
|
||||
</KobalteSelect.Icon>
|
||||
</KobalteSelect.Trigger>
|
||||
<KobalteSelect.Portal>
|
||||
<KobalteSelect.Content
|
||||
onKeyDown={(e) => {
|
||||
if (!props.filter) return
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
|
||||
return
|
||||
}
|
||||
inputRef?.focus()
|
||||
}}
|
||||
classList={{
|
||||
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
|
||||
"bg-background-panel p-1 shadow-md z-50": true,
|
||||
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
|
||||
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
|
||||
}}
|
||||
>
|
||||
<Show when={props.filter}>
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
id="select-filter"
|
||||
type="text"
|
||||
placeholder={props.filter ? props.filter.placeholder : "Filter items"}
|
||||
value={store.filter}
|
||||
onInput={(e) => setStore("filter", e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
listboxRef?.focus()
|
||||
}
|
||||
}}
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<KobalteSelect.Listbox
|
||||
ref={(el) => (listboxRef = el)}
|
||||
classList={{
|
||||
"overflow-y-auto max-h-48 no-scrollbar": true,
|
||||
}}
|
||||
/>
|
||||
</KobalteSelect.Content>
|
||||
</KobalteSelect.Portal>
|
||||
</KobalteSelect>
|
||||
)
|
||||
}
|
||||
@@ -24,22 +24,26 @@ function init() {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const agent = (() => {
|
||||
const [store, setStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
current: list()[0].name,
|
||||
})
|
||||
return {
|
||||
list,
|
||||
current() {
|
||||
return agents().find((x) => x.name === store.current)!
|
||||
return list().find((x) => x.name === store.current)!
|
||||
},
|
||||
set(name: string | undefined) {
|
||||
setStore("current", name ?? list()[0].name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
let next = agents().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
let next = list().findIndex((x) => x.name === store.current) + direction
|
||||
if (next < 0) next = list().length - 1
|
||||
if (next >= list().length) next = 0
|
||||
const value = list()[next]
|
||||
setStore("current", value.name)
|
||||
if (value.model)
|
||||
model.set({
|
||||
@@ -89,7 +93,12 @@ function init() {
|
||||
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
|
||||
})
|
||||
|
||||
const list = createMemo(() =>
|
||||
sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))),
|
||||
)
|
||||
|
||||
return {
|
||||
list,
|
||||
current,
|
||||
recent() {
|
||||
return store.recent
|
||||
@@ -103,10 +112,10 @@ function init() {
|
||||
model: model.name ?? value.modelID,
|
||||
}
|
||||
}),
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
setStore("model", agent.current().name, model ?? fallback())
|
||||
if (options?.recent && model) {
|
||||
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setStore("recent", uniq)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
|
||||
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
|
||||
import { Tabs } from "@/ui/tabs"
|
||||
import { Select } from "@/components/select"
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { useLocal, useSDK } from "@/context"
|
||||
import { Code } from "@/components/code"
|
||||
import {
|
||||
@@ -17,18 +18,17 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { LocalFile } from "@/context/local"
|
||||
import SessionList from "@/components/session-list"
|
||||
import SessionTimeline from "@/components/session-timeline"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const local = useLocal()
|
||||
const [clickTimer, setClickTimer] = createSignal<number | undefined>()
|
||||
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
const [isDragging, setIsDragging] = createSignal<"left" | "right" | undefined>(undefined)
|
||||
const [leftScrolled, setLeftScrolled] = createSignal(false)
|
||||
|
||||
// TODO: remove
|
||||
local.model.set({ providerID: "opencode", modelID: "grok-code" })
|
||||
const [store, setStore] = createStore({
|
||||
clickTimer: undefined as number | undefined,
|
||||
activeItem: undefined as string | undefined,
|
||||
prompt: "",
|
||||
dragging: undefined as "left" | "right" | undefined,
|
||||
})
|
||||
|
||||
let inputRef: HTMLInputElement | undefined = undefined
|
||||
|
||||
@@ -51,6 +51,10 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
if (document.activeElement?.id === "select-filter") {
|
||||
return
|
||||
}
|
||||
|
||||
if (local.file.active()) {
|
||||
if (e.getModifierState(MOD)) {
|
||||
if (e.key.toLowerCase() === "a") {
|
||||
@@ -76,20 +80,20 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const resetClickTimer = () => {
|
||||
if (!clickTimer()) return
|
||||
clearTimeout(clickTimer())
|
||||
setClickTimer(undefined)
|
||||
if (!store.clickTimer) return
|
||||
clearTimeout(store.clickTimer)
|
||||
setStore("clickTimer", undefined)
|
||||
}
|
||||
|
||||
const startClickTimer = () => {
|
||||
const newClickTimer = setTimeout(() => {
|
||||
setClickTimer(undefined)
|
||||
setStore("clickTimer", undefined)
|
||||
}, 300)
|
||||
setClickTimer(newClickTimer as unknown as number)
|
||||
setStore("clickTimer", newClickTimer as unknown as number)
|
||||
}
|
||||
|
||||
const handleFileClick = async (file: LocalFile) => {
|
||||
if (clickTimer()) {
|
||||
if (store.clickTimer) {
|
||||
resetClickTimer()
|
||||
local.file.update(file.path, { ...file, pinned: true })
|
||||
} else {
|
||||
@@ -107,7 +111,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const onDragStart = (event: any) => {
|
||||
setActiveItem(event.draggable.id as string)
|
||||
setStore("activeItem", event.draggable.id as string)
|
||||
}
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
@@ -123,12 +127,12 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const onDragEnd = () => {
|
||||
setActiveItem(undefined)
|
||||
setStore("activeItem", undefined)
|
||||
}
|
||||
|
||||
const handleLeftDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging("left")
|
||||
setStore("dragging", "left")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.leftWidth()
|
||||
|
||||
@@ -139,7 +143,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(undefined)
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
@@ -150,7 +154,7 @@ export default function Page() {
|
||||
|
||||
const handleRightDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging("right")
|
||||
setStore("dragging", "right")
|
||||
const startX = e.clientX
|
||||
const startWidth = local.layout.rightWidth()
|
||||
|
||||
@@ -161,7 +165,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(undefined)
|
||||
setStore("dragging", undefined)
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
@@ -172,8 +176,8 @@ export default function Page() {
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
const prompt = inputValue()
|
||||
setInputValue("")
|
||||
const prompt = store.prompt
|
||||
setStore("prompt", "")
|
||||
inputRef?.blur()
|
||||
|
||||
const session =
|
||||
@@ -228,35 +232,19 @@ export default function Page() {
|
||||
<Tabs class="relative flex flex-col h-full" defaultValue="files">
|
||||
<div class="sticky top-0 shrink-0 flex">
|
||||
<Tabs.List class="grow w-full after:hidden">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center">
|
||||
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
|
||||
Files
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center">
|
||||
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
|
||||
Changes
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</div>
|
||||
<Tabs.Content
|
||||
value="files"
|
||||
class="grow min-h-0 py-2 bg-background"
|
||||
onScroll={(e: Event & { currentTarget: HTMLDivElement }) => setLeftScrolled(e.currentTarget.scrollTop > 0)}
|
||||
>
|
||||
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
|
||||
<FileTree path="" onFileClick={handleFileClick} />
|
||||
<Show when={leftScrolled()}>
|
||||
<div
|
||||
class="pointer-events-none sticky top-20 left-px h-4
|
||||
bg-gradient-to-t from-transparent to-background"
|
||||
style={`width: ${local.layout.leftWidth() - 2}px`}
|
||||
/>
|
||||
</Show>
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-0 left-px h-4
|
||||
bg-gradient-to-b from-transparent to-background"
|
||||
style={`width: ${local.layout.leftWidth() - 2}px`}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
|
||||
<div class="px-2 text-sm text-text-muted">No changes yet</div>
|
||||
<div class="px-2 text-xs text-text-muted">No changes yet</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -266,9 +254,9 @@ export default function Page() {
|
||||
onMouseDown={(e) => handleLeftDragStart(e)}
|
||||
>
|
||||
<div
|
||||
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
|
||||
classList={{
|
||||
"bg-border-active": isDragging() === "left",
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "left",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -281,8 +269,8 @@ export default function Page() {
|
||||
<Show when={local.session.active()} fallback={<SessionList />}>
|
||||
{(activeSession) => (
|
||||
<div class="relative">
|
||||
<div class="sticky top-0 bg-background z-50 p-2 h-9 border-b border-border-subtle/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
|
||||
<div class="h-full flex items-center gap-2">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
@@ -300,16 +288,6 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div
|
||||
class="pointer-events-none fixed top-0 right-px h-4
|
||||
bg-gradient-to-t from-transparent to-background"
|
||||
style={`width: ${local.layout.rightWidth() - 2}px`}
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none fixed bottom-0 right-px h-4
|
||||
bg-gradient-to-b from-transparent to-background"
|
||||
style={`width: ${local.layout.rightWidth() - 2}px`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -318,14 +296,22 @@ export default function Page() {
|
||||
onMouseDown={(e) => handleRightDragStart(e)}
|
||||
>
|
||||
<div
|
||||
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
|
||||
classList={{ "bg-border-active": isDragging() === "right" }}
|
||||
classList={{
|
||||
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
|
||||
"bg-border-active!": store.dragging === "right",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="relative"
|
||||
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
|
||||
>
|
||||
<Logo
|
||||
size={64}
|
||||
variant="ornate"
|
||||
class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
@@ -435,12 +421,12 @@ export default function Page() {
|
||||
</For>
|
||||
</Tabs>
|
||||
<DragOverlay>
|
||||
{activeItem() &&
|
||||
{store.activeItem &&
|
||||
(() => {
|
||||
const draggedFile = local.file.node(activeItem()!)
|
||||
const draggedFile = local.file.node(store.activeItem!)
|
||||
return (
|
||||
<div
|
||||
class="relative px-3 h-9 flex items-center
|
||||
class="relative px-3 h-8 flex items-center
|
||||
text-sm font-medium text-text whitespace-nowrap
|
||||
shrink-0 bg-background-panel
|
||||
border-x border-border-subtle/40 border-b border-b-transparent"
|
||||
@@ -453,11 +439,14 @@ export default function Page() {
|
||||
</DragDropProvider>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
class="peer/editor absolute bottom-8 z-50 flex items-center justify-center"
|
||||
style={`left: ${local.layout.leftWidth() + 40}px; right: ${local.layout.rightPane() ? local.layout.rightWidth() + 40 : 40}px`}
|
||||
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
|
||||
classList={{
|
||||
"bottom-8": !!local.file.active(),
|
||||
"bottom-2/5": local.file.active() === undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl min-w-1/2 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
|
||||
flex flex-col gap-1
|
||||
bg-gradient-to-b from-background-panel/90 to-background/90
|
||||
ring-1 ring-border-active/50 border border-transparent
|
||||
@@ -479,16 +468,36 @@ export default function Page() {
|
||||
<input
|
||||
ref={(el) => (inputRef = el)}
|
||||
type="text"
|
||||
value={inputValue()}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
value={store.prompt}
|
||||
onInput={(e) => setStore("prompt", e.currentTarget.value)}
|
||||
placeholder="It all starts with a prompt..."
|
||||
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
|
||||
/>
|
||||
<div class="px-1 flex justify-between items-center text-xs text-text-muted">
|
||||
<span>
|
||||
<span class="text-primary uppercase">{local.agent.current()?.name ?? "unknown"}</span> /{" "}
|
||||
{local.model.parsed().provider} / {local.model.parsed().model}
|
||||
</span>
|
||||
<div class="flex justify-between items-center text-xs text-text-muted">
|
||||
<div class="flex gap-2 items-center">
|
||||
<Select
|
||||
options={local.agent.list().map((a) => a.name)}
|
||||
current={local.agent.current().name}
|
||||
onSelect={local.agent.set}
|
||||
size="sm"
|
||||
class="uppercase"
|
||||
/>
|
||||
<Select
|
||||
options={local.model.list()}
|
||||
current={local.model.current()}
|
||||
onSelect={local.model.set}
|
||||
label={(x) => x.modelID}
|
||||
value={(x) => `${x.providerID}.${x.modelID}`}
|
||||
filter={{
|
||||
keys: ["providerID", "modelID"],
|
||||
placeholder: "Filter models",
|
||||
}}
|
||||
groupBy={(x) => x.providerID}
|
||||
size="sm"
|
||||
class="uppercase"
|
||||
/>
|
||||
<span class="text-text-muted/70">{local.model.parsed().provider}</span>
|
||||
</div>
|
||||
<div class="flex gap-1 items-center">
|
||||
<IconButton class="text-text-muted" size="xs" variant="ghost">
|
||||
<Icon name="photo" size={16} />
|
||||
@@ -548,7 +557,7 @@ const SortableTab = (props: {
|
||||
<TabVisual file={props.file} />
|
||||
</Tabs.Trigger>
|
||||
<IconButton
|
||||
class="absolute right-1 top-2 opacity-0 text-text-muted/60
|
||||
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60
|
||||
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
|
||||
peer-data-[selected]/tab:hover:bg-border-subtle
|
||||
hover:opacity-100 peer-hover/tab:opacity-100"
|
||||
|
||||
@@ -18,7 +18,7 @@ function TabsList(props: TabsListProps) {
|
||||
classList={{
|
||||
"relative flex items-center bg-background overflow-x-auto no-scrollbar": true,
|
||||
"divide-x divide-border-subtle/40": true,
|
||||
"after:content-[''] after:block after:grow after:h-9": true,
|
||||
"after:content-[''] after:block after:grow after:h-8": true,
|
||||
"after:border-l empty:after:border-l-0! after:border-b after:border-border-subtle/40": true,
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
@@ -32,7 +32,7 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
|
||||
return (
|
||||
<KobalteTabs.Trigger
|
||||
classList={{
|
||||
"relative px-3 h-9 flex items-center": true,
|
||||
"relative px-3 h-8 flex items-center": true,
|
||||
"text-sm font-medium text-text-muted/60 cursor-pointer": true,
|
||||
"whitespace-nowrap shrink-0 border-b border-border-subtle/40": true,
|
||||
"disabled:pointer-events-none disabled:opacity-50": true,
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
{
|
||||
"name": "@opencode/cloud-app",
|
||||
"name": "@opencode/console-app",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vinxi dev --host 0.0.0.0",
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.9.10"
|
||||
"version": "0.10.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "^1.1.0",
|
||||
"solid-js": "catalog:",
|
||||
"vinxi": "^0.5.7",
|
||||
"@opencode/cloud-core": "workspace:*"
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="600" height="600" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
|
||||
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 377 B |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 212 B After Width: | Height: | Size: 212 B |
|
Before Width: | Height: | Size: 443 B After Width: | Height: | Size: 443 B |
|
Before Width: | Height: | Size: 902 KiB After Width: | Height: | Size: 902 KiB |
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 998 KiB After Width: | Height: | Size: 998 KiB |
|
Before Width: | Height: | Size: 592 KiB After Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 981 B After Width: | Height: | Size: 981 B |
@@ -1,6 +1,6 @@
|
||||
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { IconCreditCard } from "~/component/icon"
|
||||
import styles from "./billing-section.module.css"
|
||||
@@ -174,11 +174,20 @@ export function BillingSection() {
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="usage">
|
||||
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
|
||||
<p>
|
||||
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
|
||||
your account. You can continue using the API with your remaining balance.
|
||||
</p>
|
||||
<Show when={!balanceInfo()?.reload}>
|
||||
<Show
|
||||
when={!(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}
|
||||
fallback={
|
||||
<p>
|
||||
We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
|
||||
your account. You can continue using the API with your remaining balance.
|
||||
</p>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
|
||||
<p>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
|
||||
import { createEffect, createSignal, For, Show } from "solid-js"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode/cloud-core/key.js"
|
||||
import { Key } from "@opencode/console-core/key.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { formatDateUTC, formatDateForTable } from "./common"
|
||||
@@ -2,7 +2,7 @@ import { json, query, action, useParams, createAsync, useSubmission } from "@sol
|
||||
import { createEffect, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import styles from "./monthly-limit-section.module.css"
|
||||
|
||||
const getBillingInfo = query(async (workspaceID: string) => {
|
||||
@@ -1,8 +1,8 @@
|
||||
import { query, useParams, createAsync } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Show } from "solid-js"
|
||||
import { IconCopy, IconCheck } from "~/component/icon"
|
||||
import { Key } from "@opencode/cloud-core/key.js"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Key } from "@opencode/console-core/key.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import styles from "./new-user-section.module.css"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
|
||||
import { For } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
@@ -95,7 +95,6 @@ export function PaymentSection() {
|
||||
}
|
||||
}}
|
||||
data-slot="receipt-button"
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
view
|
||||
</button>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import { query, useParams, createAsync } from "@solidjs/router"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { formatDateUTC, formatDateForTable } from "./common"
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import { and, Database, eq, inArray } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode/cloud-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
|
||||
import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
|
||||
import { redirect } from "@solidjs/router"
|
||||
import { AccountTable } from "@opencode/cloud-core/schema/account.sql.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { AccountTable } from "@opencode/console-core/schema/account.sql.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
|
||||
import { createClient } from "@openauthjs/openauth/client"
|
||||
import { useAuthSession } from "./auth.session"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { getActor } from "./auth"
|
||||
|
||||
export async function withActor<T>(fn: () => T, workspace?: string) {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Account } from "@opencode/cloud-core/account.js"
|
||||
import { Account } from "@opencode/console-core/account.js"
|
||||
import { redirect } from "@solidjs/router"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { json } from "@solidjs/router"
|
||||
import { Database } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
|
||||
import { Database } from "@opencode/console-core/drizzle/index.js"
|
||||
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
|
||||
|
||||
export async function GET(evt: APIEvent) {
|
||||
return json({
|
||||
@@ -8,7 +8,7 @@ import { IconCopy, IconCheck } from "../component/icon"
|
||||
import { createAsync, query } from "@solidjs/router"
|
||||
import { getActor } from "~/context/auth"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { Account } from "@opencode/cloud-core/account.js"
|
||||
import { Account } from "@opencode/console-core/account.js"
|
||||
|
||||
function CopyStatus() {
|
||||
return (
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { Billing } from "@opencode/console-core/billing.js"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode/cloud-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { Resource } from "@opencode/cloud-resource"
|
||||
import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
|
||||
import { Identifier } from "@opencode/console-core/identifier.js"
|
||||
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
|
||||
export async function POST(input: APIEvent) {
|
||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
useParams,
|
||||
A,
|
||||
} from "@solidjs/router"
|
||||
import { User } from "@opencode/cloud-core/user.js"
|
||||
import { Actor } from "@opencode/cloud-core/actor.js"
|
||||
import { User } from "@opencode/console-core/user.js"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
|
||||
const getUserInfo = query(async (workspaceID: string) => {
|
||||
@@ -1,8 +1,4 @@
|
||||
import "./[id].css"
|
||||
import { Billing } from "@opencode/cloud-core/billing.js"
|
||||
import { query, useParams, createAsync } from "@solidjs/router"
|
||||
import { Show } from "solid-js"
|
||||
import { withActor } from "~/context/auth.withActor"
|
||||
import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
|
||||
import { NewUserSection } from "~/component/workspace/new-user-section"
|
||||
import { BillingSection } from "~/component/workspace/billing-section"
|
||||
@@ -10,17 +6,7 @@ import { PaymentSection } from "~/component/workspace/payment-section"
|
||||
import { UsageSection } from "~/component/workspace/usage-section"
|
||||
import { KeySection } from "~/component/workspace/key-section"
|
||||
|
||||
const getBillingInfo = query(async (workspaceID: string) => {
|
||||
"use server"
|
||||
return withActor(async () => {
|
||||
return await Billing.get()
|
||||
}, workspaceID)
|
||||
}, "billing.get")
|
||||
|
||||
export default function () {
|
||||
const params = useParams()
|
||||
const balanceInfo = createAsync(() => getBillingInfo(params.id))
|
||||
|
||||
return (
|
||||
<div data-page="workspace-[id]">
|
||||
<section data-component="title-section">
|
||||
@@ -38,13 +24,18 @@ export default function () {
|
||||
<NewUserSection />
|
||||
<KeySection />
|
||||
<BillingSection />
|
||||
<Show when={true}>
|
||||
{/*<Show when={balanceInfo()?.reload}>*/}
|
||||
<MonthlyLimitSection />
|
||||
</Show>
|
||||
<MonthlyLimitSection />
|
||||
<UsageSection />
|
||||
<PaymentSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function isBeta(workspaceID: string) {
|
||||
return [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
|
||||
"wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
|
||||
"wrk_01K4PJRKJ2WPQZN3FFYRV4673F", // frank
|
||||
].includes(workspaceID)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { z } from "zod"
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import path from "node:path"
|
||||
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
|
||||
import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
|
||||
import { BillingTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
|
||||
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
|
||||
import { Identifier } from "@opencode/console-core/identifier.js"
|
||||
import { Resource } from "@opencode/console-resource"
|
||||
import { Billing } from "../../../../core/src/billing"
|
||||
import { Actor } from "@opencode/console-core/actor.js"
|
||||
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
|
||||
|
||||
export async function handler(
|
||||
input: APIEvent,
|
||||
opts: {
|
||||
modifyBody?: (body: any) => any
|
||||
setAuthHeader: (headers: Headers, apiKey: string) => void
|
||||
parseApiKey: (headers: Headers) => string | undefined
|
||||
onStreamPart: (chunk: string) => void
|
||||
getStreamUsage: () => any
|
||||
normalizeUsage: (body: any) => {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
reasoningTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWrite5mTokens?: number
|
||||
cacheWrite1hTokens?: number
|
||||
}
|
||||
},
|
||||
) {
|
||||
class AuthError extends Error {}
|
||||
class CreditsError extends Error {}
|
||||
class MonthlyLimitError extends Error {}
|
||||
class ModelError extends Error {}
|
||||
|
||||
const ModelCostSchema = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cacheRead: z.number().optional(),
|
||||
cacheWrite5m: z.number().optional(),
|
||||
cacheWrite1h: z.number().optional(),
|
||||
})
|
||||
|
||||
const ModelSchema = z.object({
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
api: z.string(),
|
||||
apiKey: z.string(),
|
||||
model: z.string(),
|
||||
weight: z.number().optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
type Model = z.infer<typeof ModelSchema>
|
||||
|
||||
const FREE_WORKSPACES = [
|
||||
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
||||
]
|
||||
|
||||
const logger = {
|
||||
metric: (values: Record<string, any>) => {
|
||||
console.log(`_metric:${JSON.stringify(values)}`)
|
||||
},
|
||||
log: console.log,
|
||||
debug: (message: string) => {
|
||||
if (Resource.App.stage === "production") return
|
||||
console.debug(message)
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input.request.url)
|
||||
const body = await input.request.json()
|
||||
logger.debug(JSON.stringify(body))
|
||||
logger.metric({
|
||||
is_tream: !!body.stream,
|
||||
session: input.request.headers.get("x-opencode-session"),
|
||||
request: input.request.headers.get("x-opencode-request"),
|
||||
})
|
||||
const modelInfo = validateModel(body.model)
|
||||
const providerInfo = selectProvider(modelInfo)
|
||||
const authInfo = await authenticate(modelInfo)
|
||||
validateBilling(modelInfo, authInfo)
|
||||
logger.metric({ provider: providerInfo.id })
|
||||
|
||||
// Request to model provider
|
||||
const startTimestamp = Date.now()
|
||||
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen/, "") + url.search), {
|
||||
method: "POST",
|
||||
headers: (() => {
|
||||
const headers = input.request.headers
|
||||
headers.delete("host")
|
||||
headers.delete("content-length")
|
||||
opts.setAuthHeader(headers, providerInfo.apiKey)
|
||||
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
|
||||
headers.set(k, headers.get(v)!)
|
||||
})
|
||||
return headers
|
||||
})(),
|
||||
body: JSON.stringify({
|
||||
...(opts.modifyBody?.(body) ?? body),
|
||||
model: providerInfo.model,
|
||||
}),
|
||||
})
|
||||
|
||||
// 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 json = await res.json()
|
||||
const body = JSON.stringify(json)
|
||||
logger.metric({ response_length: body.length })
|
||||
logger.debug(body)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
|
||||
await reload(authInfo)
|
||||
return new Response(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 = ""
|
||||
let responseLength = 0
|
||||
|
||||
function pump(): Promise<void> {
|
||||
return (
|
||||
reader?.read().then(async ({ done, value }) => {
|
||||
if (done) {
|
||||
logger.metric({ response_length: responseLength })
|
||||
const usage = opts.getStreamUsage()
|
||||
if (usage) {
|
||||
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
|
||||
await reload(authInfo)
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (responseLength === 0) {
|
||||
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
|
||||
}
|
||||
responseLength += value.length
|
||||
console.log(decoder.decode(value, { stream: true }))
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
const parts = buffer.split("\n\n")
|
||||
buffer = parts.pop() ?? ""
|
||||
|
||||
for (const part of parts) {
|
||||
logger.debug(part)
|
||||
opts.onStreamPart(part.trim())
|
||||
}
|
||||
|
||||
c.enqueue(value)
|
||||
|
||||
return pump()
|
||||
}) || Promise.resolve()
|
||||
)
|
||||
}
|
||||
|
||||
return pump()
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: resHeaders,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
})
|
||||
|
||||
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
|
||||
if (
|
||||
error instanceof AuthError ||
|
||||
error instanceof CreditsError ||
|
||||
error instanceof MonthlyLimitError ||
|
||||
error instanceof ModelError
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: { type: error.constructor.name, message: error.message },
|
||||
}),
|
||||
{ status: 401 },
|
||||
)
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
},
|
||||
}),
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
function validateModel(reqModel: string) {
|
||||
const json = JSON.parse(Resource.ZEN_MODELS.value)
|
||||
|
||||
const allModels = z.record(z.string(), ModelSchema).parse(json)
|
||||
|
||||
if (!(reqModel in allModels)) {
|
||||
throw new ModelError(`Model ${reqModel} not supported`)
|
||||
}
|
||||
const modelId = reqModel as keyof typeof allModels
|
||||
const modelData = allModels[modelId]
|
||||
|
||||
logger.metric({ model: modelId })
|
||||
|
||||
return { id: modelId, ...modelData }
|
||||
}
|
||||
|
||||
function selectProvider(model: Model) {
|
||||
const providers = model.providers
|
||||
.filter((provider) => !provider.disabled)
|
||||
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
|
||||
return providers[Math.floor(Math.random() * providers.length)]
|
||||
}
|
||||
|
||||
async function authenticate(model: Model) {
|
||||
const apiKey = opts.parseApiKey(input.request.headers)
|
||||
if (!apiKey) {
|
||||
if (model.allowAnonymous) return
|
||||
throw new AuthError("Missing API key.")
|
||||
}
|
||||
|
||||
const data = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
apiKey: KeyTable.id,
|
||||
workspaceID: KeyTable.workspaceID,
|
||||
balance: BillingTable.balance,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
monthlyLimit: BillingTable.monthlyLimit,
|
||||
monthlyUsage: BillingTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
|
||||
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
|
||||
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!data) throw new AuthError("Invalid API key.")
|
||||
logger.metric({
|
||||
api_key: data.apiKey,
|
||||
workspace: data.workspaceID,
|
||||
})
|
||||
|
||||
const isFree = FREE_WORKSPACES.includes(data.workspaceID)
|
||||
|
||||
return {
|
||||
apiKeyId: data.apiKey,
|
||||
workspaceID: data.workspaceID,
|
||||
billing: {
|
||||
paymentMethodID: data.paymentMethodID,
|
||||
balance: data.balance,
|
||||
monthlyLimit: data.monthlyLimit,
|
||||
monthlyUsage: data.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: data.timeMonthlyUsageUpdated,
|
||||
},
|
||||
isFree,
|
||||
}
|
||||
}
|
||||
|
||||
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
if (!authInfo || authInfo.isFree) return
|
||||
if (model.allowAnonymous) return
|
||||
|
||||
const billing = authInfo.billing
|
||||
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
|
||||
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
|
||||
if (
|
||||
billing.monthlyLimit &&
|
||||
billing.monthlyUsage &&
|
||||
billing.timeMonthlyUsageUpdated &&
|
||||
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
|
||||
) {
|
||||
const now = new Date()
|
||||
const currentYear = now.getUTCFullYear()
|
||||
const currentMonth = now.getUTCMonth()
|
||||
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
|
||||
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
|
||||
if (currentYear === dateYear && currentMonth === dateMonth)
|
||||
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function trackUsage(
|
||||
authInfo: Awaited<ReturnType<typeof authenticate>>,
|
||||
modelInfo: ReturnType<typeof validateModel>,
|
||||
providerId: string,
|
||||
usage: any,
|
||||
) {
|
||||
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
|
||||
opts.normalizeUsage(usage)
|
||||
|
||||
const modelCost =
|
||||
modelInfo.cost200K &&
|
||||
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens > 200_000
|
||||
? modelInfo.cost200K
|
||||
: modelInfo.cost
|
||||
|
||||
const inputCost = modelCost.input * inputTokens * 100
|
||||
const outputCost = modelCost.output * outputTokens * 100
|
||||
const reasoningCost = (() => {
|
||||
if (!reasoningTokens) return undefined
|
||||
return modelCost.output * reasoningTokens * 100
|
||||
})()
|
||||
const cacheReadCost = (() => {
|
||||
if (!cacheReadTokens) return undefined
|
||||
if (!modelCost.cacheRead) return undefined
|
||||
return modelCost.cacheRead * cacheReadTokens * 100
|
||||
})()
|
||||
const cacheWrite5mCost = (() => {
|
||||
if (!cacheWrite5mTokens) return undefined
|
||||
if (!modelCost.cacheWrite5m) return undefined
|
||||
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
|
||||
})()
|
||||
const cacheWrite1hCost = (() => {
|
||||
if (!cacheWrite1hTokens) return undefined
|
||||
if (!modelCost.cacheWrite1h) return undefined
|
||||
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
|
||||
})()
|
||||
const totalCostInCent =
|
||||
inputCost +
|
||||
outputCost +
|
||||
(reasoningCost ?? 0) +
|
||||
(cacheReadCost ?? 0) +
|
||||
(cacheWrite5mCost ?? 0) +
|
||||
(cacheWrite1hCost ?? 0)
|
||||
|
||||
logger.metric({
|
||||
"tokens.input": inputTokens,
|
||||
"tokens.output": outputTokens,
|
||||
"tokens.reasoning": reasoningTokens,
|
||||
"tokens.cache_read": cacheReadTokens,
|
||||
"tokens.cache_write_5m": cacheWrite5mTokens,
|
||||
"tokens.cache_write_1h": cacheWrite1hTokens,
|
||||
"cost.input": Math.round(inputCost),
|
||||
"cost.output": Math.round(outputCost),
|
||||
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
|
||||
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
|
||||
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
|
||||
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
|
||||
"cost.total": Math.round(totalCostInCent),
|
||||
})
|
||||
|
||||
if (!authInfo) return
|
||||
|
||||
const cost = authInfo.isFree ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: authInfo.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: modelInfo.id,
|
||||
provider: providerId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
})
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(eq(KeyTable.id, authInfo.apiKeyId)),
|
||||
)
|
||||
}
|
||||
|
||||
async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
|
||||
if (!authInfo) return
|
||||
|
||||
const lock = await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
timeReloadLockedTill: sql`now() + interval 1 minute`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (lock.rowsAffected === 0) return
|
||||
|
||||
await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => {
|
||||
await Billing.reload()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ type Usage = {
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
total_tokens?: number
|
||||
// used by moonshot
|
||||
cached_tokens?: number
|
||||
// used by xai
|
||||
prompt_tokens_details?: {
|
||||
text_tokens?: number
|
||||
audio_tokens?: number
|
||||
@@ -48,7 +51,7 @@ export function POST(input: APIEvent) {
|
||||
inputTokens: usage.prompt_tokens ?? 0,
|
||||
outputTokens: usage.completion_tokens ?? 0,
|
||||
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
|
||||
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
|
||||
cacheReadTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined,
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
/* eslint-disable */
|
||||
/* deno-fmt-ignore-file */
|
||||
|
||||
/// <reference path="../../sst-env.d.ts" />
|
||||
/// <reference path="../../../sst-env.d.ts" />
|
||||
|
||||
import "sst"
|
||||
export {}
|
||||