Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • 计费和认证系统

计费和认证系统

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 ID
  • workspaceID - 工作区 ID
  • billing - 计费信息(余额、月度限制等)
  • 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 状态码说明
AuthError401无效 API Key
CreditsError401余额不足 / 无支付方式
MonthlyLimitError401月度限制已到达
UserLimitError401用户级限制已到达
ModelError401模型被禁用 / 不支持

限流错误

错误类型HTTP 状态码说明
RateLimitError429请求过于频繁
SubscriptionError42929订阅配额已耗尽

相关代码位置

功能文件路径行号
主要处理逻辑packages/console/app/src/routes/zen/util/handler.ts42-784
认证函数packages/console/app/src/routes/zen/util/handler.ts400-484
计费验证packages/console/app/src/routes/zen/util/handler.ts486-580
使用量追踪packages/console/app/src/routes/zen/util/handler.ts592-749
自动充值packages/console/app/src/routes/zen/util/handler.ts751-784
试用限制packages/console/app/src/routes/zen/util/trialLimiter.ts6-49
Stripe 集成packages/console/core/src/billing.ts全部
模型配置packages/console/core/src/model.ts全部
Provider 选择packages/console/app/src/routes/zen/util/handler.ts340-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 - 配置系统