mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
feat(console): block reported model providers
This commit is contained in:
@@ -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;
|
||||
+3171
File diff suppressed because it is too large
Load Diff
@@ -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}`)
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user