feat(console): block reported model providers

This commit is contained in:
Frank
2026-07-20 19:28:59 +00:00
parent 849c2598ab
commit 40d3787d62
9 changed files with 3282 additions and 1 deletions
+2
View File
@@ -374,6 +374,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.modelAccessBlocked":
"{{models}} model access has been disabled for this workspace following a report from {{provider}}. Contact support@opencode.ai for help.",
"zen.api.error.regionNotAllowed":
"This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}",
"zen.api.error.trialEnded":
@@ -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 ModelAccessError extends Error {}
export class RegionError extends Error {}
class LimitError extends Error {
@@ -21,6 +21,7 @@ import {
MonthlyLimitError,
UserLimitError,
ModelError,
ModelAccessError,
RegionError,
RateLimitError,
FreeUsageLimitError,
@@ -52,6 +53,7 @@ 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"
import { ModelAccess } from "@opencode-ai/console-core/model-access.js"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -128,6 +130,7 @@ export async function handler(
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey)
validateModelAccess(authInfo, modelInfo)
const allowedRegions = authInfo?.region
? authInfo.region
: await (async () => {
@@ -466,7 +469,7 @@ export async function handler(
} catch {}
}
if (error instanceof RegionError)
if (error instanceof RegionError || error instanceof ModelAccessError)
return new Response(
JSON.stringify({
type: "error",
@@ -697,6 +700,7 @@ export async function handler(
workspace: {
id: WorkspaceTable.id,
region: WorkspaceTable.region,
blockedModelProviders: WorkspaceTable.blocked_model_providers,
},
billing: {
balance: BillingTable.balance,
@@ -800,6 +804,7 @@ export async function handler(
apiKeyId: data.apiKey,
workspaceID: data.workspace.id,
region: data.workspace.region,
blockedModelProviders: data.workspace.blockedModelProviders,
billing: data.billing,
user: data.user,
black: data.black,
@@ -988,6 +993,13 @@ export async function handler(
return "balance"
}
function validateModelAccess(authInfo: AuthInfo, modelInfo: ModelInfo) {
if (!authInfo) return
if (!ModelAccess.blocked(modelInfo.id, authInfo.blockedModelProviders)) return
const provider = ModelAccess.provider(modelInfo.id)!
throw new ModelAccessError(t("zen.api.error.modelAccessBlocked", ModelAccess.label(provider)))
}
function validateModelSettings(billingSource: BillingSource, authInfo: AuthInfo) {
if (billingSource === "lite") return
if (billingSource === "anonymous") return
@@ -0,0 +1 @@
ALTER TABLE `workspace` ADD `blocked_model_providers` json;
@@ -0,0 +1,46 @@
import { parseArgs } from "node:util"
import { Database, eq } from "../src/drizzle/index.js"
import { ModelAccess } from "../src/model-access.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
const args = parseArgs({
args: process.argv.slice(2),
options: {
workspace: { type: "string", short: "w" },
provider: { type: "string", short: "p" },
unblock: { type: "boolean", default: false },
},
})
if (!args.values.workspace || !args.values.provider) {
console.error("Usage: model-access.ts --workspace <workspaceID> --provider <anthropic|openai> [--unblock]")
process.exit(1)
}
const provider = ModelAccess.parseProvider(args.values.provider)
const workspace = await Database.use((tx) =>
tx
.select({ block: WorkspaceTable.blocked_model_providers })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, args.values.workspace!))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error(`Workspace ${args.values.workspace} not found`)
process.exit(1)
}
const current = workspace.block ?? []
const block = args.values.unblock
? current.filter((item) => item !== provider)
: Array.from(new Set([...current, provider]))
await Database.use((tx) =>
tx
.update(WorkspaceTable)
.set({ blocked_model_providers: block })
.where(eq(WorkspaceTable.id, args.values.workspace!)),
)
console.log(`${args.values.unblock ? "Unblocked" : "Blocked"} ${provider} models for ${args.values.workspace}`)
+25
View File
@@ -0,0 +1,25 @@
export namespace ModelAccess {
export const providers = ["anthropic", "openai"] as const
export type Provider = (typeof providers)[number]
export function parseProvider(value: string): Provider {
if (providers.includes(value as Provider)) return value as Provider
throw new Error(`Unsupported model provider: ${value}`)
}
export function provider(model: string): Provider | undefined {
if (model.startsWith("claude")) return "anthropic"
if (model.startsWith("gpt")) return "openai"
}
export function blocked(model: string, providers: Provider[] | null) {
const value = provider(model)
if (!value) return false
return providers?.includes(value) ?? false
}
export function label(provider: Provider) {
if (provider === "anthropic") return { provider: "Anthropic", models: "Claude" }
return { provider: "OpenAI", models: "GPT" }
}
}
@@ -1,5 +1,6 @@
import { json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types"
import type { ModelAccess } from "../model-access"
export const WorkspaceTable = mysqlTable(
"workspace",
@@ -8,6 +9,7 @@ export const WorkspaceTable = mysqlTable(
slug: varchar("slug", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(),
region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(),
blocked_model_providers: json().$type<ModelAccess.Provider[]>(),
...timestamps,
},
(table) => [uniqueIndex("slug").on(table.slug)],
@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { ModelAccess } from "../src/model-access"
describe("ModelAccess", () => {
test("maps Claude and GPT model IDs to their reporting providers", () => {
expect(ModelAccess.provider("claude-opus-4-1")).toBe("anthropic")
expect(ModelAccess.provider("gpt-5.1-codex")).toBe("openai")
expect(ModelAccess.provider("gemini-2.5-pro")).toBeUndefined()
})
test("blocks only models from a blocked provider", () => {
expect(ModelAccess.blocked("claude-sonnet-4", ["anthropic"])).toBe(true)
expect(ModelAccess.blocked("gpt-5", ["anthropic"])).toBe(false)
expect(ModelAccess.blocked("claude-sonnet-4", null)).toBe(false)
})
test("provides names for the API error", () => {
expect(ModelAccess.label("anthropic")).toEqual({ provider: "Anthropic", models: "Claude" })
expect(ModelAccess.label("openai")).toEqual({ provider: "OpenAI", models: "GPT" })
})
})