gateway.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903
  1. import { z } from "zod"
  2. import { Hono, MiddlewareHandler } from "hono"
  3. import { cors } from "hono/cors"
  4. import { HTTPException } from "hono/http-exception"
  5. import { zValidator } from "@hono/zod-validator"
  6. import { Resource } from "sst"
  7. import { type ProviderMetadata, type LanguageModelUsage, generateText, streamText, Tool } from "ai"
  8. import { createAnthropic } from "@ai-sdk/anthropic"
  9. import { createOpenAI } from "@ai-sdk/openai"
  10. import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
  11. import type { LanguageModelV2Prompt } from "@ai-sdk/provider"
  12. import { type ChatCompletionCreateParamsBase } from "openai/resources/chat/completions"
  13. import { Actor } from "@opencode/cloud-core/actor.js"
  14. import { and, Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
  15. import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
  16. import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
  17. import { createClient } from "@openauthjs/openauth/client"
  18. import { Log } from "@opencode/cloud-core/util/log.js"
  19. import { Billing } from "@opencode/cloud-core/billing.js"
  20. import { Workspace } from "@opencode/cloud-core/workspace.js"
  21. import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
  22. import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
  23. import { Identifier } from "../../core/src/identifier"
  24. type Env = {}
  25. let _client: ReturnType<typeof createClient>
  26. const client = () => {
  27. if (_client) return _client
  28. _client = createClient({
  29. clientID: "api",
  30. issuer: Resource.AUTH_API_URL.value,
  31. })
  32. return _client
  33. }
  34. const SUPPORTED_MODELS = {
  35. "anthropic/claude-sonnet-4": {
  36. input: 0.0000015,
  37. output: 0.000006,
  38. reasoning: 0.0000015,
  39. cacheRead: 0.0000001,
  40. cacheWrite: 0.0000001,
  41. model: () =>
  42. createAnthropic({
  43. apiKey: Resource.ANTHROPIC_API_KEY.value,
  44. })("claude-sonnet-4-20250514"),
  45. },
  46. "openai/gpt-4.1": {
  47. input: 0.0000015,
  48. output: 0.000006,
  49. reasoning: 0.0000015,
  50. cacheRead: 0.0000001,
  51. cacheWrite: 0.0000001,
  52. model: () =>
  53. createOpenAI({
  54. apiKey: Resource.OPENAI_API_KEY.value,
  55. })("gpt-4.1"),
  56. },
  57. "zhipuai/glm-4.5-flash": {
  58. input: 0,
  59. output: 0,
  60. reasoning: 0,
  61. cacheRead: 0,
  62. cacheWrite: 0,
  63. model: () =>
  64. createOpenAICompatible({
  65. name: "Zhipu AI",
  66. baseURL: "https://api.z.ai/api/paas/v4",
  67. apiKey: Resource.ZHIPU_API_KEY.value,
  68. })("glm-4.5-flash"),
  69. },
  70. }
  71. const log = Log.create({
  72. namespace: "api",
  73. })
  74. const GatewayAuth: MiddlewareHandler = async (c, next) => {
  75. const authHeader = c.req.header("authorization")
  76. if (!authHeader || !authHeader.startsWith("Bearer ")) {
  77. return c.json(
  78. {
  79. error: {
  80. message: "Missing API key.",
  81. type: "invalid_request_error",
  82. param: null,
  83. code: "unauthorized",
  84. },
  85. },
  86. 401,
  87. )
  88. }
  89. const apiKey = authHeader.split(" ")[1]
  90. // Check against KeyTable
  91. const keyRecord = await Database.use((tx) =>
  92. tx
  93. .select({
  94. id: KeyTable.id,
  95. workspaceID: KeyTable.workspaceID,
  96. })
  97. .from(KeyTable)
  98. .where(eq(KeyTable.key, apiKey))
  99. .then((rows) => rows[0]),
  100. )
  101. if (!keyRecord) {
  102. return c.json(
  103. {
  104. error: {
  105. message: "Invalid API key.",
  106. type: "invalid_request_error",
  107. param: null,
  108. code: "unauthorized",
  109. },
  110. },
  111. 401,
  112. )
  113. }
  114. c.set("keyRecord", keyRecord)
  115. await next()
  116. }
  117. const RestAuth: MiddlewareHandler = async (c, next) => {
  118. const authorization = c.req.header("authorization")
  119. if (!authorization) {
  120. return Actor.provide("public", {}, next)
  121. }
  122. const token = authorization.split(" ")[1]
  123. if (!token)
  124. throw new HTTPException(403, {
  125. message: "Bearer token is required.",
  126. })
  127. const verified = await client().verify(token)
  128. if (verified.err) {
  129. throw new HTTPException(403, {
  130. message: "Invalid token.",
  131. })
  132. }
  133. let subject = verified.subject as Actor.Info
  134. if (subject.type === "account") {
  135. const workspaceID = c.req.header("x-opencode-workspace")
  136. const email = subject.properties.email
  137. if (workspaceID) {
  138. const user = await Database.use((tx) =>
  139. tx
  140. .select({
  141. id: UserTable.id,
  142. workspaceID: UserTable.workspaceID,
  143. email: UserTable.email,
  144. })
  145. .from(UserTable)
  146. .where(and(eq(UserTable.email, email), eq(UserTable.workspaceID, workspaceID)))
  147. .then((rows) => rows[0]),
  148. )
  149. if (!user)
  150. throw new HTTPException(403, {
  151. message: "You do not have access to this workspace.",
  152. })
  153. subject = {
  154. type: "user",
  155. properties: {
  156. userID: user.id,
  157. workspaceID: workspaceID,
  158. email: user.email,
  159. },
  160. }
  161. }
  162. }
  163. await Actor.provide(subject.type, subject.properties, next)
  164. }
  165. const app = new Hono<{ Bindings: Env; Variables: { keyRecord?: { id: string; workspaceID: string } } }>()
  166. .get("/", (c) => c.text("Hello, world!"))
  167. .post("/v1/chat/completions", GatewayAuth, async (c) => {
  168. try {
  169. const body = await c.req.json<ChatCompletionCreateParamsBase>()
  170. const model = SUPPORTED_MODELS[body.model as keyof typeof SUPPORTED_MODELS]?.model()
  171. if (!model) throw new Error(`Unsupported model: ${body.model}`)
  172. const requestBody = transformOpenAIRequestToAiSDK()
  173. return body.stream ? await handleStream() : await handleGenerate()
  174. async function handleStream() {
  175. const result = streamText({
  176. model,
  177. ...requestBody,
  178. })
  179. const encoder = new TextEncoder()
  180. const stream = new ReadableStream({
  181. async start(controller) {
  182. const id = `chatcmpl-${Date.now()}`
  183. const created = Math.floor(Date.now() / 1000)
  184. try {
  185. for await (const chunk of result.fullStream) {
  186. console.log("!!! CHUNK !!! : " + chunk.type)
  187. switch (chunk.type) {
  188. case "text-delta": {
  189. const data = {
  190. id,
  191. object: "chat.completion.chunk",
  192. created,
  193. model: body.model,
  194. choices: [
  195. {
  196. index: 0,
  197. delta: {
  198. content: chunk.text,
  199. },
  200. finish_reason: null,
  201. },
  202. ],
  203. }
  204. controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  205. break
  206. }
  207. case "reasoning-delta": {
  208. const data = {
  209. id,
  210. object: "chat.completion.chunk",
  211. created,
  212. model: body.model,
  213. choices: [
  214. {
  215. index: 0,
  216. delta: {
  217. reasoning_content: chunk.text,
  218. },
  219. finish_reason: null,
  220. },
  221. ],
  222. }
  223. controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  224. break
  225. }
  226. case "tool-call": {
  227. const data = {
  228. id,
  229. object: "chat.completion.chunk",
  230. created,
  231. model: body.model,
  232. choices: [
  233. {
  234. index: 0,
  235. delta: {
  236. tool_calls: [
  237. {
  238. id: chunk.toolCallId,
  239. type: "function",
  240. function: {
  241. name: chunk.toolName,
  242. arguments: JSON.stringify(chunk.input),
  243. },
  244. },
  245. ],
  246. },
  247. finish_reason: null,
  248. },
  249. ],
  250. }
  251. controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  252. break
  253. }
  254. case "error": {
  255. const data = {
  256. id,
  257. object: "chat.completion.chunk",
  258. created,
  259. model: body.model,
  260. choices: [
  261. {
  262. index: 0,
  263. delta: {},
  264. finish_reason: "stop",
  265. },
  266. ],
  267. error: {
  268. message: typeof chunk.error === "string" ? chunk.error : JSON.stringify(chunk.error),
  269. type: "server_error",
  270. },
  271. }
  272. controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  273. controller.enqueue(encoder.encode("data: [DONE]\n\n"))
  274. controller.close()
  275. break
  276. }
  277. case "finish": {
  278. const data = {
  279. id,
  280. object: "chat.completion.chunk",
  281. created,
  282. model: body.model,
  283. choices: [
  284. {
  285. index: 0,
  286. delta: {},
  287. finish_reason:
  288. {
  289. stop: "stop",
  290. length: "length",
  291. "content-filter": "content_filter",
  292. "tool-calls": "tool_calls",
  293. error: "stop",
  294. other: "stop",
  295. unknown: "stop",
  296. }[chunk.finishReason] || "stop",
  297. },
  298. ],
  299. usage: {
  300. prompt_tokens: chunk.totalUsage.inputTokens,
  301. completion_tokens: chunk.totalUsage.outputTokens,
  302. total_tokens: chunk.totalUsage.totalTokens,
  303. completion_tokens_details: {
  304. reasoning_tokens: chunk.totalUsage.reasoningTokens,
  305. },
  306. prompt_tokens_details: {
  307. cached_tokens: chunk.totalUsage.cachedInputTokens,
  308. },
  309. },
  310. }
  311. controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
  312. controller.enqueue(encoder.encode("data: [DONE]\n\n"))
  313. controller.close()
  314. break
  315. }
  316. case "finish-step": {
  317. await trackUsage(body.model, chunk.usage, chunk.providerMetadata)
  318. }
  319. //case "stream-start":
  320. //case "response-metadata":
  321. case "start-step":
  322. case "text-start":
  323. case "text-end":
  324. case "reasoning-start":
  325. case "reasoning-end":
  326. case "tool-input-start":
  327. case "tool-input-delta":
  328. case "tool-input-end":
  329. case "raw":
  330. default:
  331. // Log unknown chunk types for debugging
  332. console.warn(`Unknown chunk type: ${(chunk as any).type}`)
  333. break
  334. }
  335. }
  336. } catch (error) {
  337. controller.error(error)
  338. }
  339. },
  340. })
  341. return new Response(stream, {
  342. headers: {
  343. "Content-Type": "text/plain; charset=utf-8",
  344. "Cache-Control": "no-cache",
  345. Connection: "keep-alive",
  346. },
  347. })
  348. }
  349. async function handleGenerate() {
  350. const response = await generateText({
  351. model,
  352. ...requestBody,
  353. })
  354. await trackUsage(body.model, response.usage, response.providerMetadata)
  355. return c.json({
  356. id: `chatcmpl-${Date.now()}`,
  357. object: "chat.completion" as const,
  358. created: Math.floor(Date.now() / 1000),
  359. model: body.model,
  360. choices: [
  361. {
  362. index: 0,
  363. message: {
  364. role: "assistant" as const,
  365. content: response.content?.find((c) => c.type === "text")?.text ?? "",
  366. reasoning_content: response.content?.find((c) => c.type === "reasoning")?.text,
  367. tool_calls: response.content
  368. ?.filter((c) => c.type === "tool-call")
  369. .map((toolCall) => ({
  370. id: toolCall.toolCallId,
  371. type: "function" as const,
  372. function: {
  373. name: toolCall.toolName,
  374. arguments: toolCall.input,
  375. },
  376. })),
  377. },
  378. finish_reason:
  379. (
  380. {
  381. stop: "stop",
  382. length: "length",
  383. "content-filter": "content_filter",
  384. "tool-calls": "tool_calls",
  385. error: "stop",
  386. other: "stop",
  387. unknown: "stop",
  388. } as const
  389. )[response.finishReason] || "stop",
  390. },
  391. ],
  392. usage: {
  393. prompt_tokens: response.usage?.inputTokens,
  394. completion_tokens: response.usage?.outputTokens,
  395. total_tokens: response.usage?.totalTokens,
  396. completion_tokens_details: {
  397. reasoning_tokens: response.usage?.reasoningTokens,
  398. },
  399. prompt_tokens_details: {
  400. cached_tokens: response.usage?.cachedInputTokens,
  401. },
  402. },
  403. })
  404. }
  405. function transformOpenAIRequestToAiSDK() {
  406. const prompt = transformMessages()
  407. const tools = transformTools()
  408. return {
  409. prompt,
  410. maxOutputTokens: body.max_tokens ?? body.max_completion_tokens ?? undefined,
  411. temperature: body.temperature ?? undefined,
  412. topP: body.top_p ?? undefined,
  413. frequencyPenalty: body.frequency_penalty ?? undefined,
  414. presencePenalty: body.presence_penalty ?? undefined,
  415. providerOptions: body.reasoning_effort
  416. ? {
  417. anthropic: {
  418. reasoningEffort: body.reasoning_effort,
  419. },
  420. }
  421. : undefined,
  422. stopSequences: (typeof body.stop === "string" ? [body.stop] : body.stop) ?? undefined,
  423. responseFormat: (() => {
  424. if (!body.response_format) return { type: "text" }
  425. if (body.response_format.type === "json_schema")
  426. return {
  427. type: "json",
  428. schema: body.response_format.json_schema.schema,
  429. name: body.response_format.json_schema.name,
  430. description: body.response_format.json_schema.description,
  431. }
  432. if (body.response_format.type === "json_object") return { type: "json" }
  433. throw new Error("Unsupported response format")
  434. })(),
  435. seed: body.seed ?? undefined,
  436. //tools: tools.tools,
  437. //toolChoice: tools.toolChoice,
  438. }
  439. function transformTools() {
  440. const { tools, tool_choice } = body
  441. if (!tools || tools.length === 0) {
  442. return { tools: undefined, toolChoice: undefined }
  443. }
  444. const aiSdkTools = tools.reduce(
  445. (acc, tool) => {
  446. acc[tool.function.name] = {
  447. name: tool.function.name,
  448. description: tool.function.description,
  449. inputSchema: tool.function.parameters,
  450. }
  451. return acc
  452. },
  453. {} as Record<string, any>,
  454. )
  455. let aiSdkToolChoice
  456. if (tool_choice == null) {
  457. aiSdkToolChoice = undefined
  458. } else if (tool_choice === "auto") {
  459. aiSdkToolChoice = "auto" as const
  460. } else if (tool_choice === "none") {
  461. aiSdkToolChoice = "none" as const
  462. } else if (tool_choice === "required") {
  463. aiSdkToolChoice = "required" as const
  464. } else if (tool_choice.type === "function") {
  465. aiSdkToolChoice = {
  466. type: "tool" as const,
  467. toolName: tool_choice.function.name,
  468. }
  469. }
  470. return { tools: aiSdkTools, toolChoice: aiSdkToolChoice }
  471. }
  472. function transformMessages() {
  473. const { messages } = body
  474. const prompt: LanguageModelV2Prompt = []
  475. for (const message of messages) {
  476. switch (message.role) {
  477. case "system": {
  478. prompt.push({
  479. role: "system",
  480. content: message.content as string,
  481. })
  482. break
  483. }
  484. case "user": {
  485. if (typeof message.content === "string") {
  486. prompt.push({
  487. role: "user",
  488. content: [{ type: "text", text: message.content }],
  489. })
  490. } else {
  491. const content = message.content.map((part) => {
  492. switch (part.type) {
  493. case "text":
  494. return { type: "text" as const, text: part.text }
  495. case "image_url":
  496. return {
  497. type: "file" as const,
  498. mediaType: "image/jpeg" as const,
  499. data: part.image_url.url,
  500. }
  501. default:
  502. throw new Error(`Unsupported content part type: ${(part as any).type}`)
  503. }
  504. })
  505. prompt.push({
  506. role: "user",
  507. content,
  508. })
  509. }
  510. break
  511. }
  512. case "assistant": {
  513. const content: Array<
  514. | { type: "text"; text: string }
  515. | {
  516. type: "tool-call"
  517. toolCallId: string
  518. toolName: string
  519. input: any
  520. }
  521. > = []
  522. if (message.content) {
  523. content.push({
  524. type: "text",
  525. text: message.content as string,
  526. })
  527. }
  528. if (message.tool_calls) {
  529. for (const toolCall of message.tool_calls) {
  530. content.push({
  531. type: "tool-call",
  532. toolCallId: toolCall.id,
  533. toolName: toolCall.function.name,
  534. input: JSON.parse(toolCall.function.arguments),
  535. })
  536. }
  537. }
  538. prompt.push({
  539. role: "assistant",
  540. content,
  541. })
  542. break
  543. }
  544. case "tool": {
  545. prompt.push({
  546. role: "tool",
  547. content: [
  548. {
  549. type: "tool-result",
  550. toolName: "placeholder",
  551. toolCallId: message.tool_call_id,
  552. output: {
  553. type: "text",
  554. value: message.content as string,
  555. },
  556. },
  557. ],
  558. })
  559. break
  560. }
  561. default: {
  562. throw new Error(`Unsupported message role: ${message.role}`)
  563. }
  564. }
  565. }
  566. return prompt
  567. }
  568. }
  569. async function trackUsage(model: string, usage: LanguageModelUsage, providerMetadata?: ProviderMetadata) {
  570. const keyRecord = c.get("keyRecord")
  571. if (!keyRecord) return
  572. const modelData = SUPPORTED_MODELS[model as keyof typeof SUPPORTED_MODELS]
  573. if (!modelData) throw new Error(`Unsupported model: ${model}`)
  574. const inputTokens = usage.inputTokens ?? 0
  575. const outputTokens = usage.outputTokens ?? 0
  576. const reasoningTokens = usage.reasoningTokens ?? 0
  577. const cacheReadTokens = usage.cachedInputTokens ?? 0
  578. const cacheWriteTokens =
  579. providerMetadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
  580. // @ts-expect-error
  581. providerMetadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
  582. 0
  583. const inputCost = modelData.input * inputTokens
  584. const outputCost = modelData.output * outputTokens
  585. const reasoningCost = modelData.reasoning * reasoningTokens
  586. const cacheReadCost = modelData.cacheRead * cacheReadTokens
  587. const cacheWriteCost = modelData.cacheWrite * cacheWriteTokens
  588. const costInCents = (inputCost + outputCost + reasoningCost + cacheReadCost + cacheWriteCost) * 100
  589. await Actor.provide("system", { workspaceID: keyRecord.workspaceID }, async () => {
  590. await Billing.consume({
  591. model,
  592. inputTokens,
  593. outputTokens,
  594. reasoningTokens,
  595. cacheReadTokens,
  596. cacheWriteTokens,
  597. costInCents,
  598. })
  599. })
  600. await Database.use((tx) =>
  601. tx
  602. .update(KeyTable)
  603. .set({ timeUsed: sql`now()` })
  604. .where(eq(KeyTable.id, keyRecord.id)),
  605. )
  606. }
  607. } catch (error: any) {
  608. return c.json({ error: { message: error.message } }, 500)
  609. }
  610. })
  611. .use("/*", cors())
  612. .use(RestAuth)
  613. .get("/rest/account", async (c) => {
  614. const account = Actor.assert("account")
  615. let workspaces = await Workspace.list()
  616. if (workspaces.length === 0) {
  617. await Workspace.create()
  618. workspaces = await Workspace.list()
  619. }
  620. return c.json({
  621. id: account.properties.accountID,
  622. email: account.properties.email,
  623. workspaces,
  624. })
  625. })
  626. .get("/billing/info", async (c) => {
  627. const billing = await Billing.get()
  628. const payments = await Database.use((tx) =>
  629. tx
  630. .select()
  631. .from(PaymentTable)
  632. .where(eq(PaymentTable.workspaceID, Actor.workspace()))
  633. .orderBy(sql`${PaymentTable.timeCreated} DESC`)
  634. .limit(100),
  635. )
  636. const usage = await Database.use((tx) =>
  637. tx
  638. .select()
  639. .from(UsageTable)
  640. .where(eq(UsageTable.workspaceID, Actor.workspace()))
  641. .orderBy(sql`${UsageTable.timeCreated} DESC`)
  642. .limit(100),
  643. )
  644. return c.json({ billing, payments, usage })
  645. })
  646. .post(
  647. "/billing/checkout",
  648. zValidator(
  649. "json",
  650. z.custom<{
  651. success_url: string
  652. cancel_url: string
  653. }>(),
  654. ),
  655. async (c) => {
  656. const account = Actor.assert("user")
  657. const body = await c.req.json()
  658. const customer = await Billing.get()
  659. const session = await Billing.stripe().checkout.sessions.create({
  660. mode: "payment",
  661. line_items: [
  662. {
  663. price_data: {
  664. currency: "usd",
  665. product_data: {
  666. name: "opencode credits",
  667. },
  668. unit_amount: 2000, // $20 minimum
  669. },
  670. quantity: 1,
  671. },
  672. ],
  673. payment_intent_data: {
  674. setup_future_usage: "on_session",
  675. },
  676. ...(customer.customerID
  677. ? { customer: customer.customerID }
  678. : {
  679. customer_email: account.properties.email,
  680. customer_creation: "always",
  681. }),
  682. metadata: {
  683. workspaceID: Actor.workspace(),
  684. },
  685. currency: "usd",
  686. payment_method_types: ["card"],
  687. success_url: body.success_url,
  688. cancel_url: body.cancel_url,
  689. })
  690. return c.json({
  691. url: session.url,
  692. })
  693. },
  694. )
  695. .post("/billing/portal", async (c) => {
  696. const body = await c.req.json()
  697. const customer = await Billing.get()
  698. if (!customer?.customerID) {
  699. throw new Error("No stripe customer ID")
  700. }
  701. const session = await Billing.stripe().billingPortal.sessions.create({
  702. customer: customer.customerID,
  703. return_url: body.return_url,
  704. })
  705. return c.json({
  706. url: session.url,
  707. })
  708. })
  709. .post("/stripe/webhook", async (c) => {
  710. const body = await Billing.stripe().webhooks.constructEventAsync(
  711. await c.req.text(),
  712. c.req.header("stripe-signature")!,
  713. Resource.STRIPE_WEBHOOK_SECRET.value,
  714. )
  715. console.log(body.type, JSON.stringify(body, null, 2))
  716. if (body.type === "checkout.session.completed") {
  717. const workspaceID = body.data.object.metadata?.workspaceID
  718. const customerID = body.data.object.customer as string
  719. const paymentID = body.data.object.payment_intent as string
  720. const amount = body.data.object.amount_total
  721. if (!workspaceID) throw new Error("Workspace ID not found")
  722. if (!customerID) throw new Error("Customer ID not found")
  723. if (!amount) throw new Error("Amount not found")
  724. if (!paymentID) throw new Error("Payment ID not found")
  725. await Actor.provide("system", { workspaceID }, async () => {
  726. const customer = await Billing.get()
  727. if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
  728. // set customer metadata
  729. if (!customer?.customerID) {
  730. await Billing.stripe().customers.update(customerID, {
  731. metadata: {
  732. workspaceID,
  733. },
  734. })
  735. }
  736. // get payment method for the payment intent
  737. const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
  738. expand: ["payment_method"],
  739. })
  740. const paymentMethod = paymentIntent.payment_method
  741. if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
  742. await Database.transaction(async (tx) => {
  743. await tx
  744. .update(BillingTable)
  745. .set({
  746. balance: sql`${BillingTable.balance} + ${centsToMicroCents(amount)}`,
  747. customerID,
  748. paymentMethodID: paymentMethod.id,
  749. paymentMethodLast4: paymentMethod.card!.last4,
  750. })
  751. .where(eq(BillingTable.workspaceID, workspaceID))
  752. await tx.insert(PaymentTable).values({
  753. workspaceID,
  754. id: Identifier.create("payment"),
  755. amount: centsToMicroCents(amount),
  756. paymentID,
  757. customerID,
  758. })
  759. })
  760. })
  761. }
  762. console.log("finished handling")
  763. return c.json("ok", 200)
  764. })
  765. .get("/keys", async (c) => {
  766. const user = Actor.assert("user")
  767. const keys = await Database.use((tx) =>
  768. tx
  769. .select({
  770. id: KeyTable.id,
  771. name: KeyTable.name,
  772. key: KeyTable.key,
  773. userID: KeyTable.userID,
  774. timeCreated: KeyTable.timeCreated,
  775. timeUsed: KeyTable.timeUsed,
  776. })
  777. .from(KeyTable)
  778. .where(eq(KeyTable.workspaceID, user.properties.workspaceID))
  779. .orderBy(sql`${KeyTable.timeCreated} DESC`),
  780. )
  781. return c.json({ keys })
  782. })
  783. .post("/keys", zValidator("json", z.object({ name: z.string().min(1).max(255) })), async (c) => {
  784. const user = Actor.assert("user")
  785. const { name } = c.req.valid("json")
  786. // Generate secret key: sk- + 64 random characters (upper, lower, numbers)
  787. const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
  788. let randomPart = ""
  789. for (let i = 0; i < 64; i++) {
  790. randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
  791. }
  792. const secretKey = `sk-${randomPart}`
  793. const keyRecord = await Database.use((tx) =>
  794. tx
  795. .insert(KeyTable)
  796. .values({
  797. id: Identifier.create("key"),
  798. workspaceID: user.properties.workspaceID,
  799. userID: user.properties.userID,
  800. name,
  801. key: secretKey,
  802. timeUsed: null,
  803. })
  804. .returning(),
  805. )
  806. return c.json({
  807. key: secretKey,
  808. id: keyRecord[0].id,
  809. name: keyRecord[0].name,
  810. created: keyRecord[0].timeCreated,
  811. })
  812. })
  813. .delete("/keys/:id", async (c) => {
  814. const user = Actor.assert("user")
  815. const keyId = c.req.param("id")
  816. const result = await Database.use((tx) =>
  817. tx
  818. .delete(KeyTable)
  819. .where(and(eq(KeyTable.id, keyId), eq(KeyTable.workspaceID, user.properties.workspaceID)))
  820. .returning({ id: KeyTable.id }),
  821. )
  822. if (result.length === 0) {
  823. return c.json({ error: "Key not found" }, 404)
  824. }
  825. return c.json({ success: true, id: result[0].id })
  826. })
  827. .all("*", (c) => c.text("Not Found"))
  828. export type ApiType = typeof app
  829. export default app