chore: merge dev into v2 (#34788)

Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Affan Ali <93028901+affanali2k3@users.noreply.github.com>
Co-authored-by: affanali2k3 <affanalikhanxx@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 <little-frank@opencord.local>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: Ben Guthrie <benjee.012@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
Co-authored-by: Max Anderson <max.a.anderson95@gmail.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: runvip <164729189+runvip@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Julian Coy <julian@ex-machina.co>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
This commit is contained in:
James Long 2026-07-01 17:12:00 -04:00 committed by GitHub
commit 8c94e9005f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
590 changed files with 15772 additions and 5530 deletions

View file

@ -327,37 +327,6 @@ body {
}
}
[data-component="desktop-app-banner"] {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 32px;
[data-slot="badge"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
font-weight: 500;
padding: 4px 8px;
line-height: 1;
flex-shrink: 0;
}
[data-slot="content"] {
display: flex;
align-items: center;
gap: 1ch;
}
[data-slot="text"] {
color: var(--color-text-strong);
line-height: 1.4;
@media (max-width: 30.625rem) {
display: none;
}
}
}
[data-slot="hero-copy"] {
img {
margin-bottom: 24px;
@ -663,10 +632,6 @@ body {
fill: var(--color-text-strong);
}
[data-bar][data-kind="promo"] {
fill: color-mix(in srgb, var(--bar-go) 50%, transparent);
}
[data-val] {
fill: var(--color-text-strong);
font-size: 13px;

View file

@ -64,10 +64,10 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.2", name: "GLM-5.2", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "110ms" },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "210ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "240ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "250ms" },
{ id: "minimax-m3", name: "MiniMax M3 (3x usage)", req: 9600, baseReq: 3200, d: "280ms" },
{ id: "minimax-m3", name: "MiniMax M3", req: 3200, d: "210ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" },
{ id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@ -157,24 +157,12 @@ function LimitsGraph(props: { href: string }) {
<rect
x={left}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.baseReq ?? m.req)) - left)}
width={Math.max(0, x(ratio(m.req)) - left)}
height={bh}
data-bar
data-kind="go"
data-model={m.id}
data-segment={m.baseReq ? "base" : undefined}
/>
{m.baseReq && (
<rect
x={x(ratio(m.baseReq)) + 2}
y={gy(i()) - bh / 2}
width={Math.max(0, x(ratio(m.req)) - x(ratio(m.baseReq)) - 2)}
height={bh}
data-bar
data-kind="promo"
data-model={m.id}
/>
)}
</g>
)}
</For>
@ -264,12 +252,6 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
<span data-slot="badge">{i18n.t("home.banner.badge")}</span>
<div data-slot="content">
<span data-slot="text">{i18n.t("go.banner.text")}</span>
</div>
</div>
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={goLogoLight} alt="" />
<img data-slot="zen logo dark" src={goLogoDark} alt="" />

View file

@ -3,7 +3,7 @@ import { z } from "zod"
import { Resource } from "@opencode-ai/console-resource"
import { safeEqual } from "@opencode-ai/console-core/util/crypto.js"
const DISCORD_ALERT_ROLE_ID = "1511795723262365887"
const DISCORD_ALERT_ROLE_ID = "1520924666359713863"
const basePayload = z.object({
name: z.string().optional(),

View file

@ -75,6 +75,40 @@
}
}
[data-slot="providers-section"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-muted);
[data-slot="providers-header"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h3 {
color: var(--color-text);
font-size: var(--font-size-lg);
font-weight: 600;
line-height: 1.2;
margin: 0;
}
p {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
}
}
[data-slot="setting-row"] {
margin-top: 0;
}
}
[data-slot="toggle-label"] {
position: relative;
display: inline-block;

View file

@ -5,7 +5,9 @@ import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
import { LiteData } from "@opencode-ai/console-core/lite.js"
import { withActor } from "~/context/auth.withActor"
@ -16,6 +18,8 @@ import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { createReferralFromCookie } from "~/lib/referral-invite"
import { getRequestEvent } from "solid-js/web"
import { countryFromRequest } from "~/lib/request-country"
import { IconAlipay, IconUpi } from "~/component/icon"
@ -34,9 +38,11 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
timeCreated: LiteTable.timeCreated,
lite: BillingTable.lite,
region: WorkspaceTable.region,
})
.from(BillingTable)
.innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID))
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, BillingTable.workspaceID))
.where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted)))
.then((r) => r[0]),
)
@ -48,6 +54,8 @@ export const queryLiteSubscription = query(async (workspaceID: string) => {
return {
mine,
useBalance: row.lite?.useBalance ?? false,
region:
row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })),
rollingUsage: Subscription.analyzeRollingUsage({
limit: limits.rollingLimit,
window: limits.rollingWindow,
@ -128,6 +136,24 @@ const setLiteUseBalance = action(async (form: FormData) => {
)
}, "setLiteUseBalance")
const setGoProviderRouting = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID") as string | null
if (!workspaceID) return { error: formError.workspaceRequired }
const useChinaProviders = (form.get("useChinaProviders") as string | null) === "true"
return json(
await withActor(
() =>
Workspace.update({ region: useChinaProviders ? ["us", "eu", "sg"] : ["us", "eu", "sg", "cn"] })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: queryLiteSubscription.key },
)
}, "go.providerRouting.set")
function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) {
const i18n = useI18n()
@ -159,6 +185,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
const checkoutAction = useAction(createLiteCheckoutUrl)
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const providerRoutingSubmission = useSubmission(setGoProviderRouting)
const [store, setStore] = createStore({
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
@ -232,6 +259,28 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
<span></span>
</label>
</form>
{/*
<div data-slot="providers-section">
<div data-slot="providers-header">
<h3>{i18n.t("workspace.lite.providers.title")}</h3>
<p>{i18n.t("workspace.lite.providers.description")}</p>
</div>
<form action={setGoProviderRouting} method="post" data-slot="setting-row">
<p>{i18n.t("workspace.lite.providers.useChina")}</p>
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="useChinaProviders" value={sub().region.includes("cn") ? "true" : "false"} />
<label data-slot="toggle-label">
<input
type="checkbox"
checked={sub().region.includes("cn")}
disabled={providerRoutingSubmission.pending}
onChange={(e) => e.currentTarget.form?.requestSubmit()}
/>
<span></span>
</label>
</form>
</div>
*/}
</section>
)}
</Show>

View file

@ -43,6 +43,7 @@ const updateWorkspace = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getWorkspaceInfo.key },
)
}, "workspace.update")

View file

@ -3,6 +3,7 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RegionError extends Error {}
class LimitError extends Error {
retryAfter?: number

View file

@ -21,6 +21,7 @@ import {
MonthlyLimitError,
UserLimitError,
ModelError,
RegionError,
RateLimitError,
FreeUsageLimitError,
GoUsageLimitError,
@ -49,6 +50,8 @@ import { createModelTpmLimiter } from "./modelTpmLimiter"
import { createModelTpsLimiter } from "./modelTpsLimiter"
import { createProviderBudgetTracker } from "./providerBudgetTracker"
import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher"
import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { countryFromRequest } from "~/lib/request-country"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@ -125,6 +128,24 @@ export async function handler(
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey)
const allowedRegions = authInfo?.region
? authInfo.region
: await (async () => {
if (!authInfo) return
return Actor.provide("system", { workspaceID: authInfo.workspaceID }, () =>
Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }),
)
})()
/*
if (true) {
if (!allowedRegions?.includes("unavailable"))
throw new RegionError(
t("zen.api.error.regionNotAllowed", {
consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`,
}),
)
}
*/
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyProvider = await stickyTracker?.get()
@ -137,7 +158,7 @@ export async function handler(
const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
)
const providerBudgetUsage = await providerBudgetTracker?.check()
const providerBudget = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(
@ -151,7 +172,7 @@ export async function handler(
stickyProvider,
modelTpmLimits,
modelTpsLimits,
providerBudgetUsage,
providerBudget,
)
validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo)
@ -201,7 +222,10 @@ export async function handler(
if (v === "$model") return headers.set(k, model)
if (v === "$request") return headers.set(k, requestId)
if (v === "$project") return headers.set(k, projectId)
if (v === "$workspace" && authInfo?.workspaceID) return headers.set(k, authInfo.workspaceID)
if (v === "$workspace") {
if (authInfo?.workspaceID) headers.set(k, authInfo.workspaceID)
return
}
headers.set(k, v)
})
headers.delete("host")
@ -213,6 +237,9 @@ export async function handler(
return headers
})(),
body: reqBody,
// Propagate caller disconnects to the upstream provider request so
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
})
if (providerInfo.id.startsWith("console.")) {
@ -281,7 +308,7 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo)
@ -308,9 +335,10 @@ export async function handler(
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser()
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
reader = res.body?.getReader()
const decoder = new TextDecoder()
const encoder = new TextEncoder()
@ -342,7 +370,11 @@ export async function handler(
timestampLastByte,
usageInfo,
)
await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await providerBudgetTracker?.track(
providerInfo.id,
providerInfo.budgetPriority,
costInfo.totalCostInCent,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo)
@ -392,6 +424,11 @@ export async function handler(
return pump()
},
cancel() {
// When the downstream caller stops reading, release the upstream
// response body instead of keeping the provider/inference stream alive.
return reader?.cancel()
},
})
return new Response(stream, {
status: resStatus,
@ -399,6 +436,15 @@ export async function handler(
headers: resHeaders,
})
} catch (error: any) {
// The caller disconnected before we finished. Because the outbound provider
// request shares input.request.signal, an aborted caller surfaces here as an
// AbortError. There is no client left to receive a body, so skip the error
// metric and 500 and return a quiet client-closed response.
if (input.request.signal.aborted || error?.name === "AbortError") {
logger.debug("REQUEST ABORTED BY CALLER")
return new Response(null, { status: 499 })
}
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
@ -412,6 +458,15 @@ export async function handler(
} catch {}
}
if (error instanceof RegionError)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 403 },
)
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
error instanceof AuthError ||
@ -509,7 +564,12 @@ export async function handler(
stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudgetUsage: Record<string, number> | undefined,
providerBudget:
| {
qualify: (providerId: string, priority: number) => boolean
prefer: (providerId: string, priority: number) => boolean
}
| undefined,
) {
const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it
@ -527,67 +587,69 @@ export async function handler(
}))
}
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetMode !== "fill") return true
const budget = zenData.providers[provider.id]?.budget
if (budget === undefined) return false
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use fallback provider if max retries reached
const fallbackProvider = allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
if (retry.retryCount === MAX_FAILOVER_RETRIES) return fallbackProvider
// Use the last 4 characters of session ID to select a provider
let h = 0
const l = stickyId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]
let topPriority = Infinity
const providers = allProviders
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
if (provider.budgetPriority === undefined) return true
if (!providerBudget) return true
return providerBudget.qualify(provider.id, provider.budgetPriority)
})
.filter((provider) => {
if (!provider.tpmLimit) return true
const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0
return usage < provider.tpmLimit * 1_000_000
})
.filter((provider) => {
if (!provider.tpsGoal) return true
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify
return !isLowTps
})
.map((provider) => {
topPriority = Math.min(topPriority, provider.priority)
return provider
})
.filter((p) => p.priority <= topPriority)
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// sticky provider does not exist => use selected provider
if (!stickyProviderId) return provider
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider
// Use the last 4 characters of session ID to select a provider
let h = 0
const l = stickyId.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0] ?? fallbackProvider
// stick provider exists + selected provider is API type => use sticky provider
if (!provider.tpsGoal) return stickProvider
// sticky provider does not exist => use selected provider
if (!stickyProviderId) return provider
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider
// stick provier exists + selected provider is GPU type + GPU not idle => use selected provider
const preferBudgetProvider =
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority)
const preferTpsProvider = (() => {
if (!provider.tpsGoal) return false
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
qualify: 0,
unqualify: 0,
}
if (tps.qualify <= tps.unqualify * 3) return stickProvider
return tps.qualify > tps.unqualify * 3
})()
return provider
}
if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
// fallback provider
return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider)
return provider
})()
if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable"))
@ -624,7 +686,10 @@ export async function handler(
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
workspace: {
id: WorkspaceTable.id,
region: WorkspaceTable.region,
},
billing: {
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
@ -702,13 +767,13 @@ export async function handler(
if (
modelInfo.id.startsWith("alpha-") &&
Resource.App.stage === "production" &&
!ADMIN_WORKSPACES.includes(data.workspaceID)
!ADMIN_WORKSPACES.includes(data.workspace.id)
)
throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id }))
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
workspace: data.workspace.id,
user_id: data.user.id,
...(() => {
if (data.billing.subscription)
@ -725,13 +790,14 @@ export async function handler(
return {
apiKeyId: data.apiKey,
workspaceID: data.workspaceID,
workspaceID: data.workspace.id,
region: data.workspace.region,
billing: data.billing,
user: data.user,
black: data.black,
lite: data.lite,
provider: data.provider,
isFree: ADMIN_WORKSPACES.includes(data.workspaceID),
isFree: ADMIN_WORKSPACES.includes(data.workspace.id),
isDisabled: !!data.timeDisabled,
}
}

View file

@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
)
const now = Date.now()
const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60 * 1000))
const prevInterval = toInterval(new Date(now - 60_000))
return {
check: async () => {

View file

@ -2,50 +2,148 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis"
import { logger } from "./logger"
// Per-provider, per-minute budget with priorities. The budget belongs to a
// provider and is shared across every model that routes to it. Each model's
// provider entry carries a `budgetPriority`: priority 1 ("always") routes
// unconditionally, while higher priorities ("fill") only route while the provider's
// current-minute spend through that priority is still under budget.
//
// Spend is tracked per (provider, priority, minute) so a fill priority can yield its
// leftover headroom to the next priority down. The previous minute is also read so
// higher priorities can reserve the next minute's budget first.
export function createProviderBudgetTracker(
providers: {
id: string
budget?: number
budgetContribution?: number
budgetMode?: "always" | "fill"
budgetPriority?: number
}[],
) {
const tracked = providers.filter(
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
(provider) =>
provider.budget !== undefined &&
provider.budgetContribution !== undefined &&
provider.budgetPriority !== undefined,
)
if (tracked.length === 0) return undefined
const interval = new Date()
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
const intervalAt = (date: Date) =>
date
.toISOString()
.replace(/[^0-9]/g, "")
.substring(0, 12)
const now = new Date()
const currInterval = intervalAt(now)
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
const redis = getRedis()
const keys = Object.fromEntries(
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
)
let budgetUsage: Record<string, number> = {}
const key = (providerId: string, priority: number, withInterval: string) =>
buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval)
const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = provider.budget!
return acc
}, {})
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!)
return acc
}, {})
// Effective budget in micro-cents per provider/priority, computed in check()
// from the configured budget minus previous-minute usage from higher priorities.
let effectiveBudget: Record<string, Record<number, number>> = {}
// Cumulative current-minute spend through each priority, per provider.
let spentThroughPriority: Record<string, Record<number, number>> = {}
let previousSpentThroughPriority: Record<string, Record<number, number>> = {}
return {
// Returns whether a provider at a given priority still has budget headroom.
// Priority 1 always qualifies; higher priorities qualify only while everything through
// the current priority hasn't already filled the previous-minute adjusted
// budget.
check: async () => {
const ids = tracked.map((provider) => provider.id)
if (ids.length === 0) return {}
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
return budgetUsage
const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) =>
Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [
{ providerId, priority, interval: currInterval, prev: false },
{ providerId, priority, interval: prevInterval, prev: true },
]),
)
const values = await redis.mget<(string | number | null)[]>(
reads.map((r) => key(r.providerId, r.priority, r.interval)),
)
const current: Record<string, Record<number, number>> = {}
const previous: Record<string, Record<number, number>> = {}
reads.forEach((r, index) => {
const amount = Number(values[index] ?? 0)
if (r.prev) {
previous[r.providerId] ??= {}
previous[r.providerId][r.priority] = amount
return
}
current[r.providerId] ??= {}
current[r.providerId][r.priority] = amount
})
effectiveBudget = {}
spentThroughPriority = {}
previousSpentThroughPriority = {}
Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return
const budget = centsToMicroCents(providerBudget * 100)
let currentRunning = 0
let previousRunning = 0
effectiveBudget[providerId] = {}
spentThroughPriority[providerId] = {}
previousSpentThroughPriority[providerId] = {}
Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => {
currentRunning += current[providerId]?.[priority] ?? 0
effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning)
previousRunning += previous[providerId]?.[priority] ?? 0
spentThroughPriority[providerId][priority] = currentRunning
previousSpentThroughPriority[providerId][priority] = previousRunning
})
})
return {
// Priority 1 is unconditional. Higher priorities gate on the spend through
// the current priority against the effective budget.
qualify: (providerId: string, priority: number) => {
if (priority <= 1) return true
const budget = effectiveBudget[providerId]?.[priority]
if (budget === undefined) return false
const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0
return spentThroughCurrentPriority < budget
},
prefer: (providerId: string, priority: number) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return false
const budget = centsToMicroCents(providerBudget * 100)
const previousUsage = previousSpentThroughPriority[providerId]?.[priority]
if (previousUsage === undefined) return false
return previousUsage < budget * 0.8
},
}
},
track: async (provider: string, costInCent: number) => {
const config = tracked.find((item) => item.id === provider)
track: async (provider: string, priority: number | undefined, costInCent: number) => {
if (priority === undefined) return
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
if (!config) return
if (config.budgetContribution === undefined) return
const cost = centsToMicroCents(costInCent * config.budgetContribution)
if (cost <= 0) return
const redisKey = key(provider, priority, currInterval)
const pipeline = redis.pipeline()
pipeline.incrby(keys[provider], cost)
pipeline.expire(keys[provider], 120)
pipeline.incrby(redisKey, cost)
// Keep two minutes so the previous interval is readable for budget adjustment.
pipeline.expire(redisKey, 120)
await pipeline.exec()
logger.metric({
"provider.budget_usage": budgetUsage[provider] + cost,
"model.budget_usage": cost,
"provider.budget_usage": cost,
"provider.budget_priority": priority,
})
},
}