chore: initialize qiming workspace repository
This commit is contained in:
32
qimingcode/packages/console/core/src/account.ts
Normal file
32
qimingcode/packages/console/core/src/account.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { AccountTable } from "./schema/account.sql"
|
||||
|
||||
export namespace Account {
|
||||
export const create = fn(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
}),
|
||||
async (input) =>
|
||||
Database.use(async (tx) => {
|
||||
const id = input.id ?? Identifier.create("account")
|
||||
await tx.insert(AccountTable).values({
|
||||
id,
|
||||
})
|
||||
return id
|
||||
}),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), async (id) =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.where(eq(AccountTable.id, id))
|
||||
.then((rows) => rows[0]),
|
||||
),
|
||||
)
|
||||
}
|
||||
98
qimingcode/packages/console/core/src/actor.ts
Normal file
98
qimingcode/packages/console/core/src/actor.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Context } from "./context"
|
||||
import { UserRole } from "./schema/user.sql"
|
||||
import { Log } from "./util/log"
|
||||
|
||||
export namespace Actor {
|
||||
interface Account {
|
||||
type: "account"
|
||||
properties: {
|
||||
accountID: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
|
||||
interface Public {
|
||||
type: "public"
|
||||
properties: {}
|
||||
}
|
||||
|
||||
interface User {
|
||||
type: "user"
|
||||
properties: {
|
||||
userID: string
|
||||
workspaceID: string
|
||||
accountID: string
|
||||
role: (typeof UserRole)[number]
|
||||
}
|
||||
}
|
||||
|
||||
interface System {
|
||||
type: "system"
|
||||
properties: {
|
||||
workspaceID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type Info = Account | Public | User | System
|
||||
|
||||
const ctx = Context.create<Info>()
|
||||
export const use = ctx.use
|
||||
|
||||
const log = Log.create().tag("namespace", "actor")
|
||||
|
||||
export function provide<R, T extends Info["type"]>(
|
||||
type: T,
|
||||
properties: Extract<Info, { type: T }>["properties"],
|
||||
cb: () => R,
|
||||
) {
|
||||
return ctx.provide(
|
||||
{
|
||||
type,
|
||||
properties,
|
||||
} as any,
|
||||
() => {
|
||||
return Log.provide({ ...properties }, () => {
|
||||
log.info("provided")
|
||||
return cb()
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function assert<T extends Info["type"]>(type: T) {
|
||||
const actor = use()
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`)
|
||||
}
|
||||
return actor as Extract<Info, { type: T }>
|
||||
}
|
||||
|
||||
export const assertAdmin = () => {
|
||||
if (userRole() === "admin") return
|
||||
throw new Error(`Action not allowed. Ask your workspace admin to perform this action.`)
|
||||
}
|
||||
|
||||
export function workspace() {
|
||||
const actor = use()
|
||||
if ("workspaceID" in actor.properties) {
|
||||
return actor.properties.workspaceID
|
||||
}
|
||||
throw new Error(`actor of type "${actor.type}" is not associated with a workspace`)
|
||||
}
|
||||
|
||||
export function account() {
|
||||
const actor = use()
|
||||
if ("accountID" in actor.properties) {
|
||||
return actor.properties.accountID
|
||||
}
|
||||
throw new Error(`actor of type "${actor.type}" is not associated with an account`)
|
||||
}
|
||||
|
||||
export function userID() {
|
||||
return Actor.assert("user").properties.userID
|
||||
}
|
||||
|
||||
export function userRole() {
|
||||
return Actor.assert("user").properties.role
|
||||
}
|
||||
}
|
||||
65
qimingcode/packages/console/core/src/aws.ts
Normal file
65
qimingcode/packages/console/core/src/aws.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { AwsClient } from "aws4fetch"
|
||||
import { fn } from "./util/fn"
|
||||
|
||||
export namespace AWS {
|
||||
let client: AwsClient
|
||||
|
||||
const createClient = () => {
|
||||
if (!client) {
|
||||
client = new AwsClient({
|
||||
accessKeyId: Resource.AWS_SES_ACCESS_KEY_ID.value,
|
||||
secretAccessKey: Resource.AWS_SES_SECRET_ACCESS_KEY.value,
|
||||
region: "us-east-1",
|
||||
})
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
export const sendEmail = fn(
|
||||
z.object({
|
||||
to: z.string(),
|
||||
subject: z.string(),
|
||||
body: z.string(),
|
||||
replyTo: z.string().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Amz-Target": "SES.SendEmail",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
FromEmailAddress: `OpenCode Zen <contact@anoma.ly>`,
|
||||
Destination: {
|
||||
ToAddresses: [input.to],
|
||||
},
|
||||
...(input.replyTo && { ReplyToAddresses: [input.replyTo] }),
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: {
|
||||
Charset: "UTF-8",
|
||||
Data: input.subject,
|
||||
},
|
||||
Body: {
|
||||
Text: {
|
||||
Charset: "UTF-8",
|
||||
Data: input.body,
|
||||
},
|
||||
Html: {
|
||||
Charset: "UTF-8",
|
||||
Data: input.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to send email: ${res.statusText}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
556
qimingcode/packages/console/core/src/billing.ts
Normal file
556
qimingcode/packages/console/core/src/billing.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { Stripe } from "stripe"
|
||||
import { and, Database, eq, isNull, sql } from "./drizzle"
|
||||
import {
|
||||
BillingTable,
|
||||
CouponTable,
|
||||
CouponType,
|
||||
LiteTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
UsageTable,
|
||||
} from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { BlackData } from "./black"
|
||||
import { LiteData } from "./lite"
|
||||
|
||||
export namespace Billing {
|
||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||
export const ITEM_FEE_NAME = "processing fee"
|
||||
export const RELOAD_AMOUNT = 20
|
||||
export const RELOAD_AMOUNT_MIN = 10
|
||||
export const RELOAD_TRIGGER = 5
|
||||
export const RELOAD_TRIGGER_MIN = 5
|
||||
export const stripe = () =>
|
||||
new Stripe(Resource.STRIPE_SECRET_KEY.value, {
|
||||
apiVersion: "2025-03-31.basil",
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
})
|
||||
|
||||
export const get = async () => {
|
||||
return Database.use(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
.then((r) => r[0]),
|
||||
)
|
||||
}
|
||||
|
||||
export const payments = async () => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(PaymentTable)
|
||||
.where(eq(PaymentTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${PaymentTable.timeCreated} DESC`)
|
||||
.limit(100),
|
||||
)
|
||||
}
|
||||
|
||||
export const usages = async (page = 0, pageSize = 50) => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UsageTable)
|
||||
.where(eq(UsageTable.workspaceID, Actor.workspace()))
|
||||
.orderBy(sql`${UsageTable.timeCreated} DESC`)
|
||||
.limit(pageSize)
|
||||
.offset(page * pageSize),
|
||||
)
|
||||
}
|
||||
|
||||
export const calculateFeeInCents = (x: number) => {
|
||||
// math: x = total - (total * 0.044 + 0.30)
|
||||
// math: x = total * (1-0.044) - 0.30
|
||||
// math: (x + 0.30) / 0.956 = total
|
||||
return Math.round(((x + 30) / 0.956) * 0.044 + 30)
|
||||
}
|
||||
|
||||
export const reload = async () => {
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
reloadAmount: BillingTable.reloadAmount,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
const customerID = billing.customerID
|
||||
const paymentMethodID = billing.paymentMethodID
|
||||
const amountInCents = (billing.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
try {
|
||||
const draft = await Billing.stripe().invoices.create({
|
||||
customer: customerID!,
|
||||
auto_advance: false,
|
||||
default_payment_method: paymentMethodID!,
|
||||
collection_method: "charge_automatically",
|
||||
currency: "usd",
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
amount: amountInCents.toString(),
|
||||
},
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: amountInCents,
|
||||
currency: "usd",
|
||||
customer: customerID!,
|
||||
invoice: draft.id!,
|
||||
description: ITEM_CREDIT_NAME,
|
||||
})
|
||||
await Billing.stripe().invoiceItems.create({
|
||||
amount: calculateFeeInCents(amountInCents),
|
||||
currency: "usd",
|
||||
customer: customerID!,
|
||||
invoice: draft.id!,
|
||||
description: ITEM_FEE_NAME,
|
||||
})
|
||||
await Billing.stripe().invoices.finalizeInvoice(draft.id!)
|
||||
await Billing.stripe().invoices.pay(draft.id!, {
|
||||
off_session: true,
|
||||
payment_method: paymentMethodID!,
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
reload: false,
|
||||
reloadError: e.message ?? "Payment failed.",
|
||||
timeReloadError: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const grantCredit = async (workspaceID: string, dollarAmount: number) => {
|
||||
const amountInMicroCents = centsToMicroCents(dollarAmount * 100)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} + ${amountInMicroCents}`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
await tx.insert(PaymentTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("payment"),
|
||||
amount: amountInMicroCents,
|
||||
enrichment: {
|
||||
type: "credit",
|
||||
},
|
||||
})
|
||||
})
|
||||
return amountInMicroCents
|
||||
}
|
||||
|
||||
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||
const coupon = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(CouponTable)
|
||||
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
if (!coupon) throw new Error("Invalid coupon code")
|
||||
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
|
||||
|
||||
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(CouponTable)
|
||||
.set({ timeRedeemed: sql`now()` })
|
||||
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
|
||||
)
|
||||
}
|
||||
|
||||
export const getCoupons = async (email: string) => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.select({ type: CouponTable.type, timeRedeemed: CouponTable.timeRedeemed })
|
||||
.from(CouponTable)
|
||||
.where(and(eq(CouponTable.email, email), isNull(CouponTable.timeRedeemed)))
|
||||
.then((rows) => rows.map((row) => row.type)),
|
||||
)
|
||||
}
|
||||
|
||||
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
monthlyLimit: input,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace())),
|
||||
)
|
||||
})
|
||||
|
||||
export const generateCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
amount: z.number().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { successUrl, cancelUrl, amount } = input
|
||||
|
||||
if (amount !== undefined && amount < Billing.RELOAD_AMOUNT_MIN) {
|
||||
throw new Error(`Amount must be at least $${Billing.RELOAD_AMOUNT_MIN}`)
|
||||
}
|
||||
|
||||
const email = await User.getAuthEmail(user.properties.userID)
|
||||
const customer = await Billing.get()
|
||||
const amountInCents = (amount ?? customer.reloadAmount ?? Billing.RELOAD_AMOUNT) * 100
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "payment",
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: { name: ITEM_CREDIT_NAME },
|
||||
unit_amount: amountInCents,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
{
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
product_data: { name: ITEM_FEE_NAME },
|
||||
unit_amount: calculateFeeInCents(amountInCents),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
...(customer.customerID
|
||||
? {
|
||||
customer: customer.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email!,
|
||||
customer_creation: "always",
|
||||
}),
|
||||
currency: "usd",
|
||||
invoice_creation: {
|
||||
enabled: true,
|
||||
},
|
||||
payment_method_options: {
|
||||
card: {
|
||||
setup_future_usage: "on_session",
|
||||
},
|
||||
},
|
||||
//payment_method_data: {
|
||||
// allow_redisplay: "always",
|
||||
//},
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
amount: amountInCents.toString(),
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
|
||||
export const generateLiteCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
method: z.enum(["alipay", "upi"]).optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { successUrl, cancelUrl, method } = input
|
||||
|
||||
const email = (await User.getAuthEmail(user.properties.userID))!
|
||||
const billing = await Billing.get()
|
||||
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
|
||||
const coupons = await Billing.getCoupons(email)
|
||||
const coupon = coupons.includes("GO12MONTHS100")
|
||||
? LiteData.twelveMonths100Coupon
|
||||
: coupons.includes("GO6MONTHS100")
|
||||
? LiteData.sixMonths100Coupon
|
||||
: coupons.includes("GO3MONTHS100")
|
||||
? LiteData.threeMonths100Coupon
|
||||
: coupons.includes("GOFREEMONTH")
|
||||
? LiteData.firstMonth100Coupon
|
||||
: LiteData.firstMonth50Coupon
|
||||
const createSession = () =>
|
||||
Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
discounts: [{ coupon }],
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email,
|
||||
}),
|
||||
...(() => {
|
||||
if (method === "alipay") {
|
||||
return {
|
||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||
payment_method_types: ["alipay"],
|
||||
adaptive_pricing: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (method === "upi") {
|
||||
return {
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "inr",
|
||||
product: LiteData.productID(),
|
||||
recurring: {
|
||||
interval: "month",
|
||||
interval_count: 1,
|
||||
},
|
||||
unit_amount: LiteData.priceInr(),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
payment_method_types: ["upi"] as any,
|
||||
adaptive_pricing: {
|
||||
enabled: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||
billing_address_collection: "required",
|
||||
}
|
||||
})(),
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
userID: user.properties.userID,
|
||||
userEmail: email,
|
||||
coupon,
|
||||
type: "lite",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await createSession()
|
||||
return session.url
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e.type !== "StripeInvalidRequestError" ||
|
||||
!e.message.includes("You cannot combine currencies on a single customer")
|
||||
)
|
||||
throw e
|
||||
|
||||
// get pending payment intent
|
||||
const intents = await Billing.stripe().paymentIntents.search({
|
||||
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
|
||||
})
|
||||
if (intents.data.length === 0) throw e
|
||||
|
||||
for (const intent of intents.data) {
|
||||
// get checkout session
|
||||
const sessions = await Billing.stripe().checkout.sessions.list({
|
||||
customer: billing.customerID!,
|
||||
payment_intent: intent.id,
|
||||
})
|
||||
|
||||
// delete pending payment intent
|
||||
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
|
||||
}
|
||||
|
||||
const session = await createSession()
|
||||
return session.url
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const generateSessionUrl = fn(
|
||||
z.object({
|
||||
returnUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const { returnUrl } = input
|
||||
|
||||
const customer = await Billing.get()
|
||||
if (!customer?.customerID) {
|
||||
throw new Error("No stripe customer ID")
|
||||
}
|
||||
|
||||
const session = await Billing.stripe().billingPortal.sessions.create({
|
||||
customer: customer.customerID,
|
||||
return_url: returnUrl,
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
|
||||
export const generateReceiptUrl = fn(
|
||||
z.object({
|
||||
paymentID: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const { paymentID } = input
|
||||
|
||||
const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
|
||||
if (!intent.latest_charge) throw new Error("No charge found")
|
||||
|
||||
const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
|
||||
if (!charge.receipt_url) throw new Error("No receipt URL found")
|
||||
|
||||
return charge.receipt_url
|
||||
},
|
||||
)
|
||||
|
||||
export const subscribeBlack = fn(
|
||||
z.object({
|
||||
seats: z.number(),
|
||||
coupon: z.string().optional(),
|
||||
}),
|
||||
async ({ seats, coupon }) => {
|
||||
const user = Actor.assert("user")
|
||||
const billing = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
customerID: BillingTable.customerID,
|
||||
paymentMethodID: BillingTable.paymentMethodID,
|
||||
subscriptionID: BillingTable.subscriptionID,
|
||||
subscriptionPlan: BillingTable.subscriptionPlan,
|
||||
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected,
|
||||
})
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
if (!billing) throw new Error("Billing record not found")
|
||||
if (!billing.timeSubscriptionSelected) throw new Error("Not selected for subscription")
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed")
|
||||
if (!billing.customerID) throw new Error("No customer ID")
|
||||
if (!billing.paymentMethodID) throw new Error("No payment method")
|
||||
if (!billing.subscriptionPlan) throw new Error("No subscription plan")
|
||||
|
||||
const subscription = await Billing.stripe().subscriptions.create({
|
||||
customer: billing.customerID,
|
||||
default_payment_method: billing.paymentMethodID,
|
||||
items: [{ price: BlackData.planToPriceID({ plan: billing.subscriptionPlan }) }],
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
},
|
||||
})
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
subscriptionID: subscription.id,
|
||||
subscription: {
|
||||
status: "subscribed",
|
||||
coupon,
|
||||
seats,
|
||||
plan: billing.subscriptionPlan!,
|
||||
},
|
||||
subscriptionPlan: null,
|
||||
timeSubscriptionBooked: null,
|
||||
timeSubscriptionSelected: null,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, Actor.workspace()))
|
||||
|
||||
await tx.insert(SubscriptionTable).values({
|
||||
workspaceID: Actor.workspace(),
|
||||
id: Identifier.create("subscription"),
|
||||
userID: user.properties.userID,
|
||||
})
|
||||
})
|
||||
|
||||
return subscription.id
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribeBlack = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.subscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ subscriptionID: null, subscription: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(SubscriptionTable).where(eq(SubscriptionTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribeLite = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.liteSubscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ liteSubscriptionID: null, lite: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
40
qimingcode/packages/console/core/src/black.ts
Normal file
40
qimingcode/packages/console/core/src/black.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { BlackPlans } from "./schema/billing.sql"
|
||||
import { Subscription } from "./subscription"
|
||||
|
||||
export namespace BlackData {
|
||||
export const getLimits = fn(
|
||||
z.object({
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
return Subscription.getLimits()["black"][plan]
|
||||
},
|
||||
)
|
||||
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_BLACK_PRICE.product)
|
||||
|
||||
export const planToPriceID = fn(
|
||||
z.object({
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||
if (plan === "100") return Resource.ZEN_BLACK_PRICE.plan100
|
||||
return Resource.ZEN_BLACK_PRICE.plan20
|
||||
},
|
||||
)
|
||||
|
||||
export const priceIDToPlan = fn(
|
||||
z.object({
|
||||
priceID: z.string(),
|
||||
}),
|
||||
({ priceID }) => {
|
||||
if (priceID === Resource.ZEN_BLACK_PRICE.plan200) return "200"
|
||||
if (priceID === Resource.ZEN_BLACK_PRICE.plan100) return "100"
|
||||
return "20"
|
||||
},
|
||||
)
|
||||
}
|
||||
21
qimingcode/packages/console/core/src/context.ts
Normal file
21
qimingcode/packages/console/core/src/context.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks"
|
||||
|
||||
export namespace Context {
|
||||
export class NotFound extends Error {}
|
||||
|
||||
export function create<T>() {
|
||||
const storage = new AsyncLocalStorage<T>()
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore()
|
||||
if (!result) {
|
||||
throw new NotFound()
|
||||
}
|
||||
return result
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run(value, fn)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
85
qimingcode/packages/console/core/src/drizzle/index.ts
Normal file
85
qimingcode/packages/console/core/src/drizzle/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
export * from "drizzle-orm"
|
||||
import { Client } from "@planetscale/database"
|
||||
|
||||
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
|
||||
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
|
||||
import { Context } from "../context"
|
||||
import { memo } from "../util/memo"
|
||||
|
||||
export namespace Database {
|
||||
export type Transaction = MySqlTransaction<
|
||||
PlanetscaleQueryResultHKT,
|
||||
PlanetScalePreparedQueryHKT,
|
||||
Record<string, never>,
|
||||
any
|
||||
>
|
||||
|
||||
const client = memo(() => {
|
||||
const result = new Client({
|
||||
host: Resource.Database.host,
|
||||
username: Resource.Database.username,
|
||||
password: Resource.Database.password,
|
||||
})
|
||||
const db = drizzle({ client: result })
|
||||
return db
|
||||
})
|
||||
|
||||
export type TxOrDb = Transaction | ReturnType<typeof client>
|
||||
|
||||
const TransactionContext = Context.create<{
|
||||
tx: TxOrDb
|
||||
effects: (() => void | Promise<void>)[]
|
||||
}>()
|
||||
|
||||
export async function use<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use()
|
||||
return tx.transaction(callback)
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = await TransactionContext.provide(
|
||||
{
|
||||
effects,
|
||||
tx: client(),
|
||||
},
|
||||
() => callback(client()),
|
||||
)
|
||||
await Promise.all(effects.map((x) => x()))
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
export async function fn<Input, T>(callback: (input: Input, trx: TxOrDb) => Promise<T>) {
|
||||
return (input: Input) => use(async (tx) => callback(input, tx))
|
||||
}
|
||||
|
||||
export async function effect(effect: () => any | Promise<any>) {
|
||||
try {
|
||||
const { effects } = TransactionContext.use()
|
||||
effects.push(effect)
|
||||
} catch {
|
||||
await effect()
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(callback: (tx: TxOrDb) => Promise<T>, config?: MySqlTransactionConfig) {
|
||||
try {
|
||||
const { tx } = TransactionContext.use()
|
||||
return callback(tx)
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = await client().transaction(async (tx) => {
|
||||
return TransactionContext.provide({ tx, effects }, () => callback(tx))
|
||||
}, config)
|
||||
await Promise.all(effects.map((x) => x()))
|
||||
return result
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
33
qimingcode/packages/console/core/src/drizzle/types.ts
Normal file
33
qimingcode/packages/console/core/src/drizzle/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { bigint, timestamp, varchar } from "drizzle-orm/mysql-core"
|
||||
|
||||
export const ulid = (name: string) => varchar(name, { length: 30 })
|
||||
|
||||
export const workspaceColumns = {
|
||||
get id() {
|
||||
return ulid("id").notNull()
|
||||
},
|
||||
get workspaceID() {
|
||||
return ulid("workspace_id").notNull()
|
||||
},
|
||||
}
|
||||
|
||||
export const id = () => ulid("id").notNull()
|
||||
|
||||
export const utc = (name: string) =>
|
||||
timestamp(name, {
|
||||
fsp: 3,
|
||||
})
|
||||
|
||||
export const currency = (name: string) =>
|
||||
bigint(name, {
|
||||
mode: "number",
|
||||
})
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)`),
|
||||
timeDeleted: utc("time_deleted"),
|
||||
}
|
||||
32
qimingcode/packages/console/core/src/identifier.ts
Normal file
32
qimingcode/packages/console/core/src/identifier.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ulid } from "ulid"
|
||||
import { z } from "zod"
|
||||
|
||||
export namespace Identifier {
|
||||
const prefixes = {
|
||||
account: "acc",
|
||||
auth: "aut",
|
||||
benchmark: "ben",
|
||||
billing: "bil",
|
||||
key: "key",
|
||||
lite: "lit",
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
subscription: "sub",
|
||||
usage: "usg",
|
||||
user: "usr",
|
||||
workspace: "wrk",
|
||||
} as const
|
||||
|
||||
export function create(prefix: keyof typeof prefixes, given?: string): string {
|
||||
if (given) {
|
||||
if (given.startsWith(prefixes[prefix])) return given
|
||||
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
|
||||
}
|
||||
return [prefixes[prefix], ulid()].join("_")
|
||||
}
|
||||
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
return z.string().startsWith(prefixes[prefix])
|
||||
}
|
||||
}
|
||||
92
qimingcode/packages/console/core/src/key.ts
Normal file
92
qimingcode/packages/console/core/src/key.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
import { and, Database, eq, isNull, sql } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
|
||||
export namespace Key {
|
||||
export const list = fn(z.void(), async () => {
|
||||
const keys = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
id: KeyTable.id,
|
||||
name: KeyTable.name,
|
||||
key: KeyTable.key,
|
||||
timeUsed: KeyTable.timeUsed,
|
||||
userID: KeyTable.userID,
|
||||
email: AuthTable.subject,
|
||||
})
|
||||
.from(KeyTable)
|
||||
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
|
||||
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(
|
||||
and(
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
isNull(KeyTable.timeDeleted),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
),
|
||||
)
|
||||
.orderBy(sql`${KeyTable.name} DESC`),
|
||||
)
|
||||
// only return value for user's keys
|
||||
return keys.map((key) => ({
|
||||
...key,
|
||||
key: key.userID === Actor.userID() ? key.key : undefined,
|
||||
keyDisplay: `${key.key.slice(0, 7)}...${key.key.slice(-4)}`,
|
||||
}))
|
||||
})
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
userID: z.string(),
|
||||
name: z.string().min(1).max(255),
|
||||
}),
|
||||
async (input) => {
|
||||
const { name } = input
|
||||
|
||||
// Generate secret key: sk- + 64 random characters (upper, lower, numbers)
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
let secretKey = "sk-"
|
||||
const array = new Uint32Array(64)
|
||||
crypto.getRandomValues(array)
|
||||
for (let i = 0, l = array.length; i < l; i++) {
|
||||
secretKey += chars[array[i] % chars.length]
|
||||
}
|
||||
const keyID = Identifier.create("key")
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx.insert(KeyTable).values({
|
||||
id: keyID,
|
||||
workspaceID: Actor.workspace(),
|
||||
userID: input.userID,
|
||||
name,
|
||||
key: secretKey,
|
||||
timeUsed: null,
|
||||
}),
|
||||
)
|
||||
|
||||
return keyID
|
||||
},
|
||||
)
|
||||
|
||||
export const remove = fn(z.object({ id: z.string() }), async (input) => {
|
||||
// only admin can remove other user's keys
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(KeyTable.id, input.id),
|
||||
eq(KeyTable.workspaceID, Actor.workspace()),
|
||||
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
20
qimingcode/packages/console/core/src/lite.ts
Normal file
20
qimingcode/packages/console/core/src/lite.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { Subscription } from "./subscription"
|
||||
|
||||
export namespace LiteData {
|
||||
export const getLimits = fn(z.void(), () => {
|
||||
return Subscription.getLimits()["lite"]
|
||||
})
|
||||
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
||||
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
|
||||
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
|
||||
export const threeMonths100Coupon = Resource.ZEN_LITE_PRICE.threeMonths100Coupon
|
||||
export const sixMonths100Coupon = Resource.ZEN_LITE_PRICE.sixMonths100Coupon
|
||||
export const twelveMonths100Coupon = Resource.ZEN_LITE_PRICE.twelveMonths100Coupon
|
||||
export const planName = fn(z.void(), () => "lite")
|
||||
}
|
||||
229
qimingcode/packages/console/core/src/model.ts
Normal file
229
qimingcode/packages/console/core/src/model.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { z } from "zod"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { Database } from "./drizzle"
|
||||
import { ModelTable } from "./schema/model.sql"
|
||||
import { Identifier } from "./identifier"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace ZenData {
|
||||
const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
|
||||
export type Format = z.infer<typeof FormatSchema>
|
||||
|
||||
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({
|
||||
name: z.string(),
|
||||
cost: ModelCostSchema,
|
||||
cost200K: ModelCostSchema.optional(),
|
||||
allowAnonymous: z.boolean().optional(),
|
||||
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
|
||||
stickyProvider: z.enum(["strict", "prefer"]).optional(),
|
||||
trialProvider: z.string().optional(),
|
||||
trialEnded: z.boolean().optional(),
|
||||
fallbackProvider: z.string().optional(),
|
||||
rateLimit: z.number().optional(),
|
||||
providers: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
model: z.string(),
|
||||
priority: z.number().optional(),
|
||||
tpmLimit: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
storeModel: z.string().optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
safetyIdentifier: z.boolean().optional(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const ProviderSchema = z.object({
|
||||
displayName: z.string().optional(),
|
||||
api: z.string(),
|
||||
apiKey: z.union([z.string(), z.record(z.string(), z.string())]),
|
||||
format: FormatSchema.optional(),
|
||||
headerMappings: z.record(z.string(), z.string()).optional(),
|
||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||
adjustCacheUsage: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const ModelsSchema = z.object({
|
||||
zenModels: z.record(
|
||||
z.string(),
|
||||
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
|
||||
),
|
||||
liteModels: z.record(
|
||||
z.string(),
|
||||
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
|
||||
),
|
||||
providers: z.record(z.string(), ProviderSchema),
|
||||
})
|
||||
|
||||
export const validate = fn(ModelsSchema, (input) => {
|
||||
return input
|
||||
})
|
||||
|
||||
export const list = fn(z.enum(["lite", "full"]), (modelList) => {
|
||||
const json = JSON.parse(
|
||||
Resource.ZEN_MODELS1.value +
|
||||
Resource.ZEN_MODELS2.value +
|
||||
Resource.ZEN_MODELS3.value +
|
||||
Resource.ZEN_MODELS4.value +
|
||||
Resource.ZEN_MODELS5.value +
|
||||
Resource.ZEN_MODELS6.value +
|
||||
Resource.ZEN_MODELS7.value +
|
||||
Resource.ZEN_MODELS8.value +
|
||||
Resource.ZEN_MODELS9.value +
|
||||
Resource.ZEN_MODELS10.value +
|
||||
Resource.ZEN_MODELS11.value +
|
||||
Resource.ZEN_MODELS12.value +
|
||||
Resource.ZEN_MODELS13.value +
|
||||
Resource.ZEN_MODELS14.value +
|
||||
Resource.ZEN_MODELS15.value +
|
||||
Resource.ZEN_MODELS16.value +
|
||||
Resource.ZEN_MODELS17.value +
|
||||
Resource.ZEN_MODELS18.value +
|
||||
Resource.ZEN_MODELS19.value +
|
||||
Resource.ZEN_MODELS20.value +
|
||||
Resource.ZEN_MODELS21.value +
|
||||
Resource.ZEN_MODELS22.value +
|
||||
Resource.ZEN_MODELS23.value +
|
||||
Resource.ZEN_MODELS24.value +
|
||||
Resource.ZEN_MODELS25.value +
|
||||
Resource.ZEN_MODELS26.value +
|
||||
Resource.ZEN_MODELS27.value +
|
||||
Resource.ZEN_MODELS28.value +
|
||||
Resource.ZEN_MODELS29.value +
|
||||
Resource.ZEN_MODELS30.value,
|
||||
)
|
||||
const { zenModels, liteModels, providers } = ModelsSchema.parse(json)
|
||||
const compositeProviders = Object.fromEntries(
|
||||
Object.entries(providers).map(([id, provider]) => [
|
||||
id,
|
||||
typeof provider.apiKey === "string"
|
||||
? [{ id: id, key: provider.apiKey }]
|
||||
: Object.entries(provider.apiKey).map(([kid, key]) => ({
|
||||
id: `${id}.${kid}`,
|
||||
key,
|
||||
})),
|
||||
]),
|
||||
)
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
Object.entries(providers).flatMap(([providerId, provider]) =>
|
||||
compositeProviders[providerId].map((p) => [p.id, { ...provider, apiKey: p.key }]),
|
||||
),
|
||||
),
|
||||
models: (() => {
|
||||
const normalize = (model: z.infer<typeof ModelSchema>) => {
|
||||
const providers = model.providers.map((p) => ({
|
||||
...p,
|
||||
priority: p.priority ?? Infinity,
|
||||
weight: p.weight ?? 1,
|
||||
}))
|
||||
const composite = providers.find((p) => compositeProviders[p.id].length > 1)
|
||||
if (!composite)
|
||||
return {
|
||||
trialProvider: model.trialProvider ? [model.trialProvider] : undefined,
|
||||
providers,
|
||||
}
|
||||
|
||||
const weightMulti = compositeProviders[composite.id].length
|
||||
|
||||
return {
|
||||
trialProvider: (() => {
|
||||
if (!model.trialProvider) return undefined
|
||||
if (model.trialProvider === composite.id) return compositeProviders[composite.id].map((p) => p.id)
|
||||
return [model.trialProvider]
|
||||
})(),
|
||||
providers: providers.flatMap((p) =>
|
||||
p.id === composite.id
|
||||
? compositeProviders[p.id].map((sub) => ({
|
||||
...p,
|
||||
id: sub.id,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
...p,
|
||||
weight: p.weight * weightMulti,
|
||||
},
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(modelList === "lite" ? liteModels : zenModels).map(([modelId, model]) => {
|
||||
const n = Array.isArray(model)
|
||||
? model.map((m) => ({ ...m, ...normalize(m) }))
|
||||
: { ...model, ...normalize(model) }
|
||||
return [modelId, n]
|
||||
}),
|
||||
)
|
||||
})(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export namespace Model {
|
||||
export const enable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((db) =>
|
||||
db.delete(ModelTable).where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model))),
|
||||
)
|
||||
})
|
||||
|
||||
export const disable = fn(z.object({ model: z.string() }), ({ model }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.insert(ModelTable)
|
||||
.values({
|
||||
id: Identifier.create("model"),
|
||||
workspaceID: Actor.workspace(),
|
||||
model: model,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
export const listDisabled = fn(z.void(), () => {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select({ model: ModelTable.model })
|
||||
.from(ModelTable)
|
||||
.where(eq(ModelTable.workspaceID, Actor.workspace()))
|
||||
.then((rows) => rows.map((row) => row.model)),
|
||||
)
|
||||
})
|
||||
|
||||
export const isDisabled = fn(
|
||||
z.object({
|
||||
model: z.string(),
|
||||
}),
|
||||
({ model }) => {
|
||||
return Database.use(async (db) => {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(ModelTable)
|
||||
.where(and(eq(ModelTable.workspaceID, Actor.workspace()), eq(ModelTable.model, model)))
|
||||
.limit(1)
|
||||
|
||||
return result.length > 0
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
57
qimingcode/packages/console/core/src/provider.ts
Normal file
57
qimingcode/packages/console/core/src/provider.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
import { and, Database, eq, isNull } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { ProviderTable } from "./schema/provider.sql"
|
||||
|
||||
export namespace Provider {
|
||||
export const list = fn(z.void(), () =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(ProviderTable)
|
||||
.where(and(eq(ProviderTable.workspaceID, Actor.workspace()), isNull(ProviderTable.timeDeleted))),
|
||||
),
|
||||
)
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
provider: z.string().min(1).max(64),
|
||||
credentials: z.string(),
|
||||
}),
|
||||
async ({ provider, credentials }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.insert(ProviderTable)
|
||||
.values({
|
||||
id: Identifier.create("provider"),
|
||||
workspaceID: Actor.workspace(),
|
||||
provider,
|
||||
credentials,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
credentials,
|
||||
timeDeleted: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const remove = fn(
|
||||
z.object({
|
||||
provider: z.string(),
|
||||
}),
|
||||
async ({ provider }) => {
|
||||
Actor.assertAdmin()
|
||||
return Database.use((tx) =>
|
||||
tx
|
||||
.delete(ProviderTable)
|
||||
.where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
11
qimingcode/packages/console/core/src/schema/account.sql.ts
Normal file
11
qimingcode/packages/console/core/src/schema/account.sql.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { mysqlTable, primaryKey } from "drizzle-orm/mysql-core"
|
||||
import { id, timestamps } from "../drizzle/types"
|
||||
|
||||
export const AccountTable = mysqlTable(
|
||||
"account",
|
||||
{
|
||||
id: id(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id] })],
|
||||
)
|
||||
20
qimingcode/packages/console/core/src/schema/auth.sql.ts
Normal file
20
qimingcode/packages/console/core/src/schema/auth.sql.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { index, mysqlEnum, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { id, timestamps, ulid } from "../drizzle/types"
|
||||
|
||||
export const AuthProvider = ["email", "github", "google"] as const
|
||||
|
||||
export const AuthTable = mysqlTable(
|
||||
"auth",
|
||||
{
|
||||
id: id(),
|
||||
...timestamps,
|
||||
provider: mysqlEnum("provider", AuthProvider).notNull(),
|
||||
subject: varchar("subject", { length: 255 }).notNull(),
|
||||
accountID: ulid("account_id").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.id] }),
|
||||
uniqueIndex("provider").on(table.provider, table.subject),
|
||||
index("account_id").on(table.accountID),
|
||||
],
|
||||
)
|
||||
14
qimingcode/packages/console/core/src/schema/benchmark.sql.ts
Normal file
14
qimingcode/packages/console/core/src/schema/benchmark.sql.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"
|
||||
import { id, timestamps } from "../drizzle/types"
|
||||
|
||||
export const BenchmarkTable = mysqlTable(
|
||||
"benchmark",
|
||||
{
|
||||
id: id(),
|
||||
...timestamps,
|
||||
model: varchar("model", { length: 64 }).notNull(),
|
||||
agent: varchar("agent", { length: 64 }).notNull(),
|
||||
result: mediumtext("result").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)],
|
||||
)
|
||||
145
qimingcode/packages/console/core/src/schema/billing.sql.ts
Normal file
145
qimingcode/packages/console/core/src/schema/billing.sql.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
index,
|
||||
int,
|
||||
json,
|
||||
mysqlEnum,
|
||||
mysqlTable,
|
||||
primaryKey,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const BlackPlans = ["20", "100", "200"] as const
|
||||
export const BillingTable = mysqlTable(
|
||||
"billing",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
customerID: varchar("customer_id", { length: 255 }),
|
||||
paymentMethodID: varchar("payment_method_id", { length: 255 }),
|
||||
paymentMethodType: varchar("payment_method_type", { length: 32 }),
|
||||
paymentMethodLast4: varchar("payment_method_last4", { length: 4 }),
|
||||
balance: bigint("balance", { mode: "number" }).notNull(),
|
||||
monthlyLimit: int("monthly_limit"),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||
reload: boolean("reload"),
|
||||
reloadTrigger: int("reload_trigger"),
|
||||
reloadAmount: int("reload_amount"),
|
||||
reloadError: varchar("reload_error", { length: 255 }),
|
||||
timeReloadError: utc("time_reload_error"),
|
||||
timeReloadLockedTill: utc("time_reload_locked_till"),
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
seats: number
|
||||
plan: (typeof BlackPlans)[number]
|
||||
useBalance?: boolean
|
||||
coupon?: string
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", BlackPlans),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||
liteSubscriptionID: varchar("lite_subscription_id", { length: 28 }),
|
||||
lite: json("lite").$type<{
|
||||
useBalance?: boolean
|
||||
}>(),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
uniqueIndex("global_customer_id").on(table.customerID),
|
||||
uniqueIndex("global_subscription_id").on(table.subscriptionID),
|
||||
],
|
||||
)
|
||||
|
||||
export const SubscriptionTable = mysqlTable(
|
||||
"subscription",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
fixedUsage: bigint("fixed_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeFixedUpdated: utc("time_fixed_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const LiteTable = mysqlTable(
|
||||
"lite",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
weeklyUsage: bigint("weekly_usage", { mode: "number" }),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeWeeklyUpdated: utc("time_weekly_updated"),
|
||||
timeMonthlyUpdated: utc("time_monthly_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
"payment",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
customerID: varchar("customer_id", { length: 255 }),
|
||||
invoiceID: varchar("invoice_id", { length: 255 }),
|
||||
paymentID: varchar("payment_id", { length: 255 }),
|
||||
amount: bigint("amount", { mode: "number" }).notNull(),
|
||||
timeRefunded: utc("time_refunded"),
|
||||
enrichment: json("enrichment").$type<
|
||||
| {
|
||||
type: "subscription" | "lite"
|
||||
currency?: "inr"
|
||||
couponID?: string
|
||||
}
|
||||
| {
|
||||
type: "credit"
|
||||
}
|
||||
>(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table)],
|
||||
)
|
||||
|
||||
export const UsageTable = mysqlTable(
|
||||
"usage",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
model: varchar("model", { length: 255 }).notNull(),
|
||||
provider: varchar("provider", { length: 255 }).notNull(),
|
||||
inputTokens: int("input_tokens").notNull(),
|
||||
outputTokens: int("output_tokens").notNull(),
|
||||
reasoningTokens: int("reasoning_tokens"),
|
||||
cacheReadTokens: int("cache_read_tokens"),
|
||||
cacheWrite5mTokens: int("cache_write_5m_tokens"),
|
||||
cacheWrite1hTokens: int("cache_write_1h_tokens"),
|
||||
cost: bigint("cost", { mode: "number" }).notNull(),
|
||||
keyID: ulid("key_id"),
|
||||
sessionID: varchar("session_id", { length: 30 }),
|
||||
enrichment: json("enrichment").$type<{
|
||||
plan: "sub" | "byok" | "lite"
|
||||
}>(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||
)
|
||||
|
||||
export const CouponType = ["BUILDATHON", "GOFREEMONTH", "GO3MONTHS100", "GO6MONTHS100", "GO12MONTHS100"] as const
|
||||
export const CouponTable = mysqlTable(
|
||||
"coupon",
|
||||
{
|
||||
email: varchar("email", { length: 255 }),
|
||||
type: mysqlEnum("type", CouponType).notNull(),
|
||||
timeRedeemed: utc("time_redeemed"),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.email, table.type] })],
|
||||
)
|
||||
42
qimingcode/packages/console/core/src/schema/ip.sql.ts
Normal file
42
qimingcode/packages/console/core/src/schema/ip.sql.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { mysqlTable, int, primaryKey, varchar, bigint } from "drizzle-orm/mysql-core"
|
||||
import { timestamps } from "../drizzle/types"
|
||||
|
||||
export const IpTable = mysqlTable(
|
||||
"ip",
|
||||
{
|
||||
ip: varchar("ip", { length: 45 }).notNull(),
|
||||
...timestamps,
|
||||
usage: int("usage"),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.ip] })],
|
||||
)
|
||||
|
||||
export const IpRateLimitTable = mysqlTable(
|
||||
"ip_rate_limit",
|
||||
{
|
||||
ip: varchar("ip", { length: 45 }).notNull(),
|
||||
interval: varchar("interval", { length: 10 }).notNull(),
|
||||
count: int("count").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.ip, table.interval] })],
|
||||
)
|
||||
|
||||
export const KeyRateLimitTable = mysqlTable(
|
||||
"key_rate_limit",
|
||||
{
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
interval: varchar("interval", { length: 40 }).notNull(),
|
||||
count: int("count").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.key, table.interval] })],
|
||||
)
|
||||
|
||||
export const ModelTpmRateLimitTable = mysqlTable(
|
||||
"model_tpm_rate_limit",
|
||||
{
|
||||
id: varchar("id", { length: 255 }).notNull(),
|
||||
interval: bigint("interval", { mode: "number" }).notNull(),
|
||||
count: int("count").notNull(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.id, table.interval] })],
|
||||
)
|
||||
16
qimingcode/packages/console/core/src/schema/key.sql.ts
Normal file
16
qimingcode/packages/console/core/src/schema/key.sql.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { mysqlTable, varchar, uniqueIndex } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const KeyTable = mysqlTable(
|
||||
"key",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
key: varchar("key", { length: 255 }).notNull(),
|
||||
userID: ulid("user_id").notNull(),
|
||||
timeUsed: utc("time_used"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("global_key").on(table.key)],
|
||||
)
|
||||
13
qimingcode/packages/console/core/src/schema/model.sql.ts
Normal file
13
qimingcode/packages/console/core/src/schema/model.sql.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mysqlTable, varchar, uniqueIndex } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const ModelTable = mysqlTable(
|
||||
"model",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
model: varchar("model", { length: 64 }).notNull(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("model_workspace_model").on(table.workspaceID, table.model)],
|
||||
)
|
||||
14
qimingcode/packages/console/core/src/schema/provider.sql.ts
Normal file
14
qimingcode/packages/console/core/src/schema/provider.sql.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { mysqlTable, text, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const ProviderTable = mysqlTable(
|
||||
"provider",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
provider: varchar("provider", { length: 64 }).notNull(),
|
||||
credentials: text("credentials").notNull(),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_provider").on(table.workspaceID, table.provider)],
|
||||
)
|
||||
29
qimingcode/packages/console/core/src/schema/user.sql.ts
Normal file
29
qimingcode/packages/console/core/src/schema/user.sql.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum, index, bigint } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const UserRole = ["admin", "member"] as const
|
||||
|
||||
export const UserTable = mysqlTable(
|
||||
"user",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
accountID: ulid("account_id"),
|
||||
email: varchar("email", { length: 255 }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
timeSeen: utc("time_seen"),
|
||||
color: int("color"),
|
||||
role: mysqlEnum("role", UserRole).notNull(),
|
||||
monthlyLimit: int("monthly_limit"),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeMonthlyUsageUpdated: utc("time_monthly_usage_updated"),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
uniqueIndex("user_account_id").on(table.workspaceID, table.accountID),
|
||||
uniqueIndex("user_email").on(table.workspaceID, table.email),
|
||||
index("global_account_id").on(table.accountID),
|
||||
index("global_email").on(table.email),
|
||||
],
|
||||
)
|
||||
21
qimingcode/packages/console/core/src/schema/workspace.sql.ts
Normal file
21
qimingcode/packages/console/core/src/schema/workspace.sql.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
import { timestamps, ulid } from "../drizzle/types"
|
||||
|
||||
export const WorkspaceTable = mysqlTable(
|
||||
"workspace",
|
||||
{
|
||||
id: ulid("id").notNull().primaryKey(),
|
||||
slug: varchar("slug", { length: 255 }),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(table) => [uniqueIndex("slug").on(table.slug)],
|
||||
)
|
||||
|
||||
export function workspaceIndexes(table: any) {
|
||||
return [
|
||||
primaryKey({
|
||||
columns: [table.workspaceID, table.id],
|
||||
}),
|
||||
]
|
||||
}
|
||||
153
qimingcode/packages/console/core/src/subscription.ts
Normal file
153
qimingcode/packages/console/core/src/subscription.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace Subscription {
|
||||
const LimitsSchema = z.object({
|
||||
free: z.object({
|
||||
promoTokens: z.number().int(),
|
||||
dailyRequests: z.number().int(),
|
||||
}),
|
||||
lite: z.object({
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
weeklyLimit: z.number().int(),
|
||||
monthlyLimit: z.number().int(),
|
||||
}),
|
||||
black: z.object({
|
||||
"20": z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
}),
|
||||
"100": z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
}),
|
||||
"200": z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
export const validate = fn(LimitsSchema, (input) => {
|
||||
return input
|
||||
})
|
||||
|
||||
export const getLimits = fn(z.void(), () => {
|
||||
const json = JSON.parse(Resource.ZEN_LIMITS.value)
|
||||
return LimitsSchema.parse(json)
|
||||
})
|
||||
|
||||
export const getFreeLimits = fn(z.void(), () => {
|
||||
return getLimits()["free"]
|
||||
})
|
||||
|
||||
export const analyzeRollingUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
window: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
}),
|
||||
({ limit, window, usage, timeUpdated }) => {
|
||||
const now = new Date()
|
||||
const rollingWindowMs = window * 3600 * 1000
|
||||
const rollingLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
const windowStart = new Date(now.getTime() - rollingWindowMs)
|
||||
if (timeUpdated < windowStart) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: window * 3600,
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const windowEnd = new Date(timeUpdated.getTime() + rollingWindowMs)
|
||||
if (usage < rollingLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const analyzeWeeklyUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
}),
|
||||
({ limit, usage, timeUpdated }) => {
|
||||
const now = new Date()
|
||||
const week = getWeekBounds(now)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
if (timeUpdated < week.start) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
if (usage < fixedLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const analyzeMonthlyUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
timeSubscribed: z.date(),
|
||||
}),
|
||||
({ limit, usage, timeUpdated, timeSubscribed }) => {
|
||||
const now = new Date()
|
||||
const month = getMonthlyBounds(now, timeSubscribed)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
if (timeUpdated < month.start) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
if (usage < fixedLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
226
qimingcode/packages/console/core/src/user.ts
Normal file
226
qimingcode/packages/console/core/src/user.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { z } from "zod"
|
||||
import { and, eq, getTableColumns, isNull, sql } from "drizzle-orm"
|
||||
import { fn } from "./util/fn"
|
||||
import { Database } from "./drizzle"
|
||||
import { UserRole, UserTable } from "./schema/user.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { Identifier } from "./identifier"
|
||||
import { render } from "@jsx-email/render"
|
||||
import { AWS } from "./aws"
|
||||
import { Key } from "./key"
|
||||
import { KeyTable } from "./schema/key.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { AuthTable } from "./schema/auth.sql"
|
||||
|
||||
export namespace User {
|
||||
const assertNotSelf = (id: string) => {
|
||||
if (Actor.userID() !== id) return
|
||||
throw new Error(`Expected not self actor, got self actor`)
|
||||
}
|
||||
|
||||
export const list = fn(z.void(), () =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
...getTableColumns(UserTable),
|
||||
authEmail: AuthTable.subject,
|
||||
})
|
||||
.from(UserTable)
|
||||
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, Actor.workspace()), isNull(UserTable.timeDeleted))),
|
||||
),
|
||||
)
|
||||
|
||||
export const fromID = fn(z.string(), (id) =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id), isNull(UserTable.timeDeleted)))
|
||||
.then((rows) => rows[0]),
|
||||
),
|
||||
)
|
||||
|
||||
export const getAuthEmail = fn(z.string(), (id) =>
|
||||
Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
email: AuthTable.subject,
|
||||
})
|
||||
.from(UserTable)
|
||||
.leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id)))
|
||||
.then((rows) => rows[0]?.email),
|
||||
),
|
||||
)
|
||||
|
||||
export const invite = fn(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
role: z.enum(UserRole),
|
||||
monthlyLimit: z.number().nullable().optional(),
|
||||
}),
|
||||
async ({ email, role, monthlyLimit }) => {
|
||||
Actor.assertAdmin()
|
||||
const workspaceID = Actor.workspace()
|
||||
|
||||
// create user
|
||||
const accountID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
accountID: AuthTable.accountID,
|
||||
})
|
||||
.from(AuthTable)
|
||||
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
|
||||
.then((rows) => rows[0]?.accountID),
|
||||
)
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.insert(UserTable)
|
||||
.values({
|
||||
id: Identifier.create("user"),
|
||||
name: "",
|
||||
...(accountID
|
||||
? {
|
||||
accountID,
|
||||
}
|
||||
: {
|
||||
email,
|
||||
}),
|
||||
workspaceID,
|
||||
role,
|
||||
monthlyLimit,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
role,
|
||||
monthlyLimit,
|
||||
timeDeleted: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// create api key
|
||||
if (accountID) {
|
||||
await Database.use(async (tx) => {
|
||||
const user = await tx
|
||||
.select()
|
||||
.from(UserTable)
|
||||
.where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, accountID)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
const key = await tx
|
||||
.select()
|
||||
.from(KeyTable)
|
||||
.where(and(eq(KeyTable.workspaceID, workspaceID), eq(KeyTable.userID, user.id)))
|
||||
.then((rows) => rows[0])
|
||||
|
||||
if (key) return
|
||||
|
||||
await Key.create({ userID: user.id, name: "Default API Key" })
|
||||
})
|
||||
}
|
||||
|
||||
// send email, ignore errors
|
||||
try {
|
||||
const emailInfo = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
inviterEmail: AuthTable.subject,
|
||||
workspaceName: WorkspaceTable.name,
|
||||
})
|
||||
.from(UserTable)
|
||||
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
|
||||
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, workspaceID))
|
||||
.where(
|
||||
and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, Actor.assert("user").properties.userID)),
|
||||
)
|
||||
.then((rows) => rows[0]),
|
||||
)
|
||||
|
||||
const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
|
||||
await AWS.sendEmail({
|
||||
to: email,
|
||||
subject: `You've been invited to join the ${emailInfo.workspaceName} workspace on OpenCode`,
|
||||
body: render(
|
||||
// @ts-ignore
|
||||
InviteEmail({
|
||||
inviter: emailInfo.inviterEmail,
|
||||
assetsUrl: `https://opencode.ai/email`,
|
||||
workspaceID: workspaceID,
|
||||
workspaceName: emailInfo.workspaceName,
|
||||
}),
|
||||
),
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const joinInvitedWorkspaces = fn(z.void(), async () => {
|
||||
const account = Actor.assert("account")
|
||||
const invitations = await Database.use(async (tx) => {
|
||||
const invitations = await tx
|
||||
.select({
|
||||
id: UserTable.id,
|
||||
workspaceID: UserTable.workspaceID,
|
||||
})
|
||||
.from(UserTable)
|
||||
.where(eq(UserTable.email, account.properties.email))
|
||||
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
accountID: account.properties.accountID,
|
||||
email: null,
|
||||
})
|
||||
.where(eq(UserTable.email, account.properties.email))
|
||||
return invitations
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
invitations.map((invite) =>
|
||||
Actor.provide(
|
||||
"system",
|
||||
{
|
||||
workspaceID: invite.workspaceID,
|
||||
},
|
||||
() => Key.create({ userID: invite.id, name: "Default API Key" }),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
role: z.enum(UserRole),
|
||||
monthlyLimit: z.number().nullable(),
|
||||
}),
|
||||
async ({ id, role, monthlyLimit }) => {
|
||||
Actor.assertAdmin()
|
||||
if (role === "member") assertNotSelf(id)
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.update(UserTable)
|
||||
.set({ role, monthlyLimit })
|
||||
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const remove = fn(z.string(), async (id) => {
|
||||
Actor.assertAdmin()
|
||||
assertNotSelf(id)
|
||||
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
timeDeleted: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
|
||||
)
|
||||
})
|
||||
}
|
||||
38
qimingcode/packages/console/core/src/util/date.ts
Normal file
38
qimingcode/packages/console/core/src/util/date.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function getWeekBounds(date: Date) {
|
||||
const offset = (date.getUTCDay() + 6) % 7
|
||||
const start = new Date(date)
|
||||
start.setUTCDate(date.getUTCDate() - offset)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function getMonthlyBounds(now: Date, subscribed: Date) {
|
||||
const day = subscribed.getUTCDate()
|
||||
const hh = subscribed.getUTCHours()
|
||||
const mm = subscribed.getUTCMinutes()
|
||||
const ss = subscribed.getUTCSeconds()
|
||||
const ms = subscribed.getUTCMilliseconds()
|
||||
|
||||
function anchor(year: number, month: number) {
|
||||
const max = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(year, month, Math.min(day, max), hh, mm, ss, ms))
|
||||
}
|
||||
|
||||
function shift(year: number, month: number, delta: number) {
|
||||
const total = year * 12 + month + delta
|
||||
return [Math.floor(total / 12), ((total % 12) + 12) % 12] as const
|
||||
}
|
||||
|
||||
let y = now.getUTCFullYear()
|
||||
let m = now.getUTCMonth()
|
||||
let start = anchor(y, m)
|
||||
if (start > now) {
|
||||
;[y, m] = shift(y, m, -1)
|
||||
start = anchor(y, m)
|
||||
}
|
||||
const [ny, nm] = shift(y, m, 1)
|
||||
const end = anchor(ny, nm)
|
||||
return { start, end }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {}
|
||||
11
qimingcode/packages/console/core/src/util/fn.ts
Normal file
11
qimingcode/packages/console/core/src/util/fn.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export function fn<T extends z.ZodType, Result>(schema: T, cb: (input: z.infer<T>) => Result) {
|
||||
const result = (input: z.infer<T>) => {
|
||||
const parsed = schema.parse(input)
|
||||
return cb(parsed)
|
||||
}
|
||||
result.force = (input: z.infer<T>) => cb(input)
|
||||
result.schema = schema
|
||||
return result
|
||||
}
|
||||
55
qimingcode/packages/console/core/src/util/log.ts
Normal file
55
qimingcode/packages/console/core/src/util/log.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Context } from "../context"
|
||||
|
||||
export namespace Log {
|
||||
const ctx = Context.create<{
|
||||
tags: Record<string, any>
|
||||
}>()
|
||||
|
||||
export function create(tags?: Record<string, any>) {
|
||||
tags = tags || {}
|
||||
|
||||
const result = {
|
||||
info(message?: any, extra?: Record<string, any>) {
|
||||
const prefix = Object.entries({
|
||||
...use().tags,
|
||||
...tags,
|
||||
...extra,
|
||||
})
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(" ")
|
||||
console.log(prefix, message)
|
||||
return result
|
||||
},
|
||||
tag(key: string, value: string) {
|
||||
if (tags) tags[key] = value
|
||||
return result
|
||||
},
|
||||
clone() {
|
||||
return Log.create({ ...tags })
|
||||
},
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function provide<R>(tags: Record<string, any>, cb: () => R) {
|
||||
const existing = use()
|
||||
return ctx.provide(
|
||||
{
|
||||
tags: {
|
||||
...existing.tags,
|
||||
...tags,
|
||||
},
|
||||
},
|
||||
cb,
|
||||
)
|
||||
}
|
||||
|
||||
function use() {
|
||||
try {
|
||||
return ctx.use()
|
||||
} catch {
|
||||
return { tags: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
18
qimingcode/packages/console/core/src/util/memo.ts
Normal file
18
qimingcode/packages/console/core/src/util/memo.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function memo<T>(fn: () => T, cleanup?: (input: T) => Promise<void>) {
|
||||
let value: T | undefined
|
||||
let loaded = false
|
||||
|
||||
const result = (): T => {
|
||||
if (loaded) return value as T
|
||||
loaded = true
|
||||
value = fn()
|
||||
return value as T
|
||||
}
|
||||
result.reset = async () => {
|
||||
if (cleanup && value) await cleanup(value)
|
||||
loaded = false
|
||||
value = undefined
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
7
qimingcode/packages/console/core/src/util/price.ts
Normal file
7
qimingcode/packages/console/core/src/util/price.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function centsToMicroCents(amount: number) {
|
||||
return Math.round(amount * 1000000)
|
||||
}
|
||||
|
||||
export function microCentsToCents(amount: number) {
|
||||
return Math.round(amount / 1000000)
|
||||
}
|
||||
76
qimingcode/packages/console/core/src/workspace.ts
Normal file
76
qimingcode/packages/console/core/src/workspace.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Actor } from "./actor"
|
||||
import { Database } from "./drizzle"
|
||||
import { Identifier } from "./identifier"
|
||||
import { UserTable } from "./schema/user.sql"
|
||||
import { BillingTable } from "./schema/billing.sql"
|
||||
import { WorkspaceTable } from "./schema/workspace.sql"
|
||||
import { Key } from "./key"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
|
||||
export namespace Workspace {
|
||||
export const create = fn(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
}),
|
||||
async ({ name }) => {
|
||||
const account = Actor.assert("account")
|
||||
const workspaceID = Identifier.create("workspace")
|
||||
const userID = Identifier.create("user")
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(WorkspaceTable).values({
|
||||
id: workspaceID,
|
||||
name,
|
||||
})
|
||||
await tx.insert(UserTable).values({
|
||||
workspaceID,
|
||||
id: userID,
|
||||
accountID: account.properties.accountID,
|
||||
name: "",
|
||||
role: "admin",
|
||||
})
|
||||
await tx.insert(BillingTable).values({
|
||||
workspaceID,
|
||||
id: Identifier.create("billing"),
|
||||
balance: 0,
|
||||
})
|
||||
})
|
||||
await Actor.provide(
|
||||
"system",
|
||||
{
|
||||
workspaceID,
|
||||
},
|
||||
() => Key.create({ userID, name: "Default API Key" }),
|
||||
)
|
||||
return workspaceID
|
||||
},
|
||||
)
|
||||
|
||||
export const update = fn(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
}),
|
||||
async ({ name }) => {
|
||||
Actor.assertAdmin()
|
||||
const workspaceID = Actor.workspace()
|
||||
return await Database.use((tx) =>
|
||||
tx
|
||||
.update(WorkspaceTable)
|
||||
.set({
|
||||
name,
|
||||
})
|
||||
.where(eq(WorkspaceTable.id, workspaceID)),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export const remove = fn(z.void(), async () => {
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(WorkspaceTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(eq(WorkspaceTable.id, Actor.workspace())),
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user