diff options
Diffstat (limited to 'packages/console/app')
| -rw-r--r-- | packages/console/app/src/routes/zen/util/error.ts | 1 | ||||
| -rw-r--r-- | packages/console/app/src/routes/zen/util/handler.ts | 31 | ||||
| -rw-r--r-- | packages/console/app/src/routes/zen/util/ipRateLimiter.ts (renamed from packages/console/app/src/routes/zen/util/rateLimiter.ts) | 9 | ||||
| -rw-r--r-- | packages/console/app/src/routes/zen/util/keyRateLimiter.ts | 39 | ||||
| -rw-r--r-- | packages/console/app/test/rateLimiter.test.ts | 2 |
5 files changed, 59 insertions, 23 deletions
diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index a3a93d2ef..b2a1d30d0 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -11,5 +11,6 @@ class LimitError extends Error { this.retryAfter = retryAfter } } +export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} export class SubscriptionUsageLimitError extends LimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 8c391d590..46d843522 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -21,6 +21,7 @@ import { MonthlyLimitError, UserLimitError, ModelError, + RateLimitError, FreeUsageLimitError, SubscriptionUsageLimitError, } from "./error" @@ -35,7 +36,8 @@ import { anthropicHelper } from "./provider/anthropic" import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" import { oaCompatHelper } from "./provider/openai-compatible" -import { createRateLimiter } from "./rateLimiter" +import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter" +import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter" import { createDataDumper } from "./dataDumper" import { createTrialLimiter } from "./trialLimiter" import { createStickyTracker } from "./stickyProviderTracker" @@ -92,6 +94,8 @@ export async function handler( const isStream = opts.parseIsStream(url, body) const rawIp = input.request.headers.get("x-real-ip") ?? "" const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp + const rawZenApiKey = opts.parseApiKey(input.request.headers) + const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey const sessionId = input.request.headers.get("x-opencode-session") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? "" @@ -108,17 +112,13 @@ export async function handler( const dataDumper = createDataDumper(sessionId, requestId, projectId) const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) const trialProviders = await trialLimiter?.check() - const rateLimiter = createRateLimiter( - modelInfo.id, - modelInfo.allowAnonymous, - modelInfo.rateLimit, - ip, - input.request, - ) + const rateLimiter = modelInfo.allowAnonymous + ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request) + : createKeyRateLimiter(modelInfo.id, zenApiKey, input.request) await rateLimiter?.check() const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyProvider = await stickyTracker?.get() - const authInfo = await authenticate(modelInfo) + const authInfo = await authenticate(modelInfo, zenApiKey) const billingSource = validateBilling(authInfo, modelInfo) logger.metric({ source: billingSource }) @@ -363,7 +363,11 @@ export async function handler( { status: 401 }, ) - if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) { + if ( + error instanceof RateLimitError || + error instanceof FreeUsageLimitError || + error instanceof SubscriptionUsageLimitError + ) { const headers = new Headers() if (error.retryAfter) { headers.set("retry-after", String(error.retryAfter)) @@ -492,9 +496,8 @@ export async function handler( } } - async function authenticate(modelInfo: ModelInfo) { - const apiKey = opts.parseApiKey(input.request.headers) - if (!apiKey || apiKey === "public") { + async function authenticate(modelInfo: ModelInfo, zenApiKey?: string) { + if (!zenApiKey) { if (modelInfo.allowAnonymous) return throw new AuthError(t("zen.api.error.missingApiKey")) } @@ -573,7 +576,7 @@ export async function handler( isNull(LiteTable.timeDeleted), ), ) - .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) + .where(and(eq(KeyTable.key, zenApiKey), isNull(KeyTable.timeDeleted))) .then((rows) => rows[0]), ) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts index 160633981..d22ab4ae2 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts @@ -6,14 +6,7 @@ import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { Subscription } from "@opencode-ai/console-core/subscription.js" -export function createRateLimiter( - modelId: string, - allowAnonymous: boolean | undefined, - rateLimit: number | undefined, - rawIp: string, - request: Request, -) { - if (!allowAnonymous) return +export function createRateLimiter(modelId: string, rateLimit: number | undefined, rawIp: string, request: Request) { const dict = i18n(localeFromRequest(request)) const limits = Subscription.getFreeLimits() diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts new file mode 100644 index 000000000..e3e0fb18f --- /dev/null +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -0,0 +1,39 @@ +import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" +import { RateLimitError } from "./error" +import { i18n } from "~/i18n" +import { localeFromRequest } from "~/lib/language" + +export function createRateLimiter(modelId: string, zenApiKey: string | undefined, request: Request) { + if (!zenApiKey) return + const dict = i18n(localeFromRequest(request)) + + const LIMIT = 100 + const yyyyMMddHHmm = new Date(Date.now()) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12) + const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}` + + return { + check: async () => { + const rows = await Database.use((tx) => + tx + .select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count }) + .from(KeyRateLimitTable) + .where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))), + ).then((rows) => rows[0]) + const count = rows?.count ?? 0 + + if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60) + }, + track: async () => { + await Database.use((tx) => + tx + .insert(KeyRateLimitTable) + .values({ key: zenApiKey, interval, count: 1 }) + .onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }), + ) + }, + } +} diff --git a/packages/console/app/test/rateLimiter.test.ts b/packages/console/app/test/rateLimiter.test.ts index 5cc97dccf..6c9622627 100644 --- a/packages/console/app/test/rateLimiter.test.ts +++ b/packages/console/app/test/rateLimiter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { getRetryAfterDay } from "../src/routes/zen/util/rateLimiter" +import { getRetryAfterDay } from "../src/routes/zen/util/ipRateLimiter" describe("getRetryAfterDay", () => { test("returns full day at midnight UTC", () => { |
