mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-21 05:21:20 +00:00
feat(opencode): 用量窗口资格支持 OpenCode 平台并扩展到其它厂商挂载
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user