From d03274ad5f91d9f9c3374595539b8c609f95c01a Mon Sep 17 00:00:00 2001 From: alfadb Date: Fri, 11 Sep 2026 20:34:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20=E7=94=A8=E9=87=8F=E7=AA=97?= =?UTF-8?q?=E5=8F=A3=E8=B5=84=E6=A0=BC=E6=94=AF=E6=8C=81=20OpenCode=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E5=B9=B6=E6=89=A9=E5=B1=95=E5=88=B0=E5=85=B6?= =?UTF-8?q?=E5=AE=83=E5=8E=82=E5=95=86=E6=8C=82=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/repository/account_repo.go | 99 +++++++++++---- .../account_repo_opencode_go_usage.go | 30 ++++- ...repo_opencode_go_usage_integration_test.go | 85 +++++++++++++ .../account_repo_opencode_go_usage_test.go | 60 ++++++++- backend/internal/repository/proxy_repo.go | 3 +- backend/internal/service/opencode_go_usage.go | 46 ++++++- .../service/opencode_go_usage_test.go | 120 ++++++++++++++---- 7 files changed, 376 insertions(+), 67 deletions(-) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index b8e643dac4..70ff03597c 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -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 ") + "))" diff --git a/backend/internal/repository/account_repo_opencode_go_usage.go b/backend/internal/repository/account_repo_opencode_go_usage.go index b08e27dd91..1720745adf 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage.go +++ b/backend/internal/repository/account_repo_opencode_go_usage.go @@ -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' ` ) diff --git a/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go b/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go index 91aea503dd..cd21900829 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go +++ b/backend/internal/repository/account_repo_opencode_go_usage_integration_test.go @@ -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 值为准。 diff --git a/backend/internal/repository/account_repo_opencode_go_usage_test.go b/backend/internal/repository/account_repo_opencode_go_usage_test.go index 55c568873f..a6dbebbf6f 100644 --- a/backend/internal/repository/account_repo_opencode_go_usage_test.go +++ b/backend/internal/repository/account_repo_opencode_go_usage_test.go @@ -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) diff --git a/backend/internal/repository/proxy_repo.go b/backend/internal/repository/proxy_repo.go index bbed9cdea1..0af72159c9 100644 --- a/backend/internal/repository/proxy_repo.go +++ b/backend/internal/repository/proxy_repo.go @@ -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) ) diff --git a/backend/internal/service/opencode_go_usage.go b/backend/internal/service/opencode_go_usage.go index eba93d4db3..583b06c4c0 100644 --- a/backend/internal/service/opencode_go_usage.go +++ b/backend/internal/service/opencode_go_usage.go @@ -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 { diff --git a/backend/internal/service/opencode_go_usage_test.go b/backend/internal/service/opencode_go_usage_test.go index 1c86df4cb3..91b9047042 100644 --- a/backend/internal/service/opencode_go_usage_test.go +++ b/backend/internal/service/opencode_go_usage_test.go @@ -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)