From 242907854d831d2c8ccff50ff0ab897e42fba76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Fri, 11 Sep 2026 10:05:24 +0800 Subject: [PATCH] feat(opencode): add OpenCode platform with Zen and GO account types Keep the internal platform ID opencode_go. Product surface is OpenCode with account_mode zen|go, model-aware routing across Chat Completions, Responses, and Anthropic Messages, and X-OpenCode-Session derived from prompt_cache_key / metadata.user_id so prompt cache can hit. Unmatched models fall back to /v1/chat/completions. Related to #6742. --- backend/ent/channelmonitor/channelmonitor.go | 3 +- .../channelmonitorrequesttemplate.go | 3 +- backend/ent/migrate/schema.go | 4 +- backend/ent/schema/channel_monitor.go | 2 +- .../channel_monitor_request_template.go | 2 +- backend/ent/schema/user_platform_quota.go | 2 +- backend/internal/config/config.go | 1 + .../internal/domain/channel_monitor_quota.go | 3 +- backend/internal/domain/constants.go | 15 +- .../internal/handler/admin/channel_handler.go | 1 + .../handler/admin/channel_monitor_handler.go | 4 +- .../internal/handler/admin/group_handler.go | 6 +- .../admin/group_handler_platform_test.go | 15 +- backend/internal/handler/endpoint.go | 2 +- backend/internal/handler/endpoint_test.go | 12 + backend/internal/handler/gateway_handler.go | 8 +- .../openai_gateway_cn_dispatch_test.go | 2 +- .../handler/openai_gateway_handler.go | 9 +- .../internal/model/error_passthrough_rule.go | 2 + .../model/error_passthrough_rule_test.go | 1 + backend/internal/server/api_contract_test.go | 4 +- backend/internal/server/routes/gateway.go | 5 +- backend/internal/service/account.go | 50 ++- .../service/account_header_override.go | 2 +- .../service/account_header_override_test.go | 2 + .../account_scheduling_threshold_eval.go | 11 +- backend/internal/service/account_service.go | 4 +- .../internal/service/account_test_service.go | 47 +++ .../account_test_service_cn_adaptive.go | 3 + .../account_test_service_opencode_go_test.go | 105 ++++++ backend/internal/service/admin_account.go | 12 + backend/internal/service/admin_group.go | 4 +- .../service/channel_monitor_quota_fetcher.go | 2 + .../service/channel_monitor_validate.go | 2 + backend/internal/service/channel_service.go | 2 +- .../internal/service/channel_service_test.go | 2 +- .../service/cn_provider_quota_service.go | 70 +++- backend/internal/service/cn_providers_test.go | 44 +++ .../internal/service/composite_platform.go | 2 +- .../service/composite_platform_test.go | 4 +- backend/internal/service/domain_constants.go | 33 +- .../service/openai_gateway_cc_pipeline.go | 2 +- .../openai_gateway_chat_completions.go | 39 ++- ...teway_chat_completions_anthropic_native.go | 2 +- .../service/openai_gateway_forward.go | 22 +- .../service/openai_gateway_messages.go | 25 +- ...penai_gateway_messages_anthropic_native.go | 13 + .../service/openai_gateway_passthrough.go | 2 +- ...enai_gateway_responses_anthropic_native.go | 2 +- .../service/openai_gateway_scheduling.go | 2 +- .../service/openai_gateway_service.go | 15 + .../service/openai_opencode_session.go | 161 ++++++++- .../service/openai_opencode_session_test.go | 122 ++++++- backend/internal/service/opencode_go.go | 330 ++++++++++++++++++ backend/internal/service/opencode_go_test.go | 220 ++++++++++++ .../service/ratelimit_cn_providers.go | 41 ++- backend/internal/service/ratelimit_service.go | 4 +- .../scheduler_snapshot_bulk_event_test.go | 2 +- .../service/scheduler_snapshot_service.go | 6 +- .../service/upstream_billing_probe.go | 3 +- ...stream_billing_probe_multiplatform_test.go | 6 +- backend/internal/service/upstream_models.go | 9 +- backend/internal/setup/setup_test.go | 6 +- .../migrations/238_opencode_go_platform.sql | 62 ++++ .../opencode_go_platform_migration_test.go | 29 ++ deploy/config.example.yaml | 1 + frontend/src/api/admin/channelMonitor.ts | 1 + frontend/src/api/admin/cnProviders.ts | 2 +- frontend/src/api/admin/settings.ts | 4 +- .../components/account/AccountUsageCell.vue | 5 +- .../account/CNProviderQuotaCell.vue | 15 +- .../components/account/CreateAccountModal.vue | 193 ++++++++-- .../components/account/EditAccountModal.vue | 148 +++++++- .../account/ModelWhitelistSelector.vue | 3 +- .../account/OpenCodeGoProtocolRulesEditor.vue | 106 ++++++ .../__tests__/CreateAccountModal.spec.ts | 59 ++++ .../__tests__/EditAccountModal.spec.ts | 74 ++++ .../__tests__/ModelWhitelistSelector.spec.ts | 18 + .../__tests__/credentialsBuilder.spec.ts | 57 ++- .../components/account/credentialsBuilder.ts | 103 +++++- .../admin/monitor/MonitorFiltersBar.vue | 2 + .../admin/monitor/MonitorFormDialog.vue | 4 + .../monitor/MonitorTemplateManagerDialog.vue | 2 + .../src/components/common/PlatformIcon.vue | 4 + frontend/src/components/keys/UseKeyModal.vue | 2 + .../components/user/monitor/MonitorCard.vue | 1 + .../components/user/monitor/ProviderIcon.vue | 5 + .../composables/useChannelMonitorFormat.ts | 9 + frontend/src/composables/useModelWhitelist.ts | 12 + .../src/constants/__tests__/platforms.spec.ts | 3 +- frontend/src/constants/channelMonitor.ts | 3 + frontend/src/constants/platforms.ts | 3 +- .../src/i18n/locales/en/admin/accounts.ts | 19 + .../src/i18n/locales/en/admin/overview.ts | 1 + frontend/src/i18n/locales/en/dashboard.ts | 4 +- .../src/i18n/locales/zh/admin/accounts.ts | 19 + .../src/i18n/locales/zh/admin/overview.ts | 1 + frontend/src/i18n/locales/zh/dashboard.ts | 4 +- frontend/src/types/index.ts | 4 +- frontend/src/utils/platformColors.ts | 16 + frontend/src/views/admin/ChannelsView.vue | 4 +- frontend/src/views/admin/GroupsView.vue | 4 + .../GroupsView.compositePlatforms.spec.ts | 2 +- .../__tests__/channelPlatformOptions.spec.ts | 1 + 104 files changed, 2364 insertions(+), 186 deletions(-) create mode 100644 backend/internal/service/account_test_service_opencode_go_test.go create mode 100644 backend/internal/service/opencode_go.go create mode 100644 backend/internal/service/opencode_go_test.go create mode 100644 backend/migrations/238_opencode_go_platform.sql create mode 100644 backend/migrations/opencode_go_platform_migration_test.go create mode 100644 frontend/src/components/account/OpenCodeGoProtocolRulesEditor.vue diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index f7dcac62cc..5fa6966c45 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -183,6 +183,7 @@ const ( ProviderZhipu Provider = "zhipu" ProviderDeepseek Provider = "deepseek" ProviderMinimax Provider = "minimax" + ProviderOpencodeGo Provider = "opencode_go" ) func (pr Provider) String() string { @@ -192,7 +193,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo: return nil default: return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go index 2913cf8da9..d5ce2cc890 100644 --- a/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go +++ b/backend/ent/channelmonitorrequesttemplate/channelmonitorrequesttemplate.go @@ -109,6 +109,7 @@ const ( ProviderZhipu Provider = "zhipu" ProviderDeepseek Provider = "deepseek" ProviderMinimax Provider = "minimax" + ProviderOpencodeGo Provider = "opencode_go" ) func (pr Provider) String() string { @@ -118,7 +119,7 @@ func (pr Provider) String() string { // ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save. func ProviderValidator(pr Provider) error { switch pr { - case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax: + case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo: return nil default: return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 6167725598..ceefcc684f 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -623,7 +623,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}}, {Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"}, {Name: "account_id", Type: field.TypeInt64, Nullable: true}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, @@ -776,7 +776,7 @@ var ( {Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, {Name: "name", Type: field.TypeString, Size: 100}, - {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}}, + {Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}}, {Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"}, {Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""}, {Name: "extra_headers", Type: field.TypeJSON}, diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index 1f0b2713ce..0e0330fe5e 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field { MaxLen(100), field.Enum("provider"). Values("openai", "anthropic", "gemini", "grok", - "antigravity", "kimi", "zhipu", "deepseek", "minimax"), + "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"), // check_mode: 'probe' | 'quota' | 'quota_probe' // probe - LLM 探活(默认,原有行为) // quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空) diff --git a/backend/ent/schema/channel_monitor_request_template.go b/backend/ent/schema/channel_monitor_request_template.go index 80e41c6259..3fe78fe3e2 100644 --- a/backend/ent/schema/channel_monitor_request_template.go +++ b/backend/ent/schema/channel_monitor_request_template.go @@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field { MaxLen(100), field.Enum("provider"). Values("openai", "anthropic", "gemini", "grok", - "antigravity", "kimi", "zhipu", "deepseek", "minimax"), + "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"), field.String("api_mode"). Default("chat_completions"). MaxLen(32). diff --git a/backend/ent/schema/user_platform_quota.go b/backend/ent/schema/user_platform_quota.go index abe4b2fd8d..20c0b3aa14 100644 --- a/backend/ent/schema/user_platform_quota.go +++ b/backend/ent/schema/user_platform_quota.go @@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field { // 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。 switch s { case "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "minimax": + "kimi", "zhipu", "deepseek", "minimax", "opencode_go": return nil default: return fmt.Errorf("platform %q is not allowed", s) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index f5853682ac..6c4b3748cf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2048,6 +2048,7 @@ func setDefaults() { "open.bigmodel.cn", "api.minimaxi.com", // MiniMax CN quota + inference "api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site + "opencode.ai", "generativelanguage.googleapis.com", "cloudcode-pa.googleapis.com", "*.openai.azure.com", diff --git a/backend/internal/domain/channel_monitor_quota.go b/backend/internal/domain/channel_monitor_quota.go index 1b93cd3942..7d5955ba3f 100644 --- a/backend/internal/domain/channel_monitor_quota.go +++ b/backend/internal/domain/channel_monitor_quota.go @@ -19,7 +19,8 @@ import "time" // - "7d" 7 天窗口(Claude/Codex) // - "7d-sonnet" Claude 7 天 Sonnet 独立额度 // - "7d-fable" Claude 7 天 Fable 独立额度 -// - "weekly" 周窗口(Kimi/Zhipu coding plan) +// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go) +// - "monthly" 月窗口(OpenCode Go) // - "daily" 日窗口(Gemini 日配额 / Grok 日请求) // - "30d" 30 天窗口(Grok 月度) // - "total" 无窗口语义的总量额度(Antigravity per-model 等) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 266bfa49b2..69080168c4 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -24,11 +24,14 @@ const ( PlatformAntigravity = "antigravity" PlatformGrok = "grok" // 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。 - PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) - PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) - PlatformDeepseek = "deepseek" // DeepSeek - PlatformMiniMax = "minimax" // MiniMax (M 系列) - PlatformComposite = "composite" + PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) + PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) + PlatformDeepseek = "deepseek" // DeepSeek + PlatformMiniMax = "minimax" // MiniMax (M 系列) + // PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。 + // 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。 + PlatformOpenCodeGo = "opencode_go" + PlatformComposite = "composite" ) // Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。 @@ -36,6 +39,8 @@ const ( const ( AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却 AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly) + AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1 + AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1 ) // API protocol constants 国产供应商的上游 API 协议维度。存储于 diff --git a/backend/internal/handler/admin/channel_handler.go b/backend/internal/handler/admin/channel_handler.go index e6612f5a0d..3b65ac97a8 100644 --- a/backend/internal/handler/admin/channel_handler.go +++ b/backend/internal/handler/admin/channel_handler.go @@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{ service.PlatformZhipu: "zhipu", service.PlatformDeepseek: "deepseek", service.PlatformMiniMax: "minimax", + service.PlatformOpenCodeGo: "opencode-go", } // SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表 diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index e0fdb46842..3cb8d4c1d5 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch type channelMonitorCreateRequest struct { Name string `json:"name" binding:"required,max=100"` - Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"` + Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"` APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint string `json:"endpoint" binding:"omitempty,max=500"` APIKey string `json:"api_key" binding:"omitempty,max=2000"` @@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct { type channelMonitorUpdateRequest struct { Name *string `json:"name" binding:"omitempty,max=100"` - Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"` + Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"` APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"` Endpoint *string `json:"endpoint" binding:"omitempty,max=500"` APIKey *string `json:"api_key" binding:"omitempty,max=2000"` diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 638316a324..4af0492550 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -258,7 +258,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` @@ -332,7 +332,7 @@ type UpdateGroupRequest struct { type CompositeRouteRequest struct { PublicModel string `json:"public_model" binding:"required"` MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"` - TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"` + TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"` UpstreamModel string `json:"upstream_model"` Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"` Priority int `json:"priority"` diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index 38452ec286..0c820868b4 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -12,9 +12,9 @@ import ( "github.com/stretchr/testify/require" ) -// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验 -// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致 -// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。 +// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的 +// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加, +// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。 func bindGroupPlatformJSON(t *testing.T, target any, body string) error { t.Helper() gin.SetMode(gin.TestMode) @@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error { func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) { allowed := []string{ "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "minimax", "composite", + "kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite", } for _, platform := range allowed { t.Run("create_"+platform, func(t *testing.T) { @@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { } func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { - for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} { + for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} { var req CompositeRouteRequest body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) require.NoError(t, bindGroupPlatformJSON(t, &req, body)) require.Equal(t, platform, req.TargetPlatform) } } + +func TestCompositeRouteTargetPlatform_RejectsComposite(t *testing.T) { + var req CompositeRouteRequest + require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"composite"}`)) +} diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index b45b101d89..c1bde61d63 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -315,7 +315,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string { // OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。 // 这对 force_chat_completions 的错误路径尤为重要:此时可能没有 // ForwardResult,不能把入站 /v1/responses 误报成上游端点。 - if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) { + if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) { if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" { return endpoint } diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index f87b27fcd1..aaa7e9f8fd 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) { result: &service.OpenAIForwardResult{}, want: EndpointResponses, }, + { + name: "opencode go conversion result reports responses", + account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey}, + result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses}, + want: EndpointResponses, + }, + { + name: "opencode go empty result without runtime stays inbound", + account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey}, + result: &service.OpenAIForwardResult{}, + want: EndpointChatCompletions, + }, } for _, tt := range tests { diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 66fdd616b1..327a00e069 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1269,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * seen := make(map[string]struct{}) models := make([]string, 0) schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID) - for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} { + for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) if len(platformModels) == 0 { // CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的 // default 分支是 Claude 列表),composite 下只暴露账号映射键。 - if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) { + if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) { platformModels = defaultModelIDsForPlatform(platform) } } @@ -1451,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string { return claude.DefaultModelIDs() case service.PlatformGrok: return xai.DefaultModelIDs() + case service.PlatformOpenCodeGo: + return service.DefaultOpenCodeGoModelIDs() case service.PlatformComposite: ids := make([]string, 0) seen := make(map[string]struct{}) - for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} { + for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} { for _, id := range defaultModelIDsForPlatform(concretePlatform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index 463feb276c..b4bc132058 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -18,7 +18,7 @@ import ( func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) { require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行") - for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} { + for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformGrok} { apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}} require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey), "%s 分组必须豁免 allow_messages_dispatch 闸门", platform) diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index d327710423..3d844c220d 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -305,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe // 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权—— // 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false, // 若不豁免,CN 分组将永远 403。 - if service.IsCNProvider(apiKey.Group.Platform) { + if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) { return true } - // composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免; + // composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免; // 解析到 openai 目标则受 composite 分组自身的可配置开关控制。 if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && - (platform == service.PlatformGrok || service.IsCNProvider(platform)) { + (platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) { return true } } @@ -322,7 +322,8 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool { return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax) + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformMiniMax, service.PlatformOpenCodeGo) } // isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket diff --git a/backend/internal/model/error_passthrough_rule.go b/backend/internal/model/error_passthrough_rule.go index c0cdfc4c6c..a0c81934d7 100644 --- a/backend/internal/model/error_passthrough_rule.go +++ b/backend/internal/model/error_passthrough_rule.go @@ -45,6 +45,7 @@ const ( PlatformZhipu = domain.PlatformZhipu PlatformDeepseek = domain.PlatformDeepseek PlatformMiniMax = domain.PlatformMiniMax + PlatformOpenCodeGo = domain.PlatformOpenCodeGo ) // AllPlatforms 返回所有支持的平台列表 @@ -59,6 +60,7 @@ func AllPlatforms() []string { PlatformZhipu, PlatformDeepseek, PlatformMiniMax, + PlatformOpenCodeGo, } } diff --git a/backend/internal/model/error_passthrough_rule_test.go b/backend/internal/model/error_passthrough_rule_test.go index 7b69b1cf21..34ceb414e8 100644 --- a/backend/internal/model/error_passthrough_rule_test.go +++ b/backend/internal/model/error_passthrough_rule_test.go @@ -17,5 +17,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) { "zhipu", "deepseek", "minimax", + "opencode_go", }, AllPlatforms()) } diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 7ef6745960..74bf0c3550 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1181,7 +1181,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"opencode_go":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 76d118c725..bd6e5e7ebe 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -48,7 +48,8 @@ func RegisterGatewayRoutes( isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool { switch getGroupPlatform(c) { case service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax: + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, + service.PlatformMiniMax, service.PlatformOpenCodeGo: // 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。 return true default: @@ -57,7 +58,7 @@ func RegisterGatewayRoutes( } countTokensHandler := func(c *gin.Context) { switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax: + case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo: h.OpenAIGateway.CountTokens(c) case service.PlatformGrok: h.OpenAIGateway.GrokCountTokens(c) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 3ff8446272..e7589f2860 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -296,9 +296,9 @@ func (a *Account) IsCNProvider() bool { // IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。 // openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions -// 兼容上游,也经 OpenAI 网关转发。 +// 兼容上游,也经 OpenAI 网关转发。OpenCode 同样经 OpenAI 网关按模型分流。 func (a *Account) IsOpenAICompatible() bool { - return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider()) + return a != nil && (a.Platform == PlatformOpenAI || a.Platform == PlatformGrok || a.IsCNProvider() || a.IsOpenCodeGo()) } func (a *Account) GeminiOAuthType() string { @@ -1333,13 +1333,13 @@ func (a *Account) IsOpenAIApiKey() bool { } // GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。 -// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL, -// 此处对 grok 返回 "" 以保持原有行为。 +// 适用 openai、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go; +// grok 走 GetGrokBaseURL,此处对 grok 返回 "" 以保持原有行为。 func (a *Account) GetOpenAIBaseURL() string { - if !a.IsOpenAI() && !a.IsCNProvider() { + if !a.IsOpenAI() && !a.IsCNProvider() && !a.IsOpenCodeGo() { return "" } - if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() { + if a.IsMultiProtocolAPIKey() && a.IsAdaptiveAPIProtocol() { if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok { if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" { return strings.TrimSpace(baseURL) @@ -1367,6 +1367,8 @@ func (a *Account) GetOpenAIBaseURL() string { return DefaultDeepseekBaseURL case PlatformMiniMax: return DefaultMiniMaxBaseURL + case PlatformOpenCodeGo: + return a.openCodeDefaultChatBaseURL() default: return "https://api.openai.com" } @@ -1395,7 +1397,7 @@ func (a *Account) IsCodingPlan() bool { // (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生 // Responses 端点,适配 Codex);zhipu 无此端点。 func (a *Account) GetAPIProtocol() string { - if a == nil || !a.IsCNProvider() { + if a == nil || !a.IsMultiProtocolAPIKey() { return APIProtocolChatCompletions } switch strings.TrimSpace(a.GetCredential("api_protocol")) { @@ -1410,6 +1412,9 @@ func (a *Account) GetAPIProtocol() string { case APIProtocolChatCompletions: return APIProtocolChatCompletions } + if a.IsOpenCodeGo() { + return APIProtocolAdaptive + } return APIProtocolChatCompletions } @@ -1421,7 +1426,7 @@ func (a *Account) SupportsNativeCNResponses() bool { return false } switch a.Platform { - case PlatformDeepseek, PlatformKimi, PlatformMiniMax: + case PlatformDeepseek, PlatformKimi, PlatformMiniMax, PlatformOpenCodeGo: return true default: return false @@ -1451,7 +1456,7 @@ func (a *Account) IsAdaptiveAPIProtocol() bool { // adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和 // account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。 func (a *Account) GetCNProtocolBaseURL(protocol string) string { - if a == nil || !a.IsCNProvider() { + if a == nil || !a.IsMultiProtocolAPIKey() { return "" } if a.IsAdaptiveAPIProtocol() { @@ -1484,6 +1489,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultDeepseekAnthropicBaseURL case PlatformMiniMax: return DefaultMiniMaxAnthropicBaseURL + case PlatformOpenCodeGo: + return a.openCodeDefaultAnthropicBaseURL() } case APIProtocolChatCompletions, APIProtocolResponses: switch a.Platform { @@ -1501,6 +1508,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string { return DefaultDeepseekBaseURL case PlatformMiniMax: return DefaultMiniMaxBaseURL + case PlatformOpenCodeGo: + return a.openCodeDefaultChatBaseURL() } } return "" @@ -1539,6 +1548,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string { return DefaultDeepseekAnthropicBaseURL case PlatformMiniMax: return DefaultMiniMaxAnthropicBaseURL + case PlatformOpenCodeGo: + return a.openCodeDefaultAnthropicBaseURL() default: return "" } @@ -1568,6 +1579,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string { return DefaultDeepseekBaseURL case PlatformMiniMax: return DefaultMiniMaxBaseURL + case PlatformOpenCodeGo: + return a.openCodeDefaultChatBaseURL() default: return a.GetOpenAIBaseURL() } @@ -1576,7 +1589,7 @@ func (a *Account) GetOpenAIFormatBaseURL() string { // GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。 // 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。 func (a *Account) GetCNAPIKey() string { - if a == nil || !a.IsCNProvider() { + if a == nil || !a.IsMultiProtocolAPIKey() { return "" } return a.GetCredential("api_key") @@ -1586,7 +1599,13 @@ func (a *Account) GetCNAPIKey() string { // 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。 // 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。 func (a *Account) GetCodingPlanProvider() string { - if a == nil || a.GetAccountMode() != AccountModeCoding { + if a == nil { + return "" + } + if a.IsOpenCodeGoPlan() { + return PlatformOpenCodeGo + } + if a.GetAccountMode() != AccountModeCoding { return "" } baseURL := strings.ToLower(a.GetOpenAIBaseURL()) @@ -1715,14 +1734,15 @@ func (a *Account) GetOpenAIApiKey() string { } // GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。 -// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号, -// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上 -// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。 +// 覆盖 openai 原生账号、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek) +// 以及 OpenCode Go 账号,供转发鉴权、模型列表同步等协议族共用路径使用。 +// 注意 IsOpenAIApiKey 语义上仅指 openai 平台账号,调度倍率/WS 能力门控 +// 继续以其为准,不受本方法影响。 func (a *Account) GetOpenAIProtocolAPIKey() string { if a == nil { return "" } - if a.IsCNProvider() { + if a.IsMultiProtocolAPIKey() { if a.Type != AccountTypeAPIKey { return "" } diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 3118d159fc..7f05e253ac 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool { return false } switch a.Platform { - case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: return a.Type == AccountTypeAPIKey case PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth diff --git a/backend/internal/service/account_header_override_test.go b/backend/internal/service/account_header_override_test.go index 9d99257f28..e90051df28 100644 --- a/backend/internal/service/account_header_override_test.go +++ b/backend/internal/service/account_header_override_test.go @@ -30,11 +30,13 @@ func TestIsHeaderOverrideEligible(t *testing.T) { {"kimi apikey", PlatformKimi, AccountTypeAPIKey, true}, {"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true}, {"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true}, + {"opencode go apikey", PlatformOpenCodeGo, AccountTypeAPIKey, true}, {"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false}, {"openai oauth", PlatformOpenAI, AccountTypeOAuth, false}, {"kimi oauth", PlatformKimi, AccountTypeOAuth, false}, {"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false}, {"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false}, + {"opencode go oauth", PlatformOpenCodeGo, AccountTypeOAuth, false}, {"gemini apikey", PlatformGemini, AccountTypeAPIKey, false}, {"grok apikey", PlatformGrok, AccountTypeAPIKey, true}, {"grok oauth", PlatformGrok, AccountTypeOAuth, true}, diff --git a/backend/internal/service/account_scheduling_threshold_eval.go b/backend/internal/service/account_scheduling_threshold_eval.go index fb0696bed8..345f997a15 100644 --- a/backend/internal/service/account_scheduling_threshold_eval.go +++ b/backend/internal/service/account_scheduling_threshold_eval.go @@ -58,7 +58,7 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string] winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now) case PlatformGrok: winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now) - case PlatformKimi, PlatformZhipu, PlatformMiniMax: + case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo: winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now) default: return decision @@ -356,10 +356,14 @@ func cnProviderThresholdCandidates(account *Account, provider string) []*account if account == nil || len(account.Extra) == 0 { return nil } - return []*accountSchedulingThresholdCandidate{ + candidates := []*accountSchedulingThresholdCandidate{ cnThresholdCandidate(account.Extra, provider, "5h"), cnThresholdCandidate(account.Extra, provider, "weekly"), } + if provider == PlatformOpenCodeGo { + candidates = append(candidates, cnThresholdCandidate(account.Extra, provider, "monthly")) + } + return candidates } func cnThresholdCandidate(extra map[string]any, provider, window string) *accountSchedulingThresholdCandidate { @@ -371,6 +375,9 @@ func cnThresholdCandidate(extra map[string]any, provider, window string) *accoun case "weekly": usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed) resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset) + case "monthly": + usedKey = cnExtraKey(provider, cnExtraSuffixMonthlyUsed) + resetKey = cnExtraKey(provider, cnExtraSuffixMonthlyReset) default: return nil } diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index d7fac72223..205907abc1 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -515,8 +515,8 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error { case PlatformGrok: // Grok OAuth credentials are validated via token exchange/refresh and request-path probes. return nil - case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: - // 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 + case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: + // 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。 return nil default: return fmt.Errorf("unsupported platform: %s", account.Platform) diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 44e29dfc1e..46dea6b7f7 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -381,9 +381,55 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.routeAntigravityTest(c, account, modelID, prompt) } + if account.IsOpenCodeGo() { + return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt) + } + return s.testClaudeAccountConnection(c, account, modelID) } +// testOpenCodeGoAccountConnection probes the native endpoint for the selected +// model. Adaptive accounts (the default) follow OpenCodeGoModelProtocol: +// grok/gpt/muse-spark → Responses, minimax/qwen → Anthropic, everything else +// (including deepseek-v4-flash) → Chat Completions. A pinned api_protocol +// overrides that catalog. Falling through to the generic Claude tester used +// credentials.base_url + /v1/messages?beta=true, which 404s as HTML on +// https://opencode.ai/zen/go/v1/v1/messages. +func (s *AccountTestService) testOpenCodeGoAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error { + testModelID := strings.TrimSpace(modelID) + if testModelID == "" { + testModelID = DefaultOpenCodeGoTestModel + } + proto := account.GetAPIProtocol() + switch proto { + case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: + default: + proto = openCodeGoNativeProtocol(account, testModelID) + } + switch proto { + case APIProtocolAnthropic: + return s.testCNProviderAnthropicConnection(c, account, testModelID) + case APIProtocolResponses: + return s.testOpenCodeGoResponsesConnection(c, account, testModelID) + default: + return s.testCNProviderChatCompletionsConnection(c, account, testModelID, prompt) + } +} + +func (s *AccountTestService) testOpenCodeGoResponsesConnection(c *gin.Context, account *Account, testModelID string) error { + authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) + if authToken == "" { + return s.sendErrorAndEnd(c, "No API key available") + } + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Writer.Flush() + s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID}) + return s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken) +} + func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error { testModelID := strings.TrimSpace(modelID) if testModelID == "" { @@ -2054,6 +2100,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection( // 账号级请求头覆写:测试请求与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index c0491fce4f..a68a691d27 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -85,6 +85,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co // base_url 强制 Bearer,其余保持 extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic)) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { @@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co req.Header.Set("Authorization", "Bearer "+authToken) applyOpenAICodexProbeHeaders(req.Header) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { @@ -266,6 +268,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a // extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL()) account.ApplyHeaderOverrides(req.Header) + applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes) resp, err := s.doCNProviderAdaptiveRequest(req, account) if err != nil { diff --git a/backend/internal/service/account_test_service_opencode_go_test.go b/backend/internal/service/account_test_service_opencode_go_test.go new file mode 100644 index 0000000000..837267149a --- /dev/null +++ b/backend/internal/service/account_test_service_opencode_go_test.go @@ -0,0 +1,105 @@ +//go:build unit + +package service + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func openCodeGoTestAccount(id int64) *Account { + return &Account{ + ID: id, + Name: "oc", + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Status: StatusActive, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-opencode-go-test", + "api_protocol": APIProtocolAdaptive, + "base_url": "https://opencode.ai/zen/go/v1", + "api_base_urls": map[string]any{ + APIProtocolChatCompletions: "https://opencode.ai/zen/go/v1", + APIProtocolAnthropic: "https://opencode.ai/zen/go", + APIProtocolResponses: "https://opencode.ai/zen/go/v1", + }, + }, + } +} + +func TestAccountTestService_OpenCodeGoDeepSeekFlashUsesChatCompletions(t *testing.T) { + account := openCodeGoTestAccount(401) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "deepseek-v4-flash", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "Bearer sk-opencode-go-test", upstream.requests[0].Header.Get("Authorization")) + require.NotEmpty(t, upstream.requests[0].Header.Get("X-OpenCode-Session")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) + require.NotContains(t, upstream.requests[0].URL.Path, "/messages") +} + +func TestAccountTestService_OpenCodeGoGrokUsesResponses(t *testing.T) { + account := openCodeGoTestAccount(402) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "grok-4.6", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://opencode.ai/zen/go/v1/responses", upstream.requests[0].URL.String()) + require.Equal(t, "Bearer sk-opencode-go-test", upstream.requests[0].Header.Get("Authorization")) + require.NotEmpty(t, upstream.requests[0].Header.Get("X-OpenCode-Session")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_OpenCodeGoMiniMaxUsesAnthropicMessages(t *testing.T) { + account := openCodeGoTestAccount(403) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNAnthropicTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "minimax-m3", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + req := upstream.requests[0] + require.Equal(t, "https://opencode.ai/zen/go/v1/messages", req.URL.String()) + require.Empty(t, req.URL.RawQuery) + require.Equal(t, "sk-opencode-go-test", req.Header.Get("x-api-key")) + require.NotEmpty(t, req.Header.Get("X-OpenCode-Session")) + require.Contains(t, recorder.Body.String(), `"type":"test_complete"`) +} + +func TestAccountTestService_OpenCodeGoPinnedChatProtocolIgnoresCatalog(t *testing.T) { + account := openCodeGoTestAccount(404) + account.Credentials["api_protocol"] = APIProtocolChatCompletions + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, _ := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "grok-4.6", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String()) +} + +func TestAccountTestService_OpenCodeGoEmptyModelDefaultsToChatCatalogID(t *testing.T) { + account := openCodeGoTestAccount(405) + svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse()) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "", "hi", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 1) + require.Equal(t, "https://opencode.ai/zen/go/v1/chat/completions", upstream.requests[0].URL.String()) + require.True(t, strings.Contains(recorder.Body.String(), DefaultOpenCodeGoTestModel)) +} diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index d7c02e3f50..5d778b75ce 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -323,6 +323,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + return nil, err + } duplicate, err := buildAccountForCreate(input, accountExtra) if err != nil { return nil, err @@ -514,6 +517,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + return nil, err + } // Never persist ephemeral SSO/password secrets after OAuth conversion. input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials) @@ -640,6 +646,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { return nil, err } + if err := NormalizeOpenCodeGoProtocolRulesCredentials(account.Credentials); err != nil { + return nil, err + } // Strip SSO/password residue that must never sit next to OAuth tokens. account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials) } @@ -1065,6 +1074,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { return nil, err } + if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil { + return nil, err + } // Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie // only when platform is known Grok — empty platform still strips password/*). if input.Credentials != nil { diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index fd28f681cb..6444077585 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -294,6 +294,8 @@ func defaultModelsListCandidateIDs(platform string) []string { return ids case PlatformGrok: return xai.DefaultModelIDs() + case PlatformOpenCodeGo: + return DefaultOpenCodeGoModelIDs() case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: @@ -314,7 +316,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool { func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) - for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { + for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue diff --git a/backend/internal/service/channel_monitor_quota_fetcher.go b/backend/internal/service/channel_monitor_quota_fetcher.go index 4272ea640d..868def18d3 100644 --- a/backend/internal/service/channel_monitor_quota_fetcher.go +++ b/backend/internal/service/channel_monitor_quota_fetcher.go @@ -221,6 +221,8 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI return f.fetchCNQuota(ctx, account, now) } return f.fetchCNBalance(ctx, account, now) + case domain.PlatformOpenCodeGo: + return f.fetchCNQuota(ctx, account, now) default: return f.fetchUsage(ctx, account, now) } diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 085b777c0c..c30e0fd391 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -215,6 +215,8 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string { // - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行 func monitorAccountQuotaCapability(account *Account) error { switch account.Platform { + case PlatformOpenCodeGo: + return nil case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: if account.IsCodingPlan() { if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax { diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 60d116f8eb..0d34f44014 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -367,7 +367,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool { // fallback used before a request target has been resolved. func matchingPlatforms(groupPlatform string) []string { if groupPlatform == PlatformComposite { - return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} + return []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} } return []string{groupPlatform} } diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index 2698da061c..9b85aa3e00 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}}, } for _, tt := range tests { diff --git a/backend/internal/service/cn_provider_quota_service.go b/backend/internal/service/cn_provider_quota_service.go index a78102c306..48fe0a6803 100644 --- a/backend/internal/service/cn_provider_quota_service.go +++ b/backend/internal/service/cn_provider_quota_service.go @@ -35,6 +35,8 @@ const ( cnExtraSuffix5hReset = "5h_reset_at" cnExtraSuffixWeeklyUsed = "weekly_used_percent" cnExtraSuffixWeeklyReset = "weekly_reset_at" + cnExtraSuffixMonthlyUsed = "monthly_used_percent" + cnExtraSuffixMonthlyReset = "monthly_reset_at" cnExtraSuffixUsageUpdated = "usage_updated_at" ) @@ -43,7 +45,7 @@ func cnExtraKey(provider, suffix string) string { return provider + "_" + suffix // CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。 type CNQuotaTier struct { - Window string `json:"window"` // "5h" | "weekly" + Window string `json:"window"` // "5h" | "weekly" | "monthly" UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪) ResetAt string `json:"reset_at,omitempty"` // RFC3339,空表示无重置时间 } @@ -129,8 +131,8 @@ func (s *CNProviderQuotaService) QueryUsageForAccount(ctx context.Context, accou func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) { provider := account.GetCodingPlanProvider() - if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax { - return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan account") + if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax && provider != PlatformOpenCodeGo { + return nil, infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "account is not a kimi/zhipu/minimax coding plan or opencode go account") } apiKey := strings.TrimSpace(account.GetCNAPIKey()) @@ -148,6 +150,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou case PlatformKimi: targetURL = kimiQuotaURL(baseURL) authHeader = "Bearer " + apiKey + case PlatformOpenCodeGo: + targetURL = openCodeGoQuotaURL(baseURL) + authHeader = "Bearer " + apiKey case PlatformZhipu: targetURL = zhipuQuotaURL(baseURL) authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀 @@ -234,6 +239,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou switch provider { case PlatformKimi: tiers = parseKimiUsageTiers(bodyBytes) + case PlatformOpenCodeGo: + tiers = parseOpenCodeGoUsageTiers(bodyBytes) + result.PlanLevel = "OpenCode Go" case PlatformZhipu: tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data")) result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String()) @@ -279,6 +287,12 @@ func validateCodingPlanAccount(account *Account) error { if account == nil { return infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found") } + if account.IsOpenCodeGoPlan() { + return nil + } + if account.IsOpenCodeGo() { + return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_NOT_CODING_PLAN", "opencode zen accounts have no subscription quota window") + } if !account.IsCNProvider() { return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account") } @@ -614,11 +628,61 @@ func cnQuotaExtraUpdates(provider string, tiers []CNQuotaTier, now time.Time) ma if t.ResetAt != "" { updates[cnExtraKey(provider, cnExtraSuffixWeeklyReset)] = t.ResetAt } + case "monthly": + updates[cnExtraKey(provider, cnExtraSuffixMonthlyUsed)] = t.UsedPercent + if t.ResetAt != "" { + updates[cnExtraKey(provider, cnExtraSuffixMonthlyReset)] = t.ResetAt + } } } return updates } +// parseOpenCodeGoUsageTiers 解析 OpenCode Go GET /usage 响应。 +// +// 结构对齐 cc-switch extractor: +// +// { "usage": { "rolling": {percent, resetsAt}, "weekly": {...}, "monthly": {...} } } +// +// percent 为已用百分比(0-100);rolling 映射为 5h 窗口。 +func parseOpenCodeGoUsageTiers(body []byte) []CNQuotaTier { + usage := gjson.GetBytes(body, "usage") + if !usage.Exists() { + return nil + } + var tiers []CNQuotaTier + for _, item := range []struct { + key string + window string + }{ + {key: "rolling", window: "5h"}, + {key: "weekly", window: "weekly"}, + {key: "monthly", window: "monthly"}, + } { + node := usage.Get(item.key) + if !node.Exists() { + continue + } + percentNode := node.Get("percent") + if !percentNode.Exists() { + continue + } + used, ok := cnParseF64(percentNode.Value()) + if !ok { + continue + } + if used < 0 { + used = 0 + } + tiers = append(tiers, CNQuotaTier{ + Window: item.window, + UsedPercent: used, + ResetAt: cnNormalizeResetTime(node.Get("resetsAt").Value()), + }) + } + return tiers +} + // cnParseF64 把 JSON 数值或字符串解析为 float64(兼容 "100" 与 100)。 func cnParseF64(raw any) (float64, bool) { switch v := raw.(type) { diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 8f9e7b8996..7165b29537 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -177,6 +177,34 @@ func TestCNQuotaExtraUpdates(t *testing.T) { require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"]) } +func TestParseOpenCodeGoUsageTiers(t *testing.T) { + t.Parallel() + body := []byte(`{ + "usage": { + "rolling": {"percent": 12.5, "resetsAt": "2026-09-07T12:00:00Z"}, + "weekly": {"percent": 40, "resetsAt": "2026-09-10T00:00:00Z"}, + "monthly": {"percent": 22.2, "resetsAt": "2026-10-01T00:00:00Z"} + } + }`) + tiers := parseOpenCodeGoUsageTiers(body) + require.Len(t, tiers, 3) + require.Equal(t, "5h", tiers[0].Window) + require.Equal(t, 12.5, tiers[0].UsedPercent) + require.Equal(t, "2026-09-07T12:00:00Z", tiers[0].ResetAt) + require.Equal(t, "weekly", tiers[1].Window) + require.Equal(t, 40.0, tiers[1].UsedPercent) + require.Equal(t, "monthly", tiers[2].Window) + require.InDelta(t, 22.2, tiers[2].UsedPercent, 0.001) + + updates := cnQuotaExtraUpdates(PlatformOpenCodeGo, tiers, time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC)) + require.Equal(t, 12.5, updates["opencode_go_5h_used_percent"]) + require.Equal(t, 40.0, updates["opencode_go_weekly_used_percent"]) + require.Equal(t, 22.2, updates["opencode_go_monthly_used_percent"]) + require.Equal(t, "2026-10-01T00:00:00Z", updates["opencode_go_monthly_reset_at"]) + + require.Empty(t, parseOpenCodeGoUsageTiers([]byte(`{"ok":true}`))) +} + // TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。 func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) { t.Parallel() @@ -502,6 +530,7 @@ func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) { require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi)) require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu)) require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek)) + require.Equal(t, PlatformOpenCodeGo, NormalizeOpenAICompatiblePlatform(PlatformOpenCodeGo)) // 其他平台(含空、anthropic、未知)一律归一为 openai。 require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("")) require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic)) @@ -536,6 +565,14 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) { Credentials: map[string]any{"api_key": "sk-openai"}, } require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey()) + + openCodeGo := &Account{ + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-opencode-go"}, + } + require.Equal(t, "sk-opencode-go", openCodeGo.GetOpenAIProtocolAPIKey()) + require.False(t, openCodeGo.IsOpenAIApiKey()) } // TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用: @@ -557,6 +594,8 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { {"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"}, {"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"}, {"minimax coding", PlatformMiniMax, AccountModeCoding, "https://api.minimaxi.com/v1/models"}, + {"opencode go", PlatformOpenCodeGo, AccountModeGo, "https://opencode.ai/zen/go/v1/models"}, + {"opencode zen", PlatformOpenCodeGo, AccountModeZen, "https://opencode.ai/zen/v1/models"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -601,6 +640,8 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") + require.Equal(t, APIProtocolAdaptive, mk(PlatformOpenCodeGo, "").GetAPIProtocol(), "opencode go 默认 adaptive") + require.Equal(t, APIProtocolResponses, mk(PlatformOpenCodeGo, APIProtocolResponses).GetAPIProtocol()) } func TestSupportsNativeCNResponses(t *testing.T) { @@ -609,6 +650,7 @@ func TestSupportsNativeCNResponses(t *testing.T) { require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses()) require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses()) require.True(t, (&Account{Platform: PlatformMiniMax}).SupportsNativeCNResponses()) + require.True(t, (&Account{Platform: PlatformOpenCodeGo}).SupportsNativeCNResponses()) require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses()) require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses()) } @@ -631,6 +673,8 @@ func TestAdaptiveProtocolBaseURLs(t *testing.T) { {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL}, {"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL}, {"minimax coding", PlatformMiniMax, AccountModeCoding, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL}, + {"opencode go", PlatformOpenCodeGo, AccountModeGo, DefaultOpenCodeGoBaseURL, DefaultOpenCodeGoAnthropicBaseURL, DefaultOpenCodeGoBaseURL}, + {"opencode zen", PlatformOpenCodeGo, AccountModeZen, DefaultOpenCodeZenBaseURL, DefaultOpenCodeZenAnthropicBaseURL, DefaultOpenCodeZenBaseURL}, } for _, tc := range cases { diff --git a/backend/internal/service/composite_platform.go b/backend/internal/service/composite_platform.go index fc44428039..6ddb9cfa9a 100644 --- a/backend/internal/service/composite_platform.go +++ b/backend/internal/service/composite_platform.go @@ -202,7 +202,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou func isConcreteRequestPlatform(platform string) bool { switch platform { case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: return true default: return false diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index 2ca27c5951..20505a3985 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -216,13 +216,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}, platforms, ) } func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { require.True(t, isConcreteRequestPlatform(platform)) require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform)) } diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3e0cb28c5b..5b22a92a65 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -44,11 +44,12 @@ const ( PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok // 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。 - PlatformKimi = domain.PlatformKimi - PlatformZhipu = domain.PlatformZhipu - PlatformDeepseek = domain.PlatformDeepseek - PlatformMiniMax = domain.PlatformMiniMax - PlatformComposite = domain.PlatformComposite + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek + PlatformMiniMax = domain.PlatformMiniMax + PlatformOpenCodeGo = domain.PlatformOpenCodeGo + PlatformComposite = domain.PlatformComposite // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. PlatformKiro = "kiro" @@ -58,6 +59,8 @@ const ( const ( AccountModePayG = domain.AccountModePayG AccountModeCoding = domain.AccountModeCoding + AccountModeZen = domain.AccountModeZen + AccountModeGo = domain.AccountModeGo ) // 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。 @@ -78,6 +81,10 @@ const ( DefaultDeepseekBaseURL = "https://api.deepseek.com" // MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。 DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1" + // OpenCode Go:Chat Completions / Responses / models 共用 /v1 基址。 + DefaultOpenCodeGoBaseURL = "https://opencode.ai/zen/go/v1" + // OpenCode Zen:按量付费网关,模型列表为 /zen/v1/models。 + DefaultOpenCodeZenBaseURL = "https://opencode.ai/zen/v1" ) // 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。 @@ -88,6 +95,9 @@ const ( DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic" DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic" DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.com/anthropic" + // OpenCode Go Anthropic 基址不含 /v1:nativeAnthropicTargetURL 会再拼 /v1/messages。 + DefaultOpenCodeGoAnthropicBaseURL = "https://opencode.ai/zen/go" + DefaultOpenCodeZenAnthropicBaseURL = "https://opencode.ai/zen" ) // IsCNProvider 报告 platform 是否为国产 OpenAI 兼容供应商(kimi/zhipu/deepseek/minimax)。 @@ -100,6 +110,17 @@ func IsCNProvider(platform string) bool { } } +// IsOpenCodeGo 报告 platform 是否为 OpenCode Go 订阅网关。 +func IsOpenCodeGo(platform string) bool { + return platform == PlatformOpenCodeGo +} + +// IsMultiProtocolAPIKeyProvider 报告 platform 是否为多协议 API Key 网关 +// (国产供应商 + OpenCode):走 OpenAI 网关、支持 adaptive 协议分流。 +func IsMultiProtocolAPIKeyProvider(platform string) bool { + return IsCNProvider(platform) || platform == PlatformOpenCodeGo +} + // AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 // ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束), // 若新增平台需同步修改该 schema。 @@ -113,6 +134,7 @@ var AllowedQuotaPlatforms = []string{ PlatformZhipu, PlatformDeepseek, PlatformMiniMax, + PlatformOpenCodeGo, } // AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。 @@ -125,6 +147,7 @@ var AllowedSchedulingThresholdPlatforms = []string{ PlatformKimi, PlatformZhipu, PlatformMiniMax, + PlatformOpenCodeGo, } // IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。 diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 68640507df..e405bbee03 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -223,7 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( // 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用, // 使配置值获得除共享传输层强制头之外的最高优先级。 account.ApplyHeaderOverrides(upstreamReq.Header) - applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header) + applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header, body) proxyURL := "" if account.ProxyID != nil && account.Proxy != nil { diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 403752baf5..37d83dc191 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -71,6 +71,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( defaultMappedModel string, compatPromptCacheTenantIsolated bool, ) (*OpenAIForwardResult, error) { + rememberOpenCodeInboundBody(c, body) beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { @@ -113,10 +114,43 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // accounts never forward the body unchanged to a Chat Completions endpoint. isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() + // OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。 + // 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。 + if account.IsOpenCodeGo() { + proto := openCodeGoNativeProtocol(account, resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)) + if proto != APIProtocolResponses { + if isResponsesShape { + if proto == APIProtocolAnthropic { + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") + } + var responsesReq apicompat.ResponsesRequest + if err := json.Unmarshal(body, &responsesReq); err != nil { + return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) + } + chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions( + &responsesReq, + &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID}, + ) + if err != nil { + return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err) + } + chatBody, err := json.Marshal(chatReq) + if err != nil { + return nil, fmt.Errorf("marshal converted chat completions request: %w", err) + } + return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) + } + if proto == APIProtocolAnthropic { + return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) + } + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + } + // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 // Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM // 没有 Responses 端点,先转换成 Chat Completions 再直转。 - if account.IsAdaptiveAPIProtocol() { + if account.IsAdaptiveAPIProtocol() && !account.IsOpenCodeGo() { if !isResponsesShape { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -154,6 +188,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) startTime := time.Now() @@ -376,6 +411,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated) } if account.Type == AccountTypeAPIKey && + !account.IsOpenCodeGo() && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown && !isResponsesEndpointSupportedByStatus(resp.StatusCode) { logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions", @@ -399,6 +435,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( } else { result, handleErr = s.handleChatBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime) } + stampOpenAIResponsesUpstreamEndpoint(c, result) // cyber_policy:标记已设、error 已按 Chat Completions 格式发给客户端。丢弃 result、 // 返回哨兵,使 handler 落入 tokens=0 免费用量行(对齐 /v1/responses),不计费、不 failover。 diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index c0a209eeee..bc865af81c 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -111,7 +111,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 6837cb8236..b13892a43a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -146,6 +146,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } originalBody := body + rememberOpenCodeInboundBody(c, originalBody) requestView := newOpenAIRequestView(body) reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey originalModel := reqModel @@ -154,6 +155,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime) } + if account.IsOpenCodeGo() { + mapped := normalizeOpenAIModelForUpstream(account, resolveOpenAIForwardModel(account, originalModel, "")) + switch openCodeGoNativeProtocol(account, mapped) { + case APIProtocolAnthropic: + return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) + case APIProtocolResponses: + break + default: + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) + } + } + // CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合 // (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。 // 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。 @@ -181,6 +194,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } + SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) { normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body) if reasoningErr != nil { @@ -1317,6 +1331,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if searchCount > 0 && account != nil && account.IsGrok() { forwardResult.SearchCount = searchCount } + stampOpenAIResponsesUpstreamEndpoint(c, forwardResult) return forwardResult, nil } } @@ -1325,6 +1340,11 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { if account == nil || account.Type != AccountTypeAPIKey { return false } + if account.IsOpenCodeGo() { + // Model protocol_rules are the authority. Probe Extra must not collapse + // Grok/GPT/Muse into Chat Completions. + return false + } if account.IsCNProvider() { // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi // 有原生 Responses,GLM 回退 Chat Completions。 @@ -1485,7 +1505,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) - applyOpenCodeSessionHeader(c, account, targetURL, req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey)) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 7f3144752d..857dc590a1 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -33,6 +33,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + rememberOpenCodeInboundBody(c, body) beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { @@ -43,12 +44,22 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return nil, err } - // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, - // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 - // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 - // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 - // openai_responses_supported=false,会先命中下方的 CC 直转分支。 - if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { + // OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。 + if account.IsOpenCodeGo() { + switch openCodeGoNativeProtocol(account, resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)) { + case APIProtocolAnthropic: + return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) + case APIProtocolResponses: + break + default: + return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) + } + } else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() { + // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时, + // /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留 + // thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。 + // 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标 + // openai_responses_supported=false,会先命中下方的 CC 直转分支。 return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) } @@ -57,6 +68,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if shouldForwardOpenAIResponsesViaRawChatCompletions(account) { return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } + SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) startTime := time.Now() @@ -530,6 +542,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( notifyOpenAIAutoReset(*account.ParentAccountID) } + stampOpenAIResponsesUpstreamEndpoint(c, result) return result, handleErr } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 6624c30f5a..263eceba04 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -133,9 +133,19 @@ func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (strin if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } + if account.IsOpenCodeGo() { + // OpenCode Go 的 Chat Completions base 带 /v1;用版本感知拼接避免 /v1/v1/messages。 + return buildOpenAIEndpointURL(validatedURL, "/v1/messages"), nil + } return strings.TrimRight(validatedURL, "/") + "/v1/messages", nil } +func resolveOpenCodeGoMappedModel(account *Account, body []byte, defaultMappedModel string) string { + original := strings.TrimSpace(gjson.GetBytes(body, "model").String()) + billing := resolveOpenAIForwardModel(account, original, defaultMappedModel) + return normalizeOpenAIModelForUpstream(account, billing) +} + func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( ctx context.Context, c *gin.Context, @@ -143,6 +153,7 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( body []byte, apiKey string, targetURL string, + sessionBodies ...[]byte, ) (*http.Request, []byte, error) { // 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta // header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但 @@ -199,6 +210,8 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( // 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头) account.ApplyHeaderOverrides(req.Header) + payloads := append([][]byte{body}, sessionBodies...) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header, payloads...) return req, body, nil } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index dfebb47a50..dd8f390079 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -721,7 +721,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( // 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op) account.ApplyHeaderOverrides(req.Header) - applyOpenCodeSessionHeader(c, account, targetURL, req.Header) + applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body) // x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后, // 保证不被覆盖丢失)。 applyOpenAICodexBetaFeatures(c, account, req.Header) diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 274f0f09eb..fa2e190b97 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -116,7 +116,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( } upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream) - upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL) + upstreamReq, _, err := s.buildNativeAnthropicUpstreamRequest(upstreamCtx, c, account, anthropicBody, apiKey, targetURL, body) releaseUpstreamCtx() if err != nil { return nil, fmt.Errorf("build upstream request: %w", err) diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index a980195486..2ee6bea077 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount( // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { switch platform { - case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: return platform default: return PlatformOpenAI diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index df7e66eb87..64a380a71a 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -314,6 +314,21 @@ func (r *OpenAIForwardResult) SucceededForScheduling() bool { } } +const openAIResponsesUpstreamEndpoint = "/v1/responses" + +// stampOpenAIResponsesUpstreamEndpoint records that this attempt hit the +// Responses API. OpenCode Go / CN accounts cannot derive that from inbound +// path (DeriveUpstreamEndpoint falls back to the client URL). +func stampOpenAIResponsesUpstreamEndpoint(c *gin.Context, result *OpenAIForwardResult) { + SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint) + if result == nil { + return + } + if strings.TrimSpace(result.UpstreamEndpoint) == "" { + result.UpstreamEndpoint = openAIResponsesUpstreamEndpoint + } +} + // SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current // forwarding attempt. It covers error paths where no OpenAIForwardResult is // available for usage and operations logging. diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go index fbb0b97eb4..00f078c63f 100644 --- a/backend/internal/service/openai_opencode_session.go +++ b/backend/internal/service/openai_opencode_session.go @@ -3,28 +3,61 @@ package service import ( "net/http" "net/url" + "strconv" "strings" "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/tidwall/gjson" ) -const openCodeSessionHeader = "X-OpenCode-Session" +const ( + openCodeSessionHeader = "X-OpenCode-Session" + openCodeInboundBodyContextKey = "opencode_inbound_body" +) -// applyOpenCodeSessionHeader forwards the caller-owned conversation identifier -// only to OpenCode's official API origin. The caller applies this after account -// header overrides so a per-conversation value cannot be replaced by a fixed -// account-wide override. -func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) { - if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil { +// rememberOpenCodeInboundBody keeps the client request body so protocol +// conversion (Responses↔Anthropic↔Chat Completions) can still recover +// prompt_cache_key / metadata.user_id after those fields are dropped. +func rememberOpenCodeInboundBody(c *gin.Context, body []byte) { + if c == nil || len(body) == 0 { + return + } + c.Set(openCodeInboundBodyContextKey, body) +} + +func openCodeInboundBodies(c *gin.Context) [][]byte { + if c == nil { + return nil + } + raw, ok := c.Get(openCodeInboundBodyContextKey) + if !ok { + return nil + } + body, ok := raw.([]byte) + if !ok || len(body) == 0 { + return nil + } + return [][]byte{body} +} + +// applyOpenCodeSessionHeader sets x-opencode-session on outbound inference +// requests. OpenCode Go requires a per-conversation value from 2026-09-05 +// (MissingSessionID). Prefer caller headers, then the documented body session +// fields (OpenAI prompt_cache_key / Anthropic metadata.user_id), then any +// already applied account override. A generated UUID is last-resort only for +// probes and clients that omit every stable identifier — a new UUID each turn +// would miss upstream prompt cache. +func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header, bodies ...[]byte) { + if account == nil || account.Type != AccountTypeAPIKey || headers == nil { + return + } + if !shouldSendOpenCodeSessionHeader(account, targetURL) { return } - parsed, err := url.Parse(targetURL) - if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") { - return - } - - sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)) + payloads := append(openCodeInboundBodies(c), bodies...) + sessionID := resolveOpenCodeSessionID(c, headers, shouldGenerateOpenCodeSession(account, targetURL), payloads...) if sessionID == "" { return } @@ -35,3 +68,105 @@ func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL stri } headers.Set(openCodeSessionHeader, sessionID) } + +func shouldSendOpenCodeSessionHeader(account *Account, targetURL string) bool { + if account != nil && account.IsOpenCodeGoPlan() { + return true + } + return isOfficialOpenCodeHost(targetURL) +} + +func shouldGenerateOpenCodeSession(account *Account, targetURL string) bool { + if account != nil && account.IsOpenCodeGoPlan() { + return true + } + parsed, err := url.Parse(targetURL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Scheme, "https") && + strings.EqualFold(parsed.Hostname(), "opencode.ai") && + strings.Contains(parsed.Path, "/zen/go") +} + +func isOfficialOpenCodeHost(targetURL string) bool { + parsed, err := url.Parse(targetURL) + if err != nil { + return false + } + return strings.EqualFold(parsed.Scheme, "https") && strings.EqualFold(parsed.Hostname(), "opencode.ai") +} + +func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string { + if c != nil && c.Request != nil { + if sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)); sessionID != "" { + return sessionID + } + if sessionID := explicitOpenAIHeaderSessionID(c); sessionID != "" { + return sessionID + } + if sessionID := ClaudeCodeSessionIDFromHeader(c); sessionID != "" { + return sessionID + } + } + for _, body := range bodies { + if sessionID := openCodeSessionIDFromPayload(body); sessionID != "" { + return sessionID + } + } + if sessionID := strings.TrimSpace(existingOpenCodeSessionHeader(headers)); sessionID != "" { + return sessionID + } + if generate { + return uuid.NewString() + } + return "" +} + +// openCodeSessionIDFromPayload reads the stable conversation id from documented +// request-body fields. Kimi Code / OpenAI clients send prompt_cache_key on +// Chat Completions and Responses; Anthropic clients send metadata.user_id on +// Messages. Neither field changes model behavior. +func openCodeSessionIDFromPayload(body []byte) string { + if len(body) == 0 { + return "" + } + view := openAIRequestPayloadView(body) + if sessionID := strings.TrimSpace(view.Get("prompt_cache_key").String()); sessionID != "" { + return sessionID + } + return openCodeSessionIDFromMetadataUserID(view.Get("metadata.user_id").String()) +} + +func openCodeSessionIDFromMetadataUserID(userID string) string { + userID = strings.TrimSpace(userID) + if userID == "" { + return "" + } + if strings.HasPrefix(userID, "{") { + if sessionID := strings.TrimSpace(gjson.Get(userID, "session_id").String()); sessionID != "" { + return sessionID + } + } + return userID +} + +func openCodeSessionHintBody(promptCacheKey string) []byte { + key := strings.TrimSpace(promptCacheKey) + if key == "" { + return nil + } + return []byte(`{"prompt_cache_key":` + strconv.Quote(key) + `}`) +} + +func existingOpenCodeSessionHeader(headers http.Header) string { + if headers == nil { + return "" + } + for key, values := range headers { + if strings.EqualFold(key, openCodeSessionHeader) && len(values) > 0 { + return values[0] + } + } + return "" +} diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go index fe34a95958..93404d0e1f 100644 --- a/backend/internal/service/openai_opencode_session_test.go +++ b/backend/internal/service/openai_opencode_session_test.go @@ -92,7 +92,13 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) { incoming: "conversation-123", }, { - name: "missing caller value", + name: "missing caller value generates session on go endpoint", + account: openCodeSessionTestAccount("https://opencode.ai/zen/go/v1"), + targetURL: "https://opencode.ai/zen/go/v1/responses", + want: "", + }, + { + name: "zen endpoint does not invent a session", account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"), targetURL: "https://opencode.ai/zen/v1/responses", }, @@ -108,11 +114,105 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) { t.Run(tt.name, func(t *testing.T) { headers := make(http.Header) applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers) - require.Equal(t, tt.want, headers.Get(openCodeSessionHeader)) + got := headers.Get(openCodeSessionHeader) + if tt.want == "" { + require.NotEmpty(t, got) + return + } + require.Equal(t, tt.want, got) }) } } +func TestApplyOpenCodeSessionHeaderOpenCodeGoAlwaysSetsSession(t *testing.T) { + account := &Account{ + ID: 4, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://relay.example.com/v1", + }, + } + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://relay.example.com/v1/chat/completions", headers) + require.NotEmpty(t, headers.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderMapsCallerSessionID(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + c := newOpenCodeSessionTestContext(t, "") + c.Request.Header.Set("session_id", "conv-from-client") + headers := make(http.Header) + applyOpenCodeSessionHeader(c, account, "https://opencode.ai/zen/go/v1/chat/completions", headers) + require.Equal(t, "conv-from-client", headers.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderUsesPromptCacheKeyInsteadOfRandomUUID(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + body := []byte(`{"model":"grok-4.6","prompt_cache_key":"kimi-session-42","input":"hello"}`) + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers, body) + require.Equal(t, "kimi-session-42", headers.Get(openCodeSessionHeader)) + + headers2 := make(http.Header) + laterTurn := []byte(`{"model":"grok-4.6","prompt_cache_key":"kimi-session-42","input":"follow up"}`) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers2, laterTurn) + require.Equal(t, headers.Get(openCodeSessionHeader), headers2.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderUsesAnthropicMetadataUserID(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + body := []byte(`{"model":"minimax-m3","metadata":{"user_id":"coding-agent-session"},"messages":[{"role":"user","content":"hi"}]}`) + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers, body) + require.Equal(t, "coding-agent-session", headers.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderUnwrapsClaudeCodeMetadataSessionJSON(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + body := []byte(`{"model":"claude-sonnet-4","metadata":{"user_id":"{\"session_id\":\"meta-session-xyz\"}"},"messages":[]}`) + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers, body) + require.Equal(t, "meta-session-xyz", headers.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderBodyBeatsGeneratedUUIDAndLosesToCallerHeader(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + body := []byte(`{"prompt_cache_key":"from-body"}`) + + headers := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, "from-header"), account, "https://opencode.ai/zen/go/v1/responses", headers, body) + require.Equal(t, "from-header", headers.Get(openCodeSessionHeader)) + + converted := []byte(`{"model":"minimax-m3","messages":[]}`) + headers2 := make(http.Header) + applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/messages", headers2, converted, body) + require.Equal(t, "from-body", headers2.Get(openCodeSessionHeader)) +} + +func TestApplyOpenCodeSessionHeaderUsesRememberedInboundBodyAfterConversion(t *testing.T) { + account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey} + + c := newOpenCodeSessionTestContext(t, "") + rememberOpenCodeInboundBody(c, []byte(`{"model":"gpt-5","prompt_cache_key":"inbound-responses-session","input":"hello"}`)) + headers := make(http.Header) + convertedCC := []byte(`{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hello"}]}`) + applyOpenCodeSessionHeader(c, account, "https://opencode.ai/zen/go/v1/chat/completions", headers, convertedCC) + require.Equal(t, "inbound-responses-session", headers.Get(openCodeSessionHeader)) + + c2 := newOpenCodeSessionTestContext(t, "") + rememberOpenCodeInboundBody(c2, []byte(`{"model":"grok-4.6","metadata":{"user_id":"inbound-messages-session"},"messages":[{"role":"user","content":"hi"}]}`)) + headers2 := make(http.Header) + convertedResponses := []byte(`{"model":"grok-4.6","input":"hi"}`) + applyOpenCodeSessionHeader(c2, account, "https://opencode.ai/zen/go/v1/responses", headers2, convertedResponses) + require.Equal(t, "inbound-messages-session", headers2.Get(openCodeSessionHeader)) +} + +func TestOpenCodeSessionIDFromPayloadIgnoresEmptyBody(t *testing.T) { + require.Empty(t, openCodeSessionIDFromPayload(nil)) + require.Empty(t, openCodeSessionIDFromPayload([]byte(`{"model":"gpt-5"}`))) +} + func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) { gin.SetMode(gin.TestMode) svc := openCodeSessionTestService() @@ -147,6 +247,24 @@ func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *test } } +func TestOpenCodeSessionForwardedFromPromptCacheKeyWithoutCallerHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + svc := openCodeSessionTestService() + account := &Account{ + ID: 1, + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "base_url": "https://opencode.ai/zen/go/v1", + }, + } + c := newOpenCodeSessionTestContext(t, "") + body := []byte(`{"model":"gpt-5","prompt_cache_key":"stable-cache-key","input":"hello"}`) + req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false) + require.NoError(t, err) + requireSingleOpenCodeSessionHeader(t, req.Header, "stable-cache-key") +} + func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) { gin.SetMode(gin.TestMode) svc := openCodeSessionTestService() diff --git a/backend/internal/service/opencode_go.go b/backend/internal/service/opencode_go.go new file mode 100644 index 0000000000..a4bf6b1500 --- /dev/null +++ b/backend/internal/service/opencode_go.go @@ -0,0 +1,330 @@ +package service + +import ( + "fmt" + "net/http" + "strings" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" +) + +// OpenCode Go 是 OpenCode Zen 的订阅网关:同一 API Key 下按模型分流到 +// Responses / Chat Completions / Anthropic Messages 三种原生端点, +// 额度窗口为 rolling(5h) / weekly / monthly。 + +const ( + openCodeGoUsagePath = "/usage" + // DefaultOpenCodeGoTestModel is the admin connection-test fallback when + // the UI does not pick a model. glm-5.3 is a Chat Completions catalog ID. + DefaultOpenCodeGoTestModel = "glm-5.3" + + openCodeGoProtocolRulesKey = "protocol_rules" + maxOpenCodeGoProtocolRules = 64 + maxOpenCodeGoProtocolPatternLength = 128 +) + +// DefaultOpenCodeGoModelIDs 是官方文档当前公开的模型 ID 目录, +// 供 /v1/models 在尚未同步上游列表时回退,以及账号白名单预填。 +func DefaultOpenCodeGoModelIDs() []string { + return []string{ + "grok-4.6", + "gpt-5.6-luna", + "glm-5.3-flash", + "glm-5.3", + "glm-5.2", + "glm-5.1", + "kimi-k3", + "kimi-k2.7-code", + "kimi-k2.6", + "longcat-2.0", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-vision-exp", + "mimo-v2.5", + "mimo-v2.5-pro", + "minimax-m3", + "minimax-m2.7", + "minimax-m2.5", + "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", + "qwen3.8-max", + "qwen3.8-flash", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + "hy4-preview", + "hy3", + "omen-alpha", + } +} + +func normalizeOpenCodeGoModelID(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + for _, prefix := range []string{"opencode-go/", "opencode_go/", "opencode/"} { + model = strings.TrimPrefix(model, prefix) + } + return model +} + +// OpenCodeGoProtocolRule is one model-pattern → native protocol mapping. +// Pattern is an exact ID or a suffix glob (foo* / *). First match wins. +type OpenCodeGoProtocolRule struct { + Pattern string `json:"pattern"` + Protocol string `json:"protocol"` +} + +// DefaultOpenCodeGoProtocolRules is the built-in adaptive routing table for Go. +func DefaultOpenCodeGoProtocolRules() []OpenCodeGoProtocolRule { + return []OpenCodeGoProtocolRule{ + {Pattern: "grok-*", Protocol: APIProtocolResponses}, + {Pattern: "gpt-*", Protocol: APIProtocolResponses}, + {Pattern: "muse-spark-*", Protocol: APIProtocolResponses}, + {Pattern: "minimax-*", Protocol: APIProtocolAnthropic}, + {Pattern: "qwen*", Protocol: APIProtocolAnthropic}, + } +} + +// DefaultOpenCodeZenProtocolRules 对齐 https://opencode.ai/docs/zen/ 端点表: +// GPT/Grok/Muse Spark → Responses,Claude/Qwen → Anthropic,其余 Chat Completions。 +func DefaultOpenCodeZenProtocolRules() []OpenCodeGoProtocolRule { + return []OpenCodeGoProtocolRule{ + {Pattern: "grok-*", Protocol: APIProtocolResponses}, + {Pattern: "gpt-*", Protocol: APIProtocolResponses}, + {Pattern: "muse-spark-*", Protocol: APIProtocolResponses}, + {Pattern: "claude-*", Protocol: APIProtocolAnthropic}, + {Pattern: "qwen*", Protocol: APIProtocolAnthropic}, + } +} + +func defaultOpenCodeProtocolRules(mode string) []OpenCodeGoProtocolRule { + if mode == AccountModeZen { + return DefaultOpenCodeZenProtocolRules() + } + return DefaultOpenCodeGoProtocolRules() +} + +func isNativeOpenCodeGoProtocol(protocol string) bool { + switch protocol { + case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: + return true + default: + return false + } +} + +func openCodeGoPatternMatches(pattern, model string) bool { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + model = normalizeOpenCodeGoModelID(model) + if pattern == "" || model == "" { + return false + } + if pattern == "*" { + return true + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) + } + return model == pattern +} + +func matchOpenCodeGoProtocolRules(model string, rules []OpenCodeGoProtocolRule) string { + for _, rule := range rules { + if !isNativeOpenCodeGoProtocol(rule.Protocol) { + continue + } + if openCodeGoPatternMatches(rule.Pattern, model) { + return rule.Protocol + } + } + return APIProtocolChatCompletions +} + +// OpenCodeGoModelProtocol 返回内置默认表下模型对应的原生上游协议。 +// 未命中时为 Chat Completions。账号自定义 protocol_rules 见 +// ResolveOpenCodeGoUpstreamProtocol。 +func OpenCodeGoModelProtocol(model string) string { + return matchOpenCodeGoProtocolRules(model, DefaultOpenCodeGoProtocolRules()) +} + +func (a *Account) openCodeGoProtocolRules() ([]OpenCodeGoProtocolRule, bool) { + if a == nil || a.Credentials == nil { + return nil, false + } + raw, ok := a.Credentials[openCodeGoProtocolRulesKey] + if !ok || raw == nil { + return nil, false + } + rules, err := parseOpenCodeGoProtocolRules(raw) + if err != nil { + return nil, false + } + return rules, true +} + +func parseOpenCodeGoProtocolRules(raw any) ([]OpenCodeGoProtocolRule, error) { + items, err := openCodeGoProtocolRuleItems(raw) + if err != nil { + return nil, err + } + if len(items) > maxOpenCodeGoProtocolRules { + return nil, fmt.Errorf("protocol_rules supports at most %d entries", maxOpenCodeGoProtocolRules) + } + rules := make([]OpenCodeGoProtocolRule, 0, len(items)) + for i, item := range items { + entry, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("protocol_rules[%d] must be an object", i) + } + pattern, _ := entry["pattern"].(string) + protocol, _ := entry["protocol"].(string) + pattern, err := normalizeOpenCodeGoProtocolPattern(pattern) + if err != nil { + return nil, fmt.Errorf("protocol_rules[%d]: %w", i, err) + } + protocol = strings.TrimSpace(protocol) + if !isNativeOpenCodeGoProtocol(protocol) { + return nil, fmt.Errorf("protocol_rules[%d]: protocol must be chat_completions, anthropic, or responses", i) + } + rules = append(rules, OpenCodeGoProtocolRule{Pattern: pattern, Protocol: protocol}) + } + return rules, nil +} + +func openCodeGoProtocolRuleItems(raw any) ([]any, error) { + switch items := raw.(type) { + case []any: + return items, nil + case []map[string]any: + out := make([]any, 0, len(items)) + for _, item := range items { + out = append(out, item) + } + return out, nil + default: + return nil, fmt.Errorf("protocol_rules must be an array") + } +} + +func normalizeOpenCodeGoProtocolPattern(pattern string) (string, error) { + pattern = strings.ToLower(strings.TrimSpace(pattern)) + if pattern == "" { + return "", fmt.Errorf("pattern is required") + } + if len(pattern) > maxOpenCodeGoProtocolPatternLength { + return "", fmt.Errorf("pattern is too long") + } + if strings.ContainsAny(pattern, " \t") { + return "", fmt.Errorf("pattern must not contain whitespace") + } + star := strings.Count(pattern, "*") + if star > 1 || (star == 1 && !strings.HasSuffix(pattern, "*")) { + return "", fmt.Errorf("pattern may use a single trailing * wildcard") + } + return pattern, nil +} + +// NormalizeOpenCodeGoProtocolRulesCredentials 校验并原地规范化 credentials.protocol_rules。 +// 未携带该字段时为 no-op,使旧账号继续使用内置默认表。 +func NormalizeOpenCodeGoProtocolRulesCredentials(credentials map[string]any) error { + if credentials == nil { + return nil + } + raw, ok := credentials[openCodeGoProtocolRulesKey] + if !ok || raw == nil { + return nil + } + rules, err := parseOpenCodeGoProtocolRules(raw) + if err != nil { + return infraerrors.New(http.StatusBadRequest, "INVALID_OPENCODE_GO_PROTOCOL_RULES", err.Error()) + } + encoded := make([]any, 0, len(rules)) + for _, rule := range rules { + encoded = append(encoded, map[string]any{ + "pattern": rule.Pattern, + "protocol": rule.Protocol, + }) + } + credentials[openCodeGoProtocolRulesKey] = encoded + return nil +} + +func (a *Account) IsOpenCodeGo() bool { + return a != nil && a.Platform == PlatformOpenCodeGo +} + +// GetOpenCodeAccountMode 返回 OpenCode 账号类型。未设置时按 Go 处理,兼容已有账号。 +func (a *Account) GetOpenCodeAccountMode() string { + if a == nil || !a.IsOpenCodeGo() { + return "" + } + if strings.TrimSpace(a.GetCredential("account_mode")) == AccountModeZen { + return AccountModeZen + } + return AccountModeGo +} + +func (a *Account) IsOpenCodeZen() bool { + return a.GetOpenCodeAccountMode() == AccountModeZen +} + +func (a *Account) IsOpenCodeGoPlan() bool { + return a.GetOpenCodeAccountMode() == AccountModeGo +} + +func (a *Account) openCodeDefaultChatBaseURL() string { + if a.IsOpenCodeZen() { + return DefaultOpenCodeZenBaseURL + } + return DefaultOpenCodeGoBaseURL +} + +func (a *Account) openCodeDefaultAnthropicBaseURL() string { + if a.IsOpenCodeZen() { + return DefaultOpenCodeZenAnthropicBaseURL + } + return DefaultOpenCodeGoAnthropicBaseURL +} + +func (a *Account) IsMultiProtocolAPIKey() bool { + return a != nil && IsMultiProtocolAPIKeyProvider(a.Platform) +} + +// openCodeGoNativeProtocol 返回 OpenCode Go 实际上游协议。 +// 规则未命中、空值或未知协议一律兜底 Chat Completions,避免落入 Responses 转换链。 +func openCodeGoNativeProtocol(account *Account, model string) string { + if account == nil { + return APIProtocolChatCompletions + } + switch proto := account.ResolveOpenCodeGoUpstreamProtocol(model); proto { + case APIProtocolAnthropic, APIProtocolResponses: + return proto + default: + return APIProtocolChatCompletions + } +} + +// ResolveOpenCodeGoUpstreamProtocol 按账号协议配置与模型规则决定上游协议。 +// 显式 pinned 协议优先;adaptive(默认)先走 credentials.protocol_rules, +// 未配置时回落内置默认表;已配置但未命中则走 Chat Completions。 +func (a *Account) ResolveOpenCodeGoUpstreamProtocol(model string) string { + if a == nil || !a.IsOpenCodeGo() { + return "" + } + switch a.GetAPIProtocol() { + case APIProtocolChatCompletions, APIProtocolAnthropic, APIProtocolResponses: + return a.GetAPIProtocol() + default: + if rules, present := a.openCodeGoProtocolRules(); present { + return matchOpenCodeGoProtocolRules(model, rules) + } + return matchOpenCodeGoProtocolRules(model, defaultOpenCodeProtocolRules(a.GetOpenCodeAccountMode())) + } +} + +func openCodeGoQuotaURL(baseURL string) string { + base := strings.TrimRight(strings.TrimSpace(baseURL), "/") + if base == "" { + base = DefaultOpenCodeGoBaseURL + } + return base + openCodeGoUsagePath +} diff --git a/backend/internal/service/opencode_go_test.go b/backend/internal/service/opencode_go_test.go new file mode 100644 index 0000000000..47e8db85ce --- /dev/null +++ b/backend/internal/service/opencode_go_test.go @@ -0,0 +1,220 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenCodeGoModelProtocol(t *testing.T) { + t.Parallel() + cases := []struct { + model string + want string + }{ + {"grok-4.6", APIProtocolResponses}, + {"opencode-go/gpt-5.6-luna", APIProtocolResponses}, + {"muse-spark-1.3-contributor", APIProtocolResponses}, + {"minimax-m3", APIProtocolAnthropic}, + {"qwen3.8-max", APIProtocolAnthropic}, + {"qwen3.7-plus", APIProtocolAnthropic}, + {"glm-5.3", APIProtocolChatCompletions}, + {"kimi-k3", APIProtocolChatCompletions}, + {"deepseek-v4-pro", APIProtocolChatCompletions}, + {"mimo-v2.5-pro", APIProtocolChatCompletions}, + {"hy4-preview", APIProtocolChatCompletions}, + {"omen-alpha", APIProtocolChatCompletions}, + {"", APIProtocolChatCompletions}, + {"unknown-model", APIProtocolChatCompletions}, + } + for _, tc := range cases { + require.Equal(t, tc.want, OpenCodeGoModelProtocol(tc.model), "model=%s", tc.model) + } +} + +func TestResolveOpenCodeGoUpstreamProtocol(t *testing.T) { + t.Parallel() + adaptive := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"api_protocol": APIProtocolAdaptive}} + require.Equal(t, APIProtocolAnthropic, adaptive.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + require.Equal(t, APIProtocolResponses, adaptive.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) + require.Equal(t, APIProtocolChatCompletions, adaptive.ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) + + pinned := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"api_protocol": APIProtocolChatCompletions}} + require.Equal(t, APIProtocolChatCompletions, pinned.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + + empty := &Account{Platform: PlatformOpenCodeGo} + require.Equal(t, APIProtocolResponses, empty.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) + require.Equal(t, AccountModeGo, empty.GetOpenCodeAccountMode()) + require.True(t, empty.IsOpenCodeGoPlan()) + + zen := &Account{Platform: PlatformOpenCodeGo, Credentials: map[string]any{"account_mode": AccountModeZen, "api_protocol": APIProtocolAdaptive}} + require.Equal(t, APIProtocolChatCompletions, zen.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) + require.Equal(t, APIProtocolAnthropic, zen.ResolveOpenCodeGoUpstreamProtocol("claude-opus-4-6")) + require.Equal(t, DefaultOpenCodeZenBaseURL, zen.GetOpenAIBaseURL()) + + require.Equal(t, "", (&Account{Platform: PlatformKimi}).ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) +} + +func TestResolveOpenCodeGoUpstreamProtocolUsesAccountRules(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformOpenCodeGo, + Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": "grok-*", "protocol": APIProtocolChatCompletions}, + map[string]any{"pattern": "deepseek-v4-flash", "protocol": APIProtocolResponses}, + map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic}, + }, + }, + } + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) + require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("deepseek-v4-flash")) + require.Equal(t, APIProtocolAnthropic, account.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-max")) + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("glm-5.3")) + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) +} + +func TestResolveOpenCodeGoUpstreamProtocolEmptyRulesAreAllChatCompletions(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformOpenCodeGo, + Credentials: map[string]any{ + openCodeGoProtocolRulesKey: []any{}, + }, + } + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("minimax-m3")) +} + +func TestResolveOpenCodeGoUpstreamProtocolFirstMatchWins(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformOpenCodeGo, + Credentials: map[string]any{ + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": "gpt-5.6-luna", "protocol": APIProtocolChatCompletions}, + map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses}, + }, + }, + } + require.Equal(t, APIProtocolChatCompletions, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) + require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.4")) +} + +func TestOpenCodeGoNativeProtocolUnmatchedFallsBackToChatCompletions(t *testing.T) { + t.Parallel() + withRules := &Account{ + Platform: PlatformOpenCodeGo, + Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": "grok-*", "protocol": APIProtocolResponses}, + map[string]any{"pattern": "gpt-*", "protocol": APIProtocolResponses}, + map[string]any{"pattern": "qwen*", "protocol": APIProtocolAnthropic}, + }, + }, + } + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "deepseek-v4-flash")) + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "glm-5.3")) + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "omen-alpha")) + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "kimi-k3")) + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(withRules, "unknown-model")) + require.Equal(t, APIProtocolResponses, openCodeGoNativeProtocol(withRules, "grok-4.6")) + require.Equal(t, APIProtocolAnthropic, openCodeGoNativeProtocol(withRules, "qwen3.8-flash")) + + defaults := &Account{Platform: PlatformOpenCodeGo} + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(defaults, "deepseek-v4-flash")) + require.Equal(t, APIProtocolChatCompletions, openCodeGoNativeProtocol(nil, "grok-4.6")) +} + +func TestParseOpenCodeGoProtocolRulesAcceptsTypedMaps(t *testing.T) { + t.Parallel() + rules, err := parseOpenCodeGoProtocolRules([]map[string]any{ + {"pattern": "grok-*", "protocol": APIProtocolResponses}, + {"pattern": "qwen*", "protocol": APIProtocolAnthropic}, + }) + require.NoError(t, err) + require.Equal(t, []OpenCodeGoProtocolRule{ + {Pattern: "grok-*", Protocol: APIProtocolResponses}, + {Pattern: "qwen*", Protocol: APIProtocolAnthropic}, + }, rules) +} + +func TestShouldForwardOpenAIResponsesViaRawChatCompletions_OpenCodeGoIgnoresProbe(t *testing.T) { + t.Parallel() + account := &Account{ + Platform: PlatformOpenCodeGo, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_protocol": APIProtocolAdaptive, + }, + Extra: map[string]any{ + "openai_responses_supported": false, + }, + } + require.False(t, shouldForwardOpenAIResponsesViaRawChatCompletions(account)) + require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("grok-4.6")) + require.Equal(t, APIProtocolResponses, account.ResolveOpenCodeGoUpstreamProtocol("gpt-5.6-luna")) + require.Equal(t, APIProtocolAnthropic, account.ResolveOpenCodeGoUpstreamProtocol("qwen3.8-flash")) +} + +func TestStampOpenAIResponsesUpstreamEndpoint(t *testing.T) { + t.Parallel() + result := &OpenAIForwardResult{} + stampOpenAIResponsesUpstreamEndpoint(nil, result) + require.Equal(t, "/v1/responses", result.UpstreamEndpoint) + + existing := &OpenAIForwardResult{UpstreamEndpoint: "/v1/messages"} + stampOpenAIResponsesUpstreamEndpoint(nil, existing) + require.Equal(t, "/v1/messages", existing.UpstreamEndpoint) +} + +func TestNormalizeOpenCodeGoProtocolRulesCredentials(t *testing.T) { + t.Parallel() + require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(nil)) + require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{})) + + creds := map[string]any{ + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": " Grok-* ", "protocol": APIProtocolResponses}, + }, + } + require.NoError(t, NormalizeOpenCodeGoProtocolRulesCredentials(creds)) + rules, ok := creds[openCodeGoProtocolRulesKey].([]any) + require.True(t, ok) + require.NotEmpty(t, rules) + entry, ok := rules[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "grok-*", entry["pattern"]) + + err := NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{ + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": "*grok", "protocol": APIProtocolResponses}, + }, + }) + require.Error(t, err) + + err = NormalizeOpenCodeGoProtocolRulesCredentials(map[string]any{ + openCodeGoProtocolRulesKey: []any{ + map[string]any{"pattern": "grok-*", "protocol": "adaptive"}, + }, + }) + require.Error(t, err) +} + +func TestOpenCodeGoQuotaURL(t *testing.T) { + t.Parallel() + require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL("")) + require.Equal(t, "https://opencode.ai/zen/go/v1/usage", openCodeGoQuotaURL(DefaultOpenCodeGoBaseURL+"/")) + require.Equal(t, "https://custom.example/v1/usage", openCodeGoQuotaURL("https://custom.example/v1")) +} + +func TestDefaultOpenCodeGoModelIDsCoverDocumentedCatalog(t *testing.T) { + t.Parallel() + ids := DefaultOpenCodeGoModelIDs() + require.Contains(t, ids, "grok-4.6") + require.Contains(t, ids, "minimax-m3") + require.Contains(t, ids, "glm-5.3") + require.NotEmpty(t, ids) +} diff --git a/backend/internal/service/ratelimit_cn_providers.go b/backend/internal/service/ratelimit_cn_providers.go index 17239e4bbc..15ea009c2a 100644 --- a/backend/internal/service/ratelimit_cn_providers.go +++ b/backend/internal/service/ratelimit_cn_providers.go @@ -127,12 +127,19 @@ func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration { // 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。 // 无快照或均已过期返回 nil。 func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time { - if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() || len(account.Extra) == 0 { + if account == nil || len(account.Extra) == 0 { + return nil + } + if !account.IsOpenCodeGo() && (!account.IsCNProvider() || !account.IsCodingPlan()) { return nil } provider := account.Platform + suffixes := []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} + if account.IsOpenCodeGo() { + suffixes = append(suffixes, cnExtraSuffixMonthlyReset) + } var earliest *time.Time - for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} { + for _, suffix := range suffixes { t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)]) if t == nil || !t.After(now) { continue @@ -152,6 +159,36 @@ func (s *RateLimitService) applyCNProviderReactive429( headers http.Header, responseBody []byte, ) bool { + if account.IsOpenCodeGo() { + if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil { + s.notifyAccountSchedulingBlocked(account, *until, "429") + if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return true + } + slog.Info("opencode_go_rate_limited", + "account_id", account.ID, + "platform", account.Platform, + "reset_at", *until, + ) + return true + } + if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil { + resetTime := time.Unix(*resetAt, 0) + s.notifyAccountSchedulingBlocked(account, resetTime, "429") + if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetTime); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + return true + } + slog.Info("opencode_go_rate_limited", + "account_id", account.ID, + "platform", account.Platform, + "reset_at", resetTime, + ) + return true + } + return false + } if !account.IsCNProvider() { return false } diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 5b42f95951..e615535f8d 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -990,7 +990,7 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst // 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据, // 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/ // 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。 - if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) || account.IsOpenCodeGo() { return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody) } // 非 Antigravity 平台:保持原有行为 @@ -1154,7 +1154,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head } // 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调, // Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。 - if account.IsCNProvider() { + if account.IsCNProvider() || account.IsOpenCodeGo() { if s.applyCNProviderReactive429(ctx, account, headers, responseBody) { return } diff --git a/backend/internal/service/scheduler_snapshot_bulk_event_test.go b/backend/internal/service/scheduler_snapshot_bulk_event_test.go index d8fdc063a0..17bfec7c9f 100644 --- a/backend/internal/service/scheduler_snapshot_bulk_event_test.go +++ b/backend/internal/service/scheduler_snapshot_bulk_event_test.go @@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing. } func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) { - for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} { t.Run(platform, func(t *testing.T) { cache := newBulkEventSnapshotCache() repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}}) diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 6ee67979e1..14d43f0c87 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 @@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account return s.rebuildBuckets(ctx, buckets, reason) } -func schedulerSnapshotPlatforms() [9]string { - return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} +func schedulerSnapshotPlatforms() [10]string { + return [10]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} } // 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。 diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index 145761adf1..97603793e5 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool { } switch platform { case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax: + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo: return true default: return false @@ -1051,6 +1051,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{ "kimi.com", "bigmodel.cn", "deepseek.com", + "opencode.ai", } func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index d76e868052..450cec373c 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -16,7 +16,7 @@ import ( func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -172,6 +172,8 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te {PlatformZhipu, "https://open.bigmodel.cn/api/anthropic"}, {PlatformDeepseek, "https://api.deepseek.com"}, {PlatformDeepseek, "https://api.deepseek.com/anthropic"}, + {PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"}, + {PlatformOpenCodeGo, "https://opencode.ai/zen/go"}, } for i, tc := range cases { account := &Account{ @@ -213,6 +215,8 @@ func TestUpstreamBillingProbeOfficialAPIHostMatchingIsNormalized(t *testing.T) { require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.kimi.com/coding/v1")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic")) require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/anthropic")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go/v1")) + require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://opencode.ai/zen/go")) // 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。 require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1")) require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai")) diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 4b106ddfe2..3f2795c4f5 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -617,7 +617,7 @@ func upstreamModelRegistryBaseURL(account *Account) string { return "" } switch { - case account.IsOpenAI() || account.IsCNProvider(): + case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo(): return account.GetOpenAIFormatBaseURL() case account.IsGrok(): return account.GetGrokBaseURL() @@ -677,6 +677,8 @@ func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, ac switch host { case "api.openai.com", "chatgpt.com": providerID = "openai" + case "opencode.ai": + providerID = "opencode-go" default: return modelsDevProvider{}, false } @@ -782,8 +784,9 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) - case account.IsOpenAI() || account.IsCNProvider(): - // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。 + case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo(): + // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go + // 复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): return s.buildGeminiUpstreamModelsRequest(ctx, account) diff --git a/backend/internal/setup/setup_test.go b/backend/internal/setup/setup_test.go index 65d14f57a8..71abebc0c9 100644 --- a/backend/internal/setup/setup_test.go +++ b/backend/internal/setup/setup_test.go @@ -270,7 +270,7 @@ func TestDatabaseConnectionUsesConfiguredTargetBeforeBootstrapDatabase(t *testin if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } - defer targetDB.Close() + defer func() { _ = targetDB.Close() }() var opened []string openDatabase := func(_ *DatabaseConfig, dbName string) (*sql.DB, error) { @@ -292,12 +292,12 @@ func TestDatabaseConnectionUsesLegacyBootstrapOnlyForMissingTarget(t *testing.T) if err != nil { t.Fatalf("sqlmock.New() bootstrap error = %v", err) } - defer bootstrapDB.Close() + defer func() { _ = bootstrapDB.Close() }() targetDB, _, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() target error = %v", err) } - defer targetDB.Close() + defer func() { _ = targetDB.Close() }() bootstrapMock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM pg_database WHERE datname = \$1\)`). WithArgs(cfg.DBName). diff --git a/backend/migrations/238_opencode_go_platform.sql b/backend/migrations/238_opencode_go_platform.sql new file mode 100644 index 0000000000..df0c0d282c --- /dev/null +++ b/backend/migrations/238_opencode_go_platform.sql @@ -0,0 +1,62 @@ +-- Add OpenCode as a first-class platform (account types Zen / GO). +-- +-- 1. user_platform_quotas.platform CHECK +-- 2. composite_model_routes.target_platform CHECK +-- 3. channel_monitors / channel_monitor_request_templates provider CHECK +-- +-- Runs after 237_add_minimax_platform.sql. DROP ... IF EXISTS + 幂等守卫保证可重入; +-- 新约束是 237 的超集,必须同时保留 MiniMax。 + +ALTER TABLE user_platform_quotas + DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check; + +ALTER TABLE user_platform_quotas + ADD CONSTRAINT user_platform_quotas_platform_check + CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go')); + +ALTER TABLE composite_model_routes + DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check; + +ALTER TABLE composite_model_routes + ADD CONSTRAINT composite_model_routes_target_platform_check + CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', + 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go')); + +DO $$ +DECLARE + monitor_constraint_def TEXT; + template_constraint_def TEXT; +BEGIN + SELECT pg_get_constraintdef(c.oid) + INTO monitor_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitors' + AND c.conname = 'channel_monitors_provider_check'; + + IF monitor_constraint_def IS NULL OR position('opencode_go' IN monitor_constraint_def) = 0 THEN + ALTER TABLE channel_monitors + DROP CONSTRAINT IF EXISTS channel_monitors_provider_check; + ALTER TABLE channel_monitors + ADD CONSTRAINT channel_monitors_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go')); + END IF; + + SELECT pg_get_constraintdef(c.oid) + INTO template_constraint_def + FROM pg_constraint c + JOIN pg_class t ON t.oid = c.conrelid + WHERE t.relname = 'channel_monitor_request_templates' + AND c.conname = 'channel_monitor_request_templates_provider_check'; + + IF template_constraint_def IS NULL OR position('opencode_go' IN template_constraint_def) = 0 THEN + ALTER TABLE channel_monitor_request_templates + DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check; + ALTER TABLE channel_monitor_request_templates + ADD CONSTRAINT channel_monitor_request_templates_provider_check + CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', + 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go')); + END IF; +END $$; diff --git a/backend/migrations/opencode_go_platform_migration_test.go b/backend/migrations/opencode_go_platform_migration_test.go new file mode 100644 index 0000000000..db80122b0f --- /dev/null +++ b/backend/migrations/opencode_go_platform_migration_test.go @@ -0,0 +1,29 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOpenCodeGoPlatformMigration(t *testing.T) { + content, err := FS.ReadFile("238_opencode_go_platform.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + require.Contains(t, sql, "user_platform_quotas_platform_check") + require.Contains(t, sql, "composite_model_routes_target_platform_check") + require.Contains(t, sql, "channel_monitors_provider_check") + require.Contains(t, sql, "channel_monitor_request_templates_provider_check") + require.Contains(t, sql, "'opencode_go'") + require.Contains(t, sql, "'minimax'") + require.Contains(t, sql, "position('opencode_go' IN monitor_constraint_def) = 0") + require.Contains(t, sql, "position('opencode_go' IN template_constraint_def) = 0") + require.Contains(t, sql, + "CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'))") + require.Contains(t, sql, + "CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'))") + require.Contains(t, sql, + "CHECK (provider IN ('openai', 'anthropic', 'gemini', 'grok', 'antigravity', 'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go'))") +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 0ff4c9086e..c5adc99d93 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -147,6 +147,7 @@ security: - "api.moonshot.cn" - "open.bigmodel.cn" - "api.minimaxi.com" + - "opencode.ai" - "generativelanguage.googleapis.com" - "cloudcode-pa.googleapis.com" - "*.openai.azure.com" diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index fb75339248..7ddd5697e5 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -15,6 +15,7 @@ export type Provider = | 'zhipu' | 'deepseek' | 'minimax' + | 'opencode_go' export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error' export type BodyOverrideMode = 'off' | 'merge' | 'replace' export type APIMode = 'chat_completions' | 'responses' diff --git a/frontend/src/api/admin/cnProviders.ts b/frontend/src/api/admin/cnProviders.ts index 668f67a73e..7a4d3f18c5 100644 --- a/frontend/src/api/admin/cnProviders.ts +++ b/frontend/src/api/admin/cnProviders.ts @@ -7,7 +7,7 @@ import { apiClient } from '../client' /** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */ export interface CNQuotaTier { - window: '5h' | 'weekly' + window: '5h' | 'weekly' | 'monthly' used_percent: number reset_at?: string } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 7c976756df..e380c578cb 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -39,11 +39,12 @@ export type SchedulingThresholdPlatformType = | "kimi" | "zhipu" | "minimax" + | "opencode_go" export type AccountSchedulingThresholdsMap = Record // 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型, -// 走余额检测而非用量阈值;minimax Coding/Token Plan 有 5h/weekly 窗口)。 +// 走余额检测而非用量阈值;minimax Coding/Token Plan 与 OpenCode GO 有滚动窗口)。 export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [ "openai", "anthropic", @@ -51,6 +52,7 @@ export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = "kimi", "zhipu", "minimax", + "opencode_go", ] export function normalizeAccountSchedulingThresholdsMap( diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index 08689559f6..72a92dd865 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -431,7 +431,7 @@ -