计费和认证系统
OpenCode 的计费和认证系统支持多种访问模式:匿名试用、付费订阅、BYOK(Bring Your Own Key)等。系统通过分层验证、IP 限制、余额扣费等机制实现灵活的资源管理。
目录
系统架构
访问模式
1. 匿名访问
特点:无需登录,基于 IP 限制免费使用
工作原理:
// packages/console/app/src/routes/zen/util/handler.ts:400-405
if (!apiKey || apiKey === "public") {
if (modelInfo.allowAnonymous) return
throw new AuthError("Missing API key.")
}
关键配置:
allowAnonymous: true- 模型必须配置为允许匿名访问trial.limit- 试用配额(Token 数量)- IP 地址追踪 - 使用
IpTable存储每个 IP 的使用量
流程:
用户未登录 → 检查模型是否允许匿名 → 检查 IP 使用量
↓
允许访问(未超限额)
↓
拒绝访问(已超限额)
2. 已认证访问
API Key 认证:
// packages/console/app/src/routes/zen/util/handler.ts:407-464
const data = await Database.use((tx) =>
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
billing: { /* ... */ },
user: { /* ... */ },
subscription: { /* ... */ },
provider: { /* ... */ },
})
.from(KeyTable)
.innerJoin(WorkspaceTable, ...)
.innerJoin(BillingTable, ...)
.leftJoin(SubscriptionTable, ...)
.leftJoin(ProviderTable, ...) // BYOK
.where(eq(KeyTable.key, apiKey))
)
返回信息:
apiKeyId- API Key IDworkspaceID- 工作区 IDbilling- 计费信息(余额、月度限制等)user- 用户信息(用户级限制)subscription- 订阅信息provider- BYOK 提供商凭证isFree- 是否为免费工作区isDisabled- 模型是否被禁用
3. BYOK(Bring Your Own Key)
允许用户使用自己的 API Key(如 OpenAI、Anthropic),不计入 OpenCode 账户。
工作原理:
// packages/console/app/src/routes/zen/util/handler.ts:487-489
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
if (!authInfo) return "anonymous"
if (authInfo.provider?.credentials) return "free" // BYOK
if (authInfo.isFree) return "free"
if (modelInfo.allowAnonymous) return "free"
// ...
}
计费:成本为 0,不扣费
// packages/console/app/src/routes/zen/util/handler.ts:656
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
4. 免费工作区
特定 workspace ID 标记为免费:
// packages/console/app/src/routes/zen/util/handler.ts:56-59
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
]
// 在 authenticate 函数中
return {
// ...
isFree: FREE_WORKSPACES.includes(data.workspaceID),
}
计费:余额不变
// packages/console/app/src/routes/zen/util/handler.ts:720-722
balance: authInfo.isFree ? sql`${BillingTable.balance} - ${0}` : sql`${BillingTable.balance} - ${cost}`
认证流程
完整认证流程
async function authenticate(modelInfo: ModelInfo) {
const apiKey = parseApiKey(headers)
// 1. 检查匿名访问
if (!apiKey || apiKey === "public") {
if (modelInfo.allowAnonymous) return
throw new AuthError("Missing API key.")
}
// 2. 数据库查询 API Key
const data = await Database.use((tx) =>
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
billing: {
/* ... */
},
user: {
/* ... */
},
subscription: {
/* ... */
},
provider: {
/* ... */
}, // BYOK
timeDisabled: ModelTable.timeCreated, // 模型是否被禁用
})
.from(KeyTable)
.where(eq(KeyTable.key, apiKey)),
)
if (!data) throw new AuthError("Invalid API key.")
return {
apiKeyId: data.apiKey,
workspaceID: data.workspaceID,
billing: data.billing,
user: data.user,
subscription: data.subscription,
provider: data.provider, // BYOK 凭证
isFree: FREE_WORKSPACES.includes(data.workspaceID),
isDisabled: !!data.timeDisabled,
}
}
计费源验证
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
if (!authInfo) return "anonymous"
if (authInfo.provider?.credentials) return "free" // BYOK
if (authInfo.isFree) return "free" // 免费工作区
if (modelInfo.allowAnonymous) return "free"
// 订阅验证
if (authInfo.billing.subscription && authInfo.subscription) {
const sub = authInfo.subscription
const plan = authInfo.billing.subscription.plan
// 检查周限制
if (sub.fixedUsage && sub.timeFixedUpdated) {
const result = Black.analyzeWeeklyUsage({
plan,
usage: sub.fixedUsage,
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.retryAfter,
)
}
// 检查滚动限制
if (sub.rollingUsage && sub.timeRollingUpdated) {
const result = Black.analyzeRollingUsage({ /* ... */ })
if (result.status === "rate-limited")
throw new SubscriptionError(...)
}
return "subscription"
}
// 余额验证
const billing = authInfo.billing
if (!billing.paymentMethodID)
throw new CreditsError("No payment method.")
if (billing.balance <= 0)
throw new CreditsError("Insufficient balance.")
// 月度限制验证
if (billing.monthlyLimit && billing.monthlyUsage >= limit)
throw new MonthlyLimitError("Monthly spending limit reached.")
// 用户级月度限制验证
if (authInfo.user.monthlyLimit && authInfo.user.monthlyUsage >= limit)
throw new UserLimitError("User monthly limit reached.")
return "balance"
}
计费机制
计费源类型
| 计费源 | 扣费方式 | 说明 |
|---|---|---|
anonymous | 不扣费 | 匿名试用 |
free | 不扣费 | 免费工作区 / BYOK |
subscription | 更新订阅配额 | 订阅用户 |
balance | 扣除余额 | 按量付费用户 |
成本计算
async function trackUsage(
authInfo: AuthInfo,
modelInfo: ModelInfo,
providerInfo: ProviderInfo,
billingSource: string,
usageInfo: UsageInfo,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
usageInfo
// 选择定价(是否超过 200K 上下文)
const modelCost =
modelInfo.cost200K &&
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
? modelInfo.cost200K
: modelInfo.cost
// 计算各部分成本
const inputCost = modelCost.input * inputTokens
const outputCost = modelCost.output * outputTokens
const reasoningCost = modelCost.output * (reasoningTokens ?? 0)
const cacheReadCost = modelCost.cacheRead * (cacheReadTokens ?? 0)
const cacheWrite5mCost = modelCost.cacheWrite5m * (cacheWrite5mTokens ?? 0)
const cacheWrite1hCost = modelCost.cacheWrite1h * (cacheWrite1hTokens ?? 0)
const totalCostInCent = inputCost + outputCost + reasoningCost + cacheReadCost + cacheWrite5mCost + cacheWrite1hCost
// 匿名用户不扣费
if (billingSource === "anonymous") return
// BYOK 成本为 0
const cost = authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
// 更新数据库
await Database.use((db) =>
Promise.all([
// 插入使用记录
db.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
model: modelInfo.id,
provider: providerInfo.id,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cost,
keyID: authInfo.apiKeyId,
}),
// 更新 API Key 使用时间
db.update(KeyTable).set({ TimeUsed: now() }),
// 根据计费源更新不同的表
billingSource === "subscription" ? updateSubscriptionUsage(cost, plan) : updateBalanceUsage(cost),
]),
)
}
余额扣费
db.update(BillingTable).set({
balance: authInfo.isFree
? sql`${BillingTable.balance} - ${0}` // 免费,不扣费
: sql`${BillingTable.balance} - ${cost}`, // 正常扣费
monthlyUsage: sql`
CASE
WHEN MONTH(timeMonthlyUsageUpdated) = MONTH(now()) AND
YEAR(timeMonthlyUsageUpdated) = YEAR(now())
THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
用户级限制
db.update(UserTable).set({
monthlyUsage: sql`
CASE
WHEN MONTH(timeMonthlyUsageUpdated) = MONTH(now()) AND
YEAR(timeMonthlyUsageUpdated) = YEAR(now())
THEN ${UserTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
订阅配额更新
// 周限制
db.update(SubscriptionTable).set({
fixedUsage: sql`
CASE
WHEN timeFixedUpdated >= weekStart
THEN ${SubscriptionTable.fixedUsage} + ${cost}
ELSE ${cost}
END
`,
timeFixedUpdated: sql`now()`,
})
// 滚动限制
const rollingWindowSeconds = black.rollingWindow * 3600
db.update(SubscriptionTable).set({
rollingUsage: sql`
CASE
WHEN UNIX_TIMESTAMP(timeRollingUpdated) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds}
THEN ${SubscriptionTable.rollingUsage} + ${cost}
ELSE ${cost}
END
`,
timeRollingUpdated: sql`
CASE
WHEN UNIX_TIMESTAMP(timeRollingUpdated) >= UNIX_TIMESTAMP(now()) - ${rollingWindowSeconds}
THEN timeRollingUpdated
ELSE now()
END
`,
})
试用限制
IP 级别试用限制
// packages/console/app/src/routes/zen/util/trialLimiter.ts:6-49
export function createTrialLimiter(trial: ZenData.Trial | undefined, ip: string, client: string) {
if (!trial) return
if (!ip) return
// 获取限制(可能按客户端类型区分)
const limit =
trial.limits.find((limit) => limit.client === client)?.limit ??
trial.limits.find((limit) => limit.client === undefined)?.limit
if (!limit) return
let _isTrial: boolean
return {
// 检查是否仍在试用期内
isTrial: async () => {
const data = await Database.use((tx) =>
tx
.select({ usage: IpTable.usage })
.from(IpTable)
.where(eq(IpTable.ip, ip))
.then((rows) => rows[0]),
)
_isTrial = (data?.usage ?? 0) < limit
return _isTrial
},
// 追踪使用量
track: async (usageInfo: UsageInfo) => {
if (!_isTrial) return
const usage =
usageInfo.inputTokens +
usageInfo.outputTokens +
(usageInfo.reasoningTokens ?? 0) +
(usageInfo.cacheReadTokens ?? 0) +
(usageInfo.cacheWrite5mTokens ?? 0) +
(usageInfo.cacheWrite1hTokens ?? 0)
await Database.use((tx) =>
tx
.insert(IpTable)
.values({ ip, usage })
.onDuplicateKeyUpdate({
set: { usage: sql`${IpTable.usage} + ${usage}` },
}),
)
},
}
}
模型试用配置
// packages/console/core/src/model.ts:12-20
const TrialSchema = z.object({
provider: z.string(), // 使用的 provider ID
1limits: z.array(
z.object({
limit: z.number(), // Token 限制
client: z.enum(["cli", "desktop"]).optional(), // 可选:按客户端类型区分
}),
),
})
使用流程
// packages/console/app/src/routes/zen/util/handler.ts:80-84
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial()
// 在 selectProvider 中使用试用 provider
if (isTrial) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
}
// 请求完成后追踪使用量
await trialLimiter?.track(tokensInfo)
数据库表结构
BillingTable
CREATE TABLE billing (
workspaceID VARCHAR PRIMARY KEY,
customerID VARCHAR, -- Stripe 客户 ID
paymentMethodID VARCHAR, -- 支付方式 ID
balance BIGINT, -- 余额(微美分)
monthlyUsage BIGINT, -- 月度使用量
timeMonthlyUsageUpdated DATETIME,
monthlyLimit INT, -- 月度限制(美元)
reload BOOLEAN, -- 是否自动充值
reloadAmount INT, -- 充值金额(美元)
reloadTrigger INT, -- 充值触发点(美元)
timeReloadLockedTill DATETIME,
reloadError TEXT,
timeReloadError DATETIME,
subscription JSON, -- 订阅信息
FOREIGN KEY (workspaceID) REFERENCES workspace(id)
)
SubscriptionTable
CREATE TABLE subscription (
workspaceID VARCHAR,
userID VARCHAR,
id VARCHAR PRIMARY KEY,
fixedUsage BIGINT, -- 固定配额使用量(周限制)
timeFixedUpdated DATETIME,
rollingUsage BIGINT, -- 滚动配额使用量
timeRollingUpdated DATETIME,
timeDeleted DATETIME,
FOREIGN KEY (workspaceID) REFERENCES workspace(id),
FOREIGN KEY (userID) REFERENCES user(id)
)
UserTable
CREATE TABLE user (
workspaceID VARCHAR,
id VARCHAR PRIMARY KEY,
accountID VARCHAR,
email VARCHAR,
name VARCHAR,
role ENUM('admin', 'member'),
monthlyLimit INT, -- 用户级月度限制(美元)
monthlyUsage BIGINT, -- 用户级月度使用量
timeMonthlyUsageUpdated DATETIME,
timeDeleted DATETIME,
FOREIGN KEY (workspaceID) REFERENCES workspace(id)
)
IpTable
CREATE TABLE ip (
ip VARCHAR PRIMARY KEY,
usage BIGINT, -- Token 使用量
timeCreated DATETIME DEFAULT NOW(),
FOREIGN KEY (ip) REFERENCES ip(ip)
)
UsageTable
CREATE TABLE usage (
workspaceID VARCHAR,
id VARCHAR PRIMARY KEY,
model VARCHAR,
provider VARCHAR,
inputTokens BIGINT,
outputTokens BIGINT,
reasoningTokens BIGINT,
cacheReadTokens BIGINT,
cacheWrite5mTokens BIGINT,
cacheWrite1hTokens BIGINT,
cost BIGINT, -- 成本(微美分)
keyID VARCHAR,
enrichment JSON, -- 额外信息(如 plan: "sub")
timeCreated DATETIME DEFAULT NOW(),
FOREIGN KEY (workspaceID) REFERENCES workspace(id),
FOREIGN KEY (keyID) REFERENCES `key`(id)
)
自动充值
async function reload(authInfo: AuthInfo, costInfo: { costInMicroCents: number }) {
if (!authInfo) return
if (authInfo.isFree) return // 免费工作区不充值
if (authInfo.provider?.credentials) return // BYOK 不充值
if (authInfo.subscription) return // 订阅用户不充值
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
// 检查是否需要充值
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
// 防止并发充值
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
.set({ timeReloadLockedTill: sql`now() + interval 1 minute` })
.where(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, reloadTrigger),
or(isNull(timeReloadLockedTill), lt(timeReloadLockedTill, sql`now()`)),
),
)
if (lock.rowsAffected === 0) return // 已有其他进程在充值
// 执行 Stripe 充值
await Billing.reload()
}
错误处理
认证错误
| 错误类型 | HTTP 状态码 | 说明 |
|---|---|---|
AuthError | 401 | 无效 API Key |
CreditsError | 401 | 余额不足 / 无支付方式 |
MonthlyLimitError | 401 | 月度限制已到达 |
UserLimitError | 401 | 用户级限制已到达 |
ModelError | 401 | 模型被禁用 / 不支持 |
限流错误
| 错误类型 | HTTP 状态码 | 说明 |
|---|---|---|
RateLimitError | 429 | 请求过于频繁 |
SubscriptionError | 42929 | 订阅配额已耗尽 |
相关代码位置
| 功能 | 文件路径 | 行号 |
|---|---|---|
| 主要处理逻辑 | packages/console/app/src/routes/zen/util/handler.ts | 42-784 |
| 认证函数 | packages/console/app/src/routes/zen/util/handler.ts | 400-484 |
| 计费验证 | packages/console/app/src/routes/zen/util/handler.ts | 486-580 |
| 使用量追踪 | packages/console/app/src/routes/zen/util/handler.ts | 592-749 |
| 自动充值 | packages/console/app/src/routes/zen/util/handler.ts | 751-784 |
| 试用限制 | packages/console/app/src/routes/zen/util/trialLimiter.ts | 6-49 |
| Stripe 集成 | packages/console/core/src/billing.ts | 全部 |
| 模型配置 | packages/console/core/src/model.ts | 全部 |
| Provider 选择 | packages/console/app/src/routes/zen/util/handler.ts | 340-398 |
最佳实践
1. 为匿名用户配置试用配额
{
"models": {
"claude-opus-4-5-20251101": {
"allowAnonymous": true,
"trial": {
"provider": "anthropic",
"limits": [
{ "limit": 100000, "client": "cli" }, // CLI: 100K tokens
{ "limit": 50000, "client": "desktop" }, // Desktop: 50K tokens
{ "limit": 20000 } // Default: 20K tokens
]
}
}
}
}
2. 配置自动充值
await Billing.update(workspaceID, {
reload: true,
reloadAmount: 20, // $20
reloadTrigger: 5, // 余额低于 $5 时触发
})
3. 设置用户级限制
await User.update(userId, {
monthlyLimit: 100, // $100/月
})
4. 使用 BYOK
await Provider.set(workspaceID, "openai", {
credentials: "sk-...",
})
相关文档
- Provider/Model - 模型配置和提供商管理
- Agent - Agent 配置
- Session - 会话对象
- Config - 配置系统