|
@@ -35,6 +35,8 @@ import { createTrialLimiter } from "./trialLimiter"
|
|
|
import { createStickyTracker } from "./stickyProviderTracker"
|
|
import { createStickyTracker } from "./stickyProviderTracker"
|
|
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
|
|
import { Resource } from "@opencode-ai/console-resource"
|
|
import { Resource } from "@opencode-ai/console-resource"
|
|
|
|
|
+import { i18n, type Key } from "~/i18n"
|
|
|
|
|
+import { localeFromRequest } from "~/lib/language"
|
|
|
|
|
|
|
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
|
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
|
|
|
type RetryOptions = {
|
|
type RetryOptions = {
|
|
@@ -43,6 +45,15 @@ type RetryOptions = {
|
|
|
}
|
|
}
|
|
|
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
|
type BillingSource = "anonymous" | "free" | "byok" | "subscription" | "lite" | "balance"
|
|
|
|
|
|
|
|
|
|
+function resolve(text: string, params?: Record<string, string | number>) {
|
|
|
|
|
+ if (!params) return text
|
|
|
|
|
+ return text.replace(/\{\{(\w+)\}\}/g, (raw, key) => {
|
|
|
|
|
+ const value = params[key]
|
|
|
|
|
+ if (value === undefined || value === null) return raw
|
|
|
|
|
+ return String(value)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export async function handler(
|
|
export async function handler(
|
|
|
input: APIEvent,
|
|
input: APIEvent,
|
|
|
opts: {
|
|
opts: {
|
|
@@ -60,6 +71,8 @@ export async function handler(
|
|
|
|
|
|
|
|
const MAX_FAILOVER_RETRIES = 3
|
|
const MAX_FAILOVER_RETRIES = 3
|
|
|
const MAX_429_RETRIES = 3
|
|
const MAX_429_RETRIES = 3
|
|
|
|
|
+ const dict = i18n(localeFromRequest(input.request))
|
|
|
|
|
+ const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
|
|
|
const ADMIN_WORKSPACES = [
|
|
const ADMIN_WORKSPACES = [
|
|
|
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
|
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
|
|
|
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
|
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
|
|
@@ -86,7 +99,7 @@ export async function handler(
|
|
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
|
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
|
|
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
|
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
|
|
const isTrial = await trialLimiter?.isTrial()
|
|
const isTrial = await trialLimiter?.isTrial()
|
|
|
- const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
|
|
|
|
|
|
|
+ const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request)
|
|
|
await rateLimiter?.check()
|
|
await rateLimiter?.check()
|
|
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
|
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
|
|
const stickyProvider = await stickyTracker?.get()
|
|
const stickyProvider = await stickyTracker?.get()
|
|
@@ -359,14 +372,20 @@ export async function handler(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function validateModel(zenData: ZenData, reqModel: string) {
|
|
function validateModel(zenData: ZenData, reqModel: string) {
|
|
|
- if (!(reqModel in zenData.models)) throw new ModelError(`Model ${reqModel} not supported`)
|
|
|
|
|
|
|
+ if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel }))
|
|
|
|
|
|
|
|
const modelId = reqModel as keyof typeof zenData.models
|
|
const modelId = reqModel as keyof typeof zenData.models
|
|
|
const modelData = Array.isArray(zenData.models[modelId])
|
|
const modelData = Array.isArray(zenData.models[modelId])
|
|
|
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
|
? zenData.models[modelId].find((model) => opts.format === model.formatFilter)
|
|
|
: zenData.models[modelId]
|
|
: zenData.models[modelId]
|
|
|
|
|
|
|
|
- if (!modelData) throw new ModelError(`Model ${reqModel} not supported for format ${opts.format}`)
|
|
|
|
|
|
|
+ if (!modelData)
|
|
|
|
|
+ throw new ModelError(
|
|
|
|
|
+ t("zen.api.error.modelFormatNotSupported", {
|
|
|
|
|
+ model: reqModel,
|
|
|
|
|
+ format: opts.format,
|
|
|
|
|
+ }),
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
logger.metric({ model: modelId })
|
|
logger.metric({ model: modelId })
|
|
|
|
|
|
|
@@ -418,8 +437,9 @@ export async function handler(
|
|
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
|
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
|
|
|
})()
|
|
})()
|
|
|
|
|
|
|
|
- if (!modelProvider) throw new ModelError("No provider available")
|
|
|
|
|
- if (!(modelProvider.id in zenData.providers)) throw new ModelError(`Provider ${modelProvider.id} not supported`)
|
|
|
|
|
|
|
+ if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
|
|
|
|
|
+ if (!(modelProvider.id in zenData.providers))
|
|
|
|
|
+ throw new ModelError(t("zen.api.error.providerNotSupported", { provider: modelProvider.id }))
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
...modelProvider,
|
|
...modelProvider,
|
|
@@ -439,7 +459,7 @@ export async function handler(
|
|
|
const apiKey = opts.parseApiKey(input.request.headers)
|
|
const apiKey = opts.parseApiKey(input.request.headers)
|
|
|
if (!apiKey || apiKey === "public") {
|
|
if (!apiKey || apiKey === "public") {
|
|
|
if (modelInfo.allowAnonymous) return
|
|
if (modelInfo.allowAnonymous) return
|
|
|
- throw new AuthError("Missing API key.")
|
|
|
|
|
|
|
+ throw new AuthError(t("zen.api.error.missingApiKey"))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const data = await Database.use((tx) =>
|
|
const data = await Database.use((tx) =>
|
|
@@ -520,13 +540,13 @@ export async function handler(
|
|
|
.then((rows) => rows[0]),
|
|
.then((rows) => rows[0]),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- if (!data) throw new AuthError("Invalid API key.")
|
|
|
|
|
|
|
+ if (!data) throw new AuthError(t("zen.api.error.invalidApiKey"))
|
|
|
if (
|
|
if (
|
|
|
modelInfo.id.startsWith("alpha-") &&
|
|
modelInfo.id.startsWith("alpha-") &&
|
|
|
Resource.App.stage === "production" &&
|
|
Resource.App.stage === "production" &&
|
|
|
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
|
!ADMIN_WORKSPACES.includes(data.workspaceID)
|
|
|
)
|
|
)
|
|
|
- throw new AuthError(`Model ${modelInfo.id} not supported`)
|
|
|
|
|
|
|
+ throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
|
|
|
|
|
|
|
|
logger.metric({
|
|
logger.metric({
|
|
|
api_key: data.apiKey,
|
|
api_key: data.apiKey,
|
|
@@ -590,7 +610,9 @@ export async function handler(
|
|
|
})
|
|
})
|
|
|
if (result.status === "rate-limited")
|
|
if (result.status === "rate-limited")
|
|
|
throw new SubscriptionUsageLimitError(
|
|
throw new SubscriptionUsageLimitError(
|
|
|
- `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
|
|
|
|
|
|
+ t("zen.api.error.subscriptionQuotaExceeded", {
|
|
|
|
|
+ retryIn: formatRetryTime(result.resetInSec),
|
|
|
|
|
+ }),
|
|
|
result.resetInSec,
|
|
result.resetInSec,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -606,7 +628,9 @@ export async function handler(
|
|
|
})
|
|
})
|
|
|
if (result.status === "rate-limited")
|
|
if (result.status === "rate-limited")
|
|
|
throw new SubscriptionUsageLimitError(
|
|
throw new SubscriptionUsageLimitError(
|
|
|
- `Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
|
|
|
|
|
|
|
+ t("zen.api.error.subscriptionQuotaExceeded", {
|
|
|
|
|
+ retryIn: formatRetryTime(result.resetInSec),
|
|
|
|
|
+ }),
|
|
|
result.resetInSec,
|
|
result.resetInSec,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -632,7 +656,7 @@ export async function handler(
|
|
|
})
|
|
})
|
|
|
if (result.status === "rate-limited")
|
|
if (result.status === "rate-limited")
|
|
|
throw new SubscriptionUsageLimitError(
|
|
throw new SubscriptionUsageLimitError(
|
|
|
- `Subscription quota exceeded. You can continue using free models.`,
|
|
|
|
|
|
|
+ t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
|
|
result.resetInSec,
|
|
result.resetInSec,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -647,7 +671,7 @@ export async function handler(
|
|
|
})
|
|
})
|
|
|
if (result.status === "rate-limited")
|
|
if (result.status === "rate-limited")
|
|
|
throw new SubscriptionUsageLimitError(
|
|
throw new SubscriptionUsageLimitError(
|
|
|
- `Subscription quota exceeded. You can continue using free models.`,
|
|
|
|
|
|
|
+ t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
|
|
result.resetInSec,
|
|
result.resetInSec,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -662,7 +686,7 @@ export async function handler(
|
|
|
})
|
|
})
|
|
|
if (result.status === "rate-limited")
|
|
if (result.status === "rate-limited")
|
|
|
throw new SubscriptionUsageLimitError(
|
|
throw new SubscriptionUsageLimitError(
|
|
|
- `Subscription quota exceeded. You can continue using free models.`,
|
|
|
|
|
|
|
+ t("zen.api.error.subscriptionQuotaExceededUseFreeModels"),
|
|
|
result.resetInSec,
|
|
result.resetInSec,
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
@@ -675,14 +699,10 @@ export async function handler(
|
|
|
|
|
|
|
|
// Validate pay as you go billing
|
|
// Validate pay as you go billing
|
|
|
const billing = authInfo.billing
|
|
const billing = authInfo.billing
|
|
|
- if (!billing.paymentMethodID)
|
|
|
|
|
- throw new CreditsError(
|
|
|
|
|
- `No payment method. Add a payment method here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
|
|
|
|
- )
|
|
|
|
|
- if (billing.balance <= 0)
|
|
|
|
|
- throw new CreditsError(
|
|
|
|
|
- `Insufficient balance. Manage your billing here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ const billingUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/billing`
|
|
|
|
|
+ const membersUrl = `https://opencode.ai/workspace/${authInfo.workspaceID}/members`
|
|
|
|
|
+ if (!billing.paymentMethodID) throw new CreditsError(t("zen.api.error.noPaymentMethod", { billingUrl }))
|
|
|
|
|
+ if (billing.balance <= 0) throw new CreditsError(t("zen.api.error.insufficientBalance", { billingUrl }))
|
|
|
|
|
|
|
|
const now = new Date()
|
|
const now = new Date()
|
|
|
const currentYear = now.getUTCFullYear()
|
|
const currentYear = now.getUTCFullYear()
|
|
@@ -696,7 +716,10 @@ export async function handler(
|
|
|
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
|
currentMonth === billing.timeMonthlyUsageUpdated.getUTCMonth()
|
|
|
)
|
|
)
|
|
|
throw new MonthlyLimitError(
|
|
throw new MonthlyLimitError(
|
|
|
- `Your workspace has reached its monthly spending limit of $${billing.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/billing`,
|
|
|
|
|
|
|
+ t("zen.api.error.workspaceMonthlyLimitReached", {
|
|
|
|
|
+ amount: billing.monthlyLimit,
|
|
|
|
|
+ billingUrl,
|
|
|
|
|
+ }),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if (
|
|
if (
|
|
@@ -708,7 +731,10 @@ export async function handler(
|
|
|
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
|
currentMonth === authInfo.user.timeMonthlyUsageUpdated.getUTCMonth()
|
|
|
)
|
|
)
|
|
|
throw new UserLimitError(
|
|
throw new UserLimitError(
|
|
|
- `You have reached your monthly spending limit of $${authInfo.user.monthlyLimit}. Manage your limits here: https://opencode.ai/workspace/${authInfo.workspaceID}/members`,
|
|
|
|
|
|
|
+ t("zen.api.error.userMonthlyLimitReached", {
|
|
|
|
|
+ amount: authInfo.user.monthlyLimit,
|
|
|
|
|
+ membersUrl,
|
|
|
|
|
+ }),
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
return "balance"
|
|
return "balance"
|
|
@@ -716,7 +742,7 @@ export async function handler(
|
|
|
|
|
|
|
|
function validateModelSettings(authInfo: AuthInfo) {
|
|
function validateModelSettings(authInfo: AuthInfo) {
|
|
|
if (!authInfo) return
|
|
if (!authInfo) return
|
|
|
- if (authInfo.isDisabled) throw new ModelError("Model is disabled")
|
|
|
|
|
|
|
+ if (authInfo.isDisabled) throw new ModelError(t("zen.api.error.modelDisabled"))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|
|
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
|