feat(opencode): 用量窗口资格支持 OpenCode 平台并扩展到其它厂商挂载

This commit is contained in:
alfadb
2026-09-23 15:26:44 +08:00
parent 0c860e81e1
commit d03274ad5f
7 changed files with 376 additions and 67 deletions
+71 -28
View File
@@ -650,13 +650,23 @@ func lockAndMergeAccountProbeExtra(
extra -> 'ollama_cloud_usage_auto_refresh',
extra -> 'ollama_cloud_usage_snapshot',
COALESCE(
platform = 'openai'
AND $2 = 'openai'
(
-- opencode_go 平台分支:新旧都是 opencode_go Go 订阅(account_mode
-- 未设置/非 zen 均为 Go,与 GetOpenCodeAccountMode 默认兼容一致),
-- 不校验 base_url。
(platform = 'opencode_go' AND $2 = 'opencode_go'
AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)
AND COALESCE(btrim($4::jsonb ->> 'account_mode') <> 'zen', true))
-- 挂载白名单分支:新旧平台都在白名单内且 base_url 均为官方
-- OpenCode Go 基址(与 Ollama 同范式,允许白名单内跨平台)。
OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`)
AND $2 IN (`+opencodeGoUsageMountPlatformsSQL+`)
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
AND `+opencodeGoBaseURLMatchSQLPrefix+`$4::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`)
)
AND type = 'apikey'
AND $3 = 'apikey'
AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key'
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
AND `+opencodeGoBaseURLMatchSQLPrefix+`$4::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`,
AND credentials -> 'api_key' IS NOT DISTINCT FROM $4::jsonb -> 'api_key',
false
),
extra -> 'opencode_go_usage_auto_refresh',
@@ -797,9 +807,10 @@ func lockAndMergeAccountProbeExtra(
}
}
}
// OpenCode Go 受管状态与 Ollama 同范式:组身份(normalized opencode base URL +
// api_key)未变时从锁定的 DB 行回填,客户端 DTO 脱敏或并发 refresh 写入都无法
// 覆盖/丢失;身份改变或不再 eligible 时既不回填,写出的 extra 即清除旧状态。
// OpenCode Go 受管状态与 Ollama 同范式:组身份(opencode_go Go 订阅,或挂载
// 白名单平台 + 官方 OpenCode Go 基址,且 api_key 不变)未变时从锁定的 DB 行
// 回填,客户端 DTO 脱敏或并发 refresh 写入都无法覆盖/丢失;身份改变或不再
// eligible 时既不回填,写出的 extra 即清除旧状态。
// 代理不属于组身份,但 snapshot 外呼与 CAS 经代理,故代理变化时快照失效而开关保留。
if service.IsOpenCodeGoUsageAccount(account) && opencodeGroupIdentityUnchanged {
if value, ok, err := decodeAccountExtraJSON(currentOpenCodeAutoRefresh); err != nil {
@@ -857,17 +868,30 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
SET
credentials = $1::jsonb,
extra = CASE
-- OpenCode Go 分支必须先于 Ollama 分支求值:Ollama 守卫对任意
-- openai/anthropic apikey 行在 api_key/base_url 变化时都会命中,
-- 若排在前面会遮蔽 opencode 行的清理。opencode 与 ollama base URL
-- 正则互斥,旧行只会命中其中一个分支。
WHEN platform = 'openai'
AND type = 'apikey'
-- OpenCode 分支必须先于 Ollama 分支求值:Ollama 守卫对任意白名单平台的
-- apikey 行在 api_key/base_url 变化时都会命中,若排在前面会遮蔽 opencode
-- 行的清理。两分支互斥:opencode_go 不在 Ollama 白名单内,挂载行的
-- opencode.ai 基址与 ollama.com 基址正则互斥,旧行只会命中其中一个分支。
WHEN type = 'apikey'
AND credentials IS DISTINCT FROM $1::jsonb
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`
AND (
-- 旧行按新谓词属于 OpenCode 用量身份
(platform = 'opencode_go'
AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true))
OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`)
AND `+opencodeGoBaseURLMatchSQLPrefix+`credentials ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`)
)
AND (
credentials -> 'api_key' IS DISTINCT FROM $1::jsonb -> 'api_key'
OR (`+opencodeGoBaseURLMatchSQLPrefix+`$1::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) IS NOT TRUE
-- 或组身份不再成立:opencode_go 转 Zen(COALESCE 恒非 NULL,
-- IS NOT TRUE 等价于判定为假;平台条件必须在 IS NOT TRUE 作用
-- 域之外,否则挂载行的 FALSE IS NOT TRUE 会误判为已变化);
-- 挂载行 base_url 不再指向官方 Go 基址。NULL-safe:
-- regex(NULL) 为 NULL 时 IS NOT TRUE 把 NULL 视为不匹配。
OR (platform = 'opencode_go'
AND COALESCE(btrim($1::jsonb ->> 'account_mode') <> 'zen', true) IS NOT TRUE)
OR (platform IN (`+opencodeGoUsageMountPlatformsSQL+`)
AND (`+opencodeGoBaseURLMatchSQLPrefix+`$1::jsonb ->> 'base_url'`+opencodeGoBaseURLMatchSQLSuffix+`) IS NOT TRUE)
)
THEN COALESCE(extra, '{}'::jsonb)
- 'upstream_billing_probe'
@@ -3066,20 +3090,36 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
" AND "+ollamaCloudBaseURLMatchesSQL(credentialPlaceholder+"::jsonb ->> 'base_url'")+")")
}
// OpenCode Go 身份清理与 Ollama 同范式但更精确:仅当旧行是 opencode base URL
// 身份时才可能持有受管键,且只在该组身份(api_key / normalized base URL)真正
// 变化或不再 eligible 时清除,避免与 Ollama 分支交叉误清。
opencodeGroupIdentityChanges := make([]string, 0, 2)
// OpenCode Go 身份清理与 Ollama 同范式但更精确:仅当旧行按新谓词属于 OpenCode
// 用量身份(opencode_go Go 订阅,或挂载白名单平台 + 官方 OpenCode Go 基址)时
// 才可能持有受管键,且只在该组身份(api_key / normalized base URL /
// account_mode)真正变化或不再 eligible 时清除,避免与 Ollama 分支交叉误清。
opencodeGroupIdentityChanges := make([]string, 0, 3)
opencodeOldBaseURL := opencodeGoBaseURLMatchSQLPrefix + "credentials ->> 'base_url'" + opencodeGoBaseURLMatchSQLSuffix
// 旧行 OpenCode 用量身份谓词(与 opencodeGoUsageEligibleSQL 的行侧条件镜像;
// type 由 opencodeEligibleAccount 统一携带)。account_mode 未设置/为 null/非
// zen 均视为 Go 订阅,与 GetOpenCodeAccountMode 默认兼容一致。
opencodeOldUsageIdentity := "((platform = 'opencode_go' AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true))" +
" OR (platform IN (" + opencodeGoUsageMountPlatformsSQL + ") AND " + opencodeOldBaseURL + "))"
if _, ok := updates.Credentials["api_key"]; ok {
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
opencodeOldBaseURL+" AND credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'")
opencodeOldUsageIdentity+" AND credentials -> 'api_key' IS DISTINCT FROM "+credentialPlaceholder+"::jsonb -> 'api_key'")
}
if _, ok := updates.Credentials["base_url"]; ok {
// NULL-safe:新 base_url 缺失/为 null 时 regex(NULL) 为 NULL,NOT NULL 仍为
// NULL 会令 WHEN 不命中而残留 OpenCode 状态;IS NOT TRUE 把 NULL 视为不匹配。
// 仅挂载行身份钉在官方基址上(opencode_go 行的 base_url 不参与资格,两个
// 官方变体都合法,不由此子句清理)。NULL-safe:新 base_url 缺失/为 null 时
// regex(NULL) 为 NULL,NOT NULL 仍为 NULL 会令 WHEN 不命中而残留 OpenCode
// 状态;IS NOT TRUE 把 NULL 视为不匹配。
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
opencodeOldBaseURL+" AND ("+opencodeGoBaseURLMatchSQLPrefix+credentialPlaceholder+"::jsonb ->> 'base_url'"+opencodeGoBaseURLMatchSQLSuffix+") IS NOT TRUE")
"platform IN ("+opencodeGoUsageMountPlatformsSQL+") AND "+opencodeOldBaseURL+
" AND ("+opencodeGoBaseURLMatchSQLPrefix+credentialPlaceholder+"::jsonb ->> 'base_url'"+opencodeGoBaseURLMatchSQLSuffix+") IS NOT TRUE")
}
if _, ok := updates.Credentials["account_mode"]; ok {
// 仅 opencode_go 行身份钉在 Go 订阅上(挂载行的 account_mode 与 OpenCode
// 资格无关,不由此子句清理)。模式转 Zen 即不再 eligible。
opencodeGroupIdentityChanges = append(opencodeGroupIdentityChanges,
"platform = 'opencode_go' AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true)"+
" AND COALESCE(btrim("+credentialPlaceholder+"::jsonb ->> 'account_mode') <> 'zen', true) IS NOT TRUE")
}
if len(updates.Extra) > 0 || len(ollamaGroupIdentityChanges) > 0 || len(opencodeGroupIdentityChanges) > 0 || ollamaProxyIdentityChanged != "" || updates.EnsureCodexFingerprintSeed {
@@ -3115,10 +3155,13 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
}
// 代理不属于 OpenCode 组身份,但 snapshot 外呼与 CAS 经代理:
// 组身份变化清除开关+快照,仅代理变化清除快照而保留开关。
// eligible 判定必须包含旧行 opencode base URL:否则 OpenAI+Ollama 行在
// 代理变化时会先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的
// 快照清理。opencode 与 ollama 正则互斥,带上 base URL 后两套分支互斥。
opencodeEligibleAccount := "platform = 'openai' AND type = 'apikey' AND " + opencodeOldBaseURL
// eligible 判定必须包含旧行 OpenCode 身份(opencode_go Go 订阅,或挂载
// 白名单平台 + opencode 基址):否则白名单平台的 Ollama 行在代理变化时会
// 先命中 OpenCode 分支(CASE 按序求值)而遮蔽 Ollama 分支的快照清理。
// OpenCode 与 Ollama 两套分支互斥:opencode_go 不在 Ollama 白名单内;挂载行
// 的 opencode.ai 基址与 ollama.com 基址正则互斥(host 不同),同一旧行不可能
// 同时命中两分支,CASE 求值顺序因此只是防御而非正确性依赖。
opencodeEligibleAccount := "type = 'apikey' AND " + opencodeOldUsageIdentity
opencodeGroupIdentityChanged := ""
if len(opencodeGroupIdentityChanges) > 0 {
opencodeGroupIdentityChanged = "(" + opencodeEligibleAccount + " AND (" + joinClauses(opencodeGroupIdentityChanges, " OR ") + "))"
@@ -13,15 +13,33 @@ import (
const (
// 与 service.isOpenCodeGoBaseURL 对齐:Go 侧接受显式默认端口 :443(parsed.Host ==
// hostname+":443"),SQL 正则必须同样接受可选 :443,否则同一 base_url 在 Go 判定
// eligible 而 SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径。
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO]/[vV]1/?$`
// hostname+":443")与两个官方基址变体(CC/Responses 的 /zen/go/v1 与 Anthropic
// 的 /zen/go),SQL 正则必须同样接受,否则同一 base_url 在 Go 判定 eligible 而
// SQL 判定不 eligible,身份清理与组查询会漏行。只做 parity,不扩域名/路径;
// Zen 基址(/zen、/zen/v1)必须拒绝。
opencodeGoBaseURLRegexSQL = `^[hH][tT][tT][pP][sS]://[oO][pP][eE][nN][cC][oO][dD][eE]\.[aA][iI](:[4][4][3])?/[zZ][eE][nN]/[gG][oO](/[vV]1)?/?$`
opencodeGoBaseURLMatchSQLPrefix = "btrim("
opencodeGoBaseURLMatchSQLSuffix = ") ~ '" + opencodeGoBaseURLRegexSQL + "'"
opencodeGoUsageEligibleSQL = `
platform = 'openai'
// opencodeGoUsageMountPlatformsSQL 是 service.isOpenCodeGoUsageMountPlatform 的
// SQL 镜像:OpenCode Go key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用,与 ollama 的 ollamaCloudUsagePlatformsSQL 完全一致。opencode_go
// 平台本身不在名单内——平台账号走下方 account_mode 分支。所有平台白名单 SQL
// 只允许引用本常量,不得各处重写字面量,防止漂移。
opencodeGoUsageMountPlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
// opencodeGoUsageEligibleSQL 与 service.IsOpenCodeGoUsageAccount 互为镜像:
// - opencode_go 平台:account_mode 存储于 credentials(domain/constants.go),
// 未设置/为 null/非 "zen" 一律视为 Go 订阅,与 GetOpenCodeAccountMode 的
// 默认兼容逻辑一致;COALESCE 把 <> 对 NULL 的结果归一为 true。不校验
// base_url(平台字段已是权威来源)。
// - 挂载白名单平台:base_url 必须匹配官方 OpenCode Go 基址正则。
opencodeGoUsageEligibleSQL = `
(
(platform = 'opencode_go'
AND COALESCE(btrim(credentials ->> 'account_mode') <> 'zen', true))
OR (platform IN (` + opencodeGoUsageMountPlatformsSQL + `)
AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + `)
)
AND type = 'apikey'
AND ` + opencodeGoBaseURLMatchSQLPrefix + `credentials ->> 'base_url'` + opencodeGoBaseURLMatchSQLSuffix + `
AND jsonb_typeof(credentials -> 'api_key') = 'string'
`
)
@@ -220,6 +220,91 @@ func TestOpenCodeGoUsageEligibleSQLAcceptsDefaultPort443(t *testing.T) {
require.Equal(t, account.ID, groups[0].ID)
}
// 资格泛化回归:opencode_go 平台 Go 订阅账号(不依赖 base_url,未设置
// account_mode 默认 Go)与 Anthropic 基址(/zen/go)挂载行都必须命中
// opencodeGoUsageEligibleSQL;同 key 的 Zen 模式行必须被 SQL 排除。
func TestOpenCodeGoUsageEligibleSQLOpencodePlatformAndAnthropicBaseURL(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
platformGo := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-platform-go", Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "platform-key"}, // 无 base_url、无 account_mode(默认 Go)
Extra: map[string]any{},
})
// 同 key 的 Zen 行:SQL 必须排除(account_mode = zen)。
platformZen := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-platform-zen", Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "platform-key", "account_mode": service.AccountModeZen},
Extra: map[string]any{},
})
anthropicMount := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "opencode-anthropic-mount", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "mount-key", "base_url": "https://opencode.ai/zen/go"},
Extra: map[string]any{},
})
groups, err := repo.ListOpenCodeGoUsageGroupAccounts(ctx, []*service.Account{platformGo, anthropicMount})
require.NoError(t, err)
require.Len(t, groups, 2)
ids := make(map[int64]struct{}, len(groups))
for _, account := range groups {
ids[account.ID] = struct{}{}
}
require.Contains(t, ids, platformGo.ID)
require.Contains(t, ids, anthropicMount.ID)
require.NotContains(t, ids, platformZen.ID, "同 key 的 Zen 模式行必须被 SQL 谓词排除")
}
// 资格泛化回归:UpdateCredentials 的 OpenCode 清理分支里,IS NOT TRUE 只能作用于
// 新凭证的检查本身,平台条件必须在作用域外——否则挂载行 (FALSE AND …) IS NOT TRUE
// 恒为 TRUE,仅添加无关凭证键也会误清受管键。两类行在 api_key/身份不变、凭证仅
// 无关变化时都必须保留 OpenCode 受管键。
func TestUpdateCredentialsOpenCodeGoUnrelatedCredentialChangeKeepsManagedState(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Now().UTC()
create := func(name, platform string, credentials map[string]any) *service.Account {
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: platform, Type: service.AccountTypeAPIKey,
Credentials: credentials,
Extra: map[string]any{
service.OpenCodeGoUsageAutoRefreshExtraKey: true,
service.OpenCodeGoUsageSnapshotExtraKey: map[string]any{
"status": service.OpenCodeGoUsageStatusOK, "last_attempt_at": now, "next_refresh_at": now.Add(time.Hour),
},
},
})
}
anthropicMount := create("opencode-mount-unrelated", service.PlatformAnthropic, map[string]any{
"api_key": "mount-key", "base_url": "https://opencode.ai/zen/go",
})
platformGo := create("opencode-platform-unrelated", service.PlatformOpenCodeGo, map[string]any{
"api_key": "platform-key", "account_mode": service.AccountModeGo,
})
// 挂载行:api_key 与官方基址都未变,仅新增无关凭证键。
require.NoError(t, repo.UpdateCredentials(ctx, anthropicMount.ID, map[string]any{
"api_key": "mount-key", "base_url": "https://opencode.ai/zen/go", "remark": "unrelated",
}))
// opencode_go 行:api_key 未变、模式仍为 Go,仅新增无关凭证键。
require.NoError(t, repo.UpdateCredentials(ctx, platformGo.ID, map[string]any{
"api_key": "platform-key", "account_mode": service.AccountModeGo, "remark": "unrelated",
}))
for _, id := range []int64{anthropicMount.ID, platformGo.ID} {
loaded, err := repo.GetByID(ctx, id)
require.NoError(t, err)
require.Equal(t, true, loaded.Extra[service.OpenCodeGoUsageAutoRefreshExtraKey])
snapshot, ok := loaded.Extra[service.OpenCodeGoUsageSnapshotExtraKey].(map[string]any)
require.True(t, ok)
require.Equal(t, service.OpenCodeGoUsageStatusOK, snapshot["status"])
}
}
// F7:生产故障路径——前端脱敏普通编辑(incoming Extra 不含 OpenCode 受管键)经真实
// repo.Update 走 lockAndMergeAccountProbeExtra 的 SELECT/Scan/UPDATE 后,custom 生效
// 且两个受管键从锁定 DB 行回填;incoming Extra 伪造受管键时以锁定 DB 值为准。
@@ -286,8 +286,10 @@ func TestBulkUpdateOpenCodeGoIdentityCleanupIsValueConditional(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, exec.execQueries)
query := normalizeSQLWhitespace(exec.execQueries[0])
// OpenCode 分支必须出现在 Ollama 分支之前,且只在旧行是 opencode base URL 时触发。
require.Contains(t, query, "platform = 'openai' AND type = 'apikey'")
// OpenCode 分支必须出现在 Ollama 分支之前,且 eligible 判定按新谓词同时覆盖
// opencode_go 平台(Go 订阅)与挂载白名单平台 + opencode 基址。
require.Contains(t, query, "platform = 'opencode_go'")
require.Contains(t, query, "platform IN ("+opencodeGoUsageMountPlatformsSQL+")")
require.Contains(t, query, "- 'opencode_go_usage_auto_refresh' - 'opencode_go_usage_snapshot'")
opencodeBranch := strings.Index(query, "opencode_go_usage_auto_refresh")
ollamaBranch := strings.Index(query, "ollama_cloud_usage_auto_refresh")
@@ -319,6 +321,9 @@ func TestBulkUpdateOpenCodeGoEligiblePredicateIncludesBaseURL(t *testing.T) {
require.NotEqual(t, -1, firstThen)
require.Less(t, caseStart, firstThen)
firstWhen := query[caseStart:firstThen]
// 第一个 WHEN 分支是 OpenCode 快照失效分支(代理变化),其 eligible 判定必须
// 覆盖 opencode_go 平台与挂载白名单的 opencode 基址,使 Ollama 行无法命中。
require.Contains(t, firstWhen, "platform = 'opencode_go'")
require.Contains(t, firstWhen, "[oO][pP][eE][nN][cC][oO][dD][eE]")
require.Contains(t, firstWhen, "credentials ->> 'base_url'")
require.NotContains(t, firstWhen, "[oO][lL][lL][aA][mM][aA]")
@@ -342,8 +347,9 @@ func TestBulkUpdateOpenCodeGoBaseURLClauseIsNullSafe(t *testing.T) {
require.NotContains(t, query, "AND NOT btrim(credentials ->> 'base_url')")
}
// F6 回归:SQL 正则与 service.isOpenCodeGoBaseURL 对齐,接受显式默认端口 :443。
// 该正则同时用于 eligible 判定与身份清理,Go 侧接受而 SQL 侧拒绝会导致漏清/漏组。
// F6 回归:SQL 正则与 service.isOpenCodeGoBaseURL 对齐,接受显式默认端口 :443 与
// 两个官方基址变体(CC/Responses 的 /zen/go/v1、Anthropic 的 /zen/go)。该正则
// 同时用于 eligible 判定与身份清理,Go 侧接受而 SQL 侧拒绝会导致漏清/漏组。
func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) {
re := regexp.MustCompile(opencodeGoBaseURLRegexSQL)
for _, url := range []string{
@@ -351,6 +357,9 @@ func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) {
"https://opencode.ai/zen/go/v1/",
"https://opencode.ai:443/zen/go/v1",
"https://opencode.ai:443/zen/go/v1/",
"https://opencode.ai/zen/go",
"https://opencode.ai/zen/go/",
"https://opencode.ai:443/zen/go",
"HTTPS://OPENCODE.AI:443/ZEN/GO/V1",
} {
require.True(t, re.MatchString(url), "SQL regex must accept %s", url)
@@ -360,11 +369,54 @@ func TestOpenCodeGoBaseURLRegexSQLAcceptsDefaultPort443(t *testing.T) {
"https://opencode.ai/v1",
"https://ollama.com/zen/go/v1",
"https://opencode.ai/zen/go/v1?x=1",
// zen 基址必须拒绝:按量付费无订阅配额窗口
"https://opencode.ai/zen",
"https://opencode.ai/zen/",
"https://opencode.ai/zen/v1",
"https://opencode.ai/zen/v1/",
// 非官方 scheme / 子域 / 路径前缀拼接
"http://opencode.ai/zen/go/v1",
"https://www.opencode.ai/zen/go/v1",
"https://opencode.ai/zen/gov1",
} {
require.False(t, re.MatchString(url), "SQL regex must reject %s", url)
}
}
// SQL 挂载平台白名单常量与 service 判定必须互为镜像:对每个挂载候选平台,常量里
// 的成员关系都要与 IsOpenCodeGoUsageAccount(apikey + 官方 OpenCode Go 基址)一致,
// 防止两侧平台列表各自漂移。opencode_go 走平台 + Go 订阅分支而非挂载白名单:
// 不得出现在白名单常量里,且其资格由 account_mode 决定,在此一并钉死。
func TestOpenCodeGoUsagePlatformWhitelistMatchesServicePredicate(t *testing.T) {
matches := regexp.MustCompile(`'([^']+)'`).FindAllStringSubmatch(opencodeGoUsageMountPlatformsSQL, -1)
sqlPlatforms := make(map[string]struct{}, len(matches))
for _, match := range matches {
sqlPlatforms[match[1]] = struct{}{}
}
require.Len(t, sqlPlatforms, 6)
for _, platform := range []string{
service.PlatformOpenAI, service.PlatformAnthropic,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformGemini, service.PlatformGrok, service.PlatformAntigravity,
service.PlatformComposite, "kiro",
} {
account := openCodeGoUsageRepositoryAccount()
account.Platform = platform
_, inSQL := sqlPlatforms[platform]
require.Equal(t, inSQL, service.IsOpenCodeGoUsageAccount(account), platform)
}
// opencode_go 不在挂载白名单里,资格来自平台 + Go 订阅分支。
_, opencodeInSQL := sqlPlatforms[service.PlatformOpenCodeGo]
require.False(t, opencodeInSQL)
goAccount := openCodeGoUsageRepositoryAccount()
goAccount.Platform = service.PlatformOpenCodeGo
require.True(t, service.IsOpenCodeGoUsageAccount(goAccount))
zenAccount := openCodeGoUsageRepositoryAccount()
zenAccount.Platform = service.PlatformOpenCodeGo
zenAccount.Credentials["account_mode"] = service.AccountModeZen
require.False(t, service.IsOpenCodeGoUsageAccount(zenAccount))
}
func TestBulkUpdateOpenCodeGoProxyChangeClearsSnapshotOnly(t *testing.T) {
exec := &recordingSQLExecutor{result: rowsAffectedResult(1)}
repo := newAccountRepositoryWithSQL(nil, exec, nil)
+2 -1
View File
@@ -237,7 +237,8 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
OR (platform IN (`+ollamaCloudUsagePlatformsSQL+`)
AND extra ? 'ollama_cloud_usage_snapshot'
AND extra -> 'ollama_cloud_usage_snapshot' <> 'null'::jsonb)
OR (platform = 'openai'
-- OpenCode 快照可能挂在 opencode_go 平台账号或挂载白名单平台账号上。
OR ((platform = 'opencode_go' OR platform IN (`+opencodeGoUsageMountPlatformsSQL+`))
AND extra ? 'opencode_go_usage_snapshot'
AND extra -> 'opencode_go_usage_snapshot' <> 'null'::jsonb)
)
+43 -3
View File
@@ -59,7 +59,8 @@ var (
"OPENCODE_GO_USAGE_UNAVAILABLE", "OpenCode Go usage is unavailable",
)
ErrOpenCodeGoUsageAccountInvalid = infraerrors.BadRequest(
"OPENCODE_GO_USAGE_ACCOUNT_INVALID", "account must be an OpenAI API key account using https://opencode.ai/zen/go/v1",
"OPENCODE_GO_USAGE_ACCOUNT_INVALID",
"account must be an OpenCode Go platform subscription (go mode) account, or an API key account under openai/anthropic/kimi/zhipu/deepseek/minimax with base_url pointing at the official OpenCode Go base URLs",
)
ErrOpenCodeGoUsageIdentityChanged = infraerrors.Conflict(
"OPENCODE_GO_USAGE_IDENTITY_CHANGED", "account identity or proxy changed during refresh; retry",
@@ -868,8 +869,37 @@ func OpenCodeGoUsageStateFromAccount(account *Account) *OpenCodeGoUsageState {
return state
}
// isOpenCodeGoUsageMountPlatform 收敛允许以 base_url 挂载 OpenCode Go 用量身份的
// 平台白名单,与 ollama 的 isOllamaCloudUsagePlatform 对齐。repository 侧 SQL
// 白名单(opencodeGoUsageMountPlatformsSQL)是本列表的镜像,两侧必须同步修改。
// opencode_go 平台本身不在名单内:平台账号走 IsOpenCodeGoPlan 判定,不依赖
// base_url。
func isOpenCodeGoUsageMountPlatform(platform string) bool {
switch platform {
case PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
return true
default:
return false
}
}
// IsOpenCodeGoUsageAccount 判定账号是否参与 OpenCode Go 用量窗口,资格来源分派:
// - platform == opencode_go:平台字段是权威来源,以 IsOpenCodeGoPlan 为准(必须是
// Go 订阅;Zen 按量付费无订阅配额窗口,上游 CN 配额链路同样排除)。此分支不要求
// base_url 匹配——opencode_go 账号的 base_url 可能是 CC/Responses 基址
// (/zen/go/v1)或 Anthropic 基址(/zen/go),甚至为空。
// - 其它平台:仅限挂载白名单(isOpenCodeGoUsageMountPlatform)内的 API Key 账号,
// 以 base_url 严格指向官方 OpenCode Go 基址判定(见 isOpenCodeGoBaseURL)。
//
// 两种情况都要求 account.Type == AccountTypeAPIKey。
func IsOpenCodeGoUsageAccount(account *Account) bool {
if account == nil || account.Type != AccountTypeAPIKey || account.Platform != PlatformOpenAI {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
if account.IsOpenCodeGo() {
return account.IsOpenCodeGoPlan()
}
if !isOpenCodeGoUsageMountPlatform(account.Platform) {
return false
}
baseURL, _ := account.Credentials["base_url"].(string)
@@ -896,9 +926,16 @@ func isOpenCodeGoBaseURL(raw string) bool {
if parsed.RawPath != "" {
return false
}
return strings.EqualFold(strings.TrimSuffix(parsed.Path, "/"), "/zen/go/v1")
// 官方两个基址变体:CC/Responses 的 /zen/go/v1 与 Anthropic 的 /zen/go。
// TrimSuffix 归一尾斜杠后,Zen 基址(/zen、/zen/v1)自然被拒绝。
path := strings.TrimSuffix(parsed.Path, "/")
return strings.EqualFold(path, "/zen/go/v1") || strings.EqualFold(path, "/zen/go")
}
// openCodeGoUsageIdentity 返回 OpenCode Go 用量组的身份。host 固定为 opencode.ai、
// 以 api_key 聚合是平台无关的有意设计:同一个 OpenCode Go 订阅 key 无论以
// opencode_go 平台账号存在,还是挂在 openai/anthropic 等挂载平台下,都属于
// 同一组、共享一次外呼与同一份快照。
func openCodeGoUsageIdentity(account *Account) map[string]any {
if !IsOpenCodeGoUsageAccount(account) {
return nil
@@ -910,6 +947,9 @@ func openCodeGoUsageIdentity(account *Account) map[string]any {
return map[string]any{"host": "opencode.ai", "api_key": apiKey}
}
// openCodeGoUsageGroupFingerprint 是组身份的指纹(sha256("opencode.ai\x00"+apiKey))。
// 与 openCodeGoUsageIdentity 一样保持平台无关:同一订阅 key 跨平台(opencode_go
// 平台账号与挂载平台账号)必须得到相同指纹,才会被归入同一刷新组。
func openCodeGoUsageGroupFingerprint(account *Account) (string, bool) {
identity := openCodeGoUsageIdentity(account)
if identity == nil {
@@ -732,60 +732,116 @@ func TestOpenCodeGoUsageRunDueSkipsWhenDisabled(t *testing.T) {
}
func TestIsOpenCodeGoUsageAccount(t *testing.T) {
base := func() *Account {
mount := func() *Account {
return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}}
}
require.True(t, IsOpenCodeGoUsageAccount(base()))
require.True(t, IsOpenCodeGoUsageAccount(mount()))
// trailing slash on the path is allowed
account := base()
account := mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/"
require.True(t, IsOpenCodeGoUsageAccount(account))
// explicit default port :443 is allowed (parsed.Host == hostname+":443")
account = base()
// anthropic base URL variant (/zen/go) is allowed
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go"
require.True(t, IsOpenCodeGoUsageAccount(account))
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/"
require.True(t, IsOpenCodeGoUsageAccount(account))
// explicit default port :443 is allowed for both path variants
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go/v1"
require.True(t, IsOpenCodeGoUsageAccount(account))
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go"
require.True(t, IsOpenCodeGoUsageAccount(account))
// non-default port is rejected
account = base()
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:444/zen/go/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// scheme/host/path are case-insensitive
account = base()
account = mount()
account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1"
require.True(t, IsOpenCodeGoUsageAccount(account))
// zen paths are rejected (no subscription quota window)
for _, raw := range []string{
"https://opencode.ai/zen/v1", "https://opencode.ai/zen", "https://opencode.ai/zen/v1/",
} {
account = mount()
account.Credentials["base_url"] = raw
require.False(t, IsOpenCodeGoUsageAccount(account), raw)
}
// wrong path
account = base()
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// wrong host
account = base()
account = mount()
account.Credentials["base_url"] = "https://ollama.com/zen/go/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// wrong platform
account = base()
account.Platform = PlatformAnthropic
require.False(t, IsOpenCodeGoUsageAccount(account))
// non-apikey type
account = base()
account.Type = "oauth"
require.False(t, IsOpenCodeGoUsageAccount(account))
// query string rejected
account = base()
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1?x=1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// missing base_url
account = base()
account = mount()
account.Credentials = map[string]any{"api_key": "k"}
require.False(t, IsOpenCodeGoUsageAccount(account))
// non-apikey type
account = mount()
account.Type = "oauth"
require.False(t, IsOpenCodeGoUsageAccount(account))
// mount platforms: every whitelist platform qualifies with the official base URL
for _, platform := range []string{
PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
} {
account = mount()
account.Platform = platform
require.True(t, IsOpenCodeGoUsageAccount(account), platform)
}
// non-whitelist platforms never qualify, even with the official base URL
for _, platform := range []string{PlatformGemini, PlatformGrok, PlatformAntigravity} {
account = mount()
account.Platform = platform
require.False(t, IsOpenCodeGoUsageAccount(account), platform)
}
// opencode_go platform: eligibility comes from platform + go mode, not base_url
platformAccount := func() *Account {
return &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}
}
require.True(t, IsOpenCodeGoUsageAccount(platformAccount())) // no base_url at all
account = platformAccount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go" // anthropic variant
require.True(t, IsOpenCodeGoUsageAccount(account))
account = platformAccount()
account.Credentials["base_url"] = "https://relay.example.com/v1" // arbitrary relay
require.True(t, IsOpenCodeGoUsageAccount(account))
account = platformAccount()
account.Credentials["account_mode"] = AccountModeGo
require.True(t, IsOpenCodeGoUsageAccount(account))
// zen mode has no subscription quota window
account = platformAccount()
account.Credentials["account_mode"] = AccountModeZen
require.False(t, IsOpenCodeGoUsageAccount(account))
// opencode_go still requires the apikey type
account = platformAccount()
account.Type = "oauth"
require.False(t, IsOpenCodeGoUsageAccount(account))
}
func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) {
@@ -793,20 +849,33 @@ func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) {
first.Credentials["api_key"] = "shared-key"
second := openCodeGoUsageAccount(2)
second.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"}
// 同一订阅 key 以 opencode_go 平台账号存在:与挂载平台账号必须同组(身份平台无关)
third := openCodeGoUsageAccount(3)
third.Credentials["api_key"] = "other-key"
third.Platform = PlatformOpenCodeGo
third.Credentials = map[string]any{"account_mode": AccountModeGo, "api_key": "shared-key"}
fourth := openCodeGoUsageAccount(4)
fourth.Credentials["api_key"] = "other-key"
// zen 模式的 opencode_go 账号不合格,无指纹
zen := openCodeGoUsageAccount(5)
zen.Platform = PlatformOpenCodeGo
zen.Credentials = map[string]any{"account_mode": AccountModeZen, "api_key": "shared-key"}
firstFP, firstOK := openCodeGoUsageGroupFingerprint(first)
secondFP, secondOK := openCodeGoUsageGroupFingerprint(second)
thirdFP, thirdOK := openCodeGoUsageGroupFingerprint(third)
fourthFP, fourthOK := openCodeGoUsageGroupFingerprint(fourth)
_, zenOK := openCodeGoUsageGroupFingerprint(zen)
require.True(t, firstOK)
require.True(t, secondOK)
require.True(t, thirdOK)
require.True(t, fourthOK)
require.False(t, zenOK)
require.Equal(t, firstFP, secondFP, "same api_key across base_url variants must share a group")
require.NotEqual(t, firstFP, thirdFP, "different api_key must not share a group")
require.Equal(t, firstFP, thirdFP, "same api_key across platforms must share a group")
require.NotEqual(t, firstFP, fourthFP, "different api_key must not share a group")
// ineligible accounts have no fingerprint
account := openCodeGoUsageAccount(4)
account := openCodeGoUsageAccount(6)
account.Credentials["base_url"] = "https://opencode.ai/v1"
_, ok := openCodeGoUsageGroupFingerprint(account)
require.False(t, ok)
@@ -891,7 +960,8 @@ func TestOpenCodeGoUsageStateFromAccount(t *testing.T) {
require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status)
// ineligible account exposes no managed state
account.Platform = PlatformAnthropic
// (anthropic 已是挂载白名单成员,改用非白名单平台验证不合格路径)
account.Platform = PlatformGemini
state = OpenCodeGoUsageStateFromAccount(account)
require.False(t, state.Eligible)
require.Nil(t, state.Snapshot)