mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-05 16:47:26 +00:00
Merge pull request #6747 from DuskLin/feat/opencode-go-platform
feat(opencode): 增加 OpenCode 平台支持(Zen / GO)
This commit is contained in:
108 files changed
+2543
-195
No files matched your search
@@ -183,6 +183,7 @@ const (
|
|||||||
ProviderZhipu Provider = "zhipu"
|
ProviderZhipu Provider = "zhipu"
|
||||||
ProviderDeepseek Provider = "deepseek"
|
ProviderDeepseek Provider = "deepseek"
|
||||||
ProviderMinimax Provider = "minimax"
|
ProviderMinimax Provider = "minimax"
|
||||||
|
ProviderOpencodeGo Provider = "opencode_go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (pr Provider) String() string {
|
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.
|
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||||
func ProviderValidator(pr Provider) error {
|
func ProviderValidator(pr Provider) error {
|
||||||
switch pr {
|
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
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const (
|
|||||||
ProviderZhipu Provider = "zhipu"
|
ProviderZhipu Provider = "zhipu"
|
||||||
ProviderDeepseek Provider = "deepseek"
|
ProviderDeepseek Provider = "deepseek"
|
||||||
ProviderMinimax Provider = "minimax"
|
ProviderMinimax Provider = "minimax"
|
||||||
|
ProviderOpencodeGo Provider = "opencode_go"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (pr Provider) String() string {
|
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.
|
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||||
func ProviderValidator(pr Provider) error {
|
func ProviderValidator(pr Provider) error {
|
||||||
switch pr {
|
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
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ var (
|
|||||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
{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: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||||
{Name: "name", Type: field.TypeString, Size: 100},
|
{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: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
|
||||||
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
|
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
|
||||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
{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: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||||
{Name: "updated_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: "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: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||||
{Name: "extra_headers", Type: field.TypeJSON},
|
{Name: "extra_headers", Type: field.TypeJSON},
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
|||||||
MaxLen(100),
|
MaxLen(100),
|
||||||
field.Enum("provider").
|
field.Enum("provider").
|
||||||
Values("openai", "anthropic", "gemini", "grok",
|
Values("openai", "anthropic", "gemini", "grok",
|
||||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
|
||||||
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
||||||
// probe - LLM 探活(默认,原有行为)
|
// probe - LLM 探活(默认,原有行为)
|
||||||
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
|||||||
MaxLen(100),
|
MaxLen(100),
|
||||||
field.Enum("provider").
|
field.Enum("provider").
|
||||||
Values("openai", "anthropic", "gemini", "grok",
|
Values("openai", "anthropic", "gemini", "grok",
|
||||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
|
||||||
field.String("api_mode").
|
field.String("api_mode").
|
||||||
Default("chat_completions").
|
Default("chat_completions").
|
||||||
MaxLen(32).
|
MaxLen(32).
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
|||||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||||
switch s {
|
switch s {
|
||||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||||
"kimi", "zhipu", "deepseek", "minimax":
|
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("platform %q is not allowed", s)
|
return fmt.Errorf("platform %q is not allowed", s)
|
||||||
|
|||||||
@@ -2048,6 +2048,7 @@ func setDefaults() {
|
|||||||
"open.bigmodel.cn",
|
"open.bigmodel.cn",
|
||||||
"api.minimaxi.com", // MiniMax CN quota + inference
|
"api.minimaxi.com", // MiniMax CN quota + inference
|
||||||
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
|
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
|
||||||
|
"opencode.ai",
|
||||||
"generativelanguage.googleapis.com",
|
"generativelanguage.googleapis.com",
|
||||||
"cloudcode-pa.googleapis.com",
|
"cloudcode-pa.googleapis.com",
|
||||||
"*.openai.azure.com",
|
"*.openai.azure.com",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import "time"
|
|||||||
// - "7d" 7 天窗口(Claude/Codex)
|
// - "7d" 7 天窗口(Claude/Codex)
|
||||||
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
|
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
|
||||||
// - "7d-fable" Claude 7 天 Fable 独立额度
|
// - "7d-fable" Claude 7 天 Fable 独立额度
|
||||||
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
|
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
|
||||||
|
// - "monthly" 月窗口(OpenCode Go)
|
||||||
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
|
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
|
||||||
// - "30d" 30 天窗口(Grok 月度)
|
// - "30d" 30 天窗口(Grok 月度)
|
||||||
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
|
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ const (
|
|||||||
PlatformAntigravity = "antigravity"
|
PlatformAntigravity = "antigravity"
|
||||||
PlatformGrok = "grok"
|
PlatformGrok = "grok"
|
||||||
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
|
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
|
||||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||||
PlatformDeepseek = "deepseek" // DeepSeek
|
PlatformDeepseek = "deepseek" // DeepSeek
|
||||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||||
PlatformComposite = "composite"
|
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
|
||||||
|
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
|
||||||
|
PlatformOpenCodeGo = "opencode_go"
|
||||||
|
PlatformComposite = "composite"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
|
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
|
||||||
@@ -36,6 +39,8 @@ const (
|
|||||||
const (
|
const (
|
||||||
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
|
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
|
||||||
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
|
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 协议维度。存储于
|
// API protocol constants 国产供应商的上游 API 协议维度。存储于
|
||||||
|
|||||||
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
|
|||||||
service.PlatformZhipu: "zhipu",
|
service.PlatformZhipu: "zhipu",
|
||||||
service.PlatformDeepseek: "deepseek",
|
service.PlatformDeepseek: "deepseek",
|
||||||
service.PlatformMiniMax: "minimax",
|
service.PlatformMiniMax: "minimax",
|
||||||
|
service.PlatformOpenCodeGo: "opencode-go",
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
|||||||
|
|
||||||
type channelMonitorCreateRequest struct {
|
type channelMonitorCreateRequest struct {
|
||||||
Name string `json:"name" binding:"required,max=100"`
|
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"`
|
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||||
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
||||||
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
||||||
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
|
|||||||
|
|
||||||
type channelMonitorUpdateRequest struct {
|
type channelMonitorUpdateRequest struct {
|
||||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
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"`
|
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
|||||||
type CreateGroupRequest struct {
|
type CreateGroupRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
Description string `json:"description"`
|
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"`
|
RateMultiplier float64 `json:"rate_multiplier"`
|
||||||
IsExclusive bool `json:"is_exclusive"`
|
IsExclusive bool `json:"is_exclusive"`
|
||||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||||
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
|
|||||||
type UpdateGroupRequest struct {
|
type UpdateGroupRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description *string `json:"description"`
|
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"`
|
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||||
IsExclusive *bool `json:"is_exclusive"`
|
IsExclusive *bool `json:"is_exclusive"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||||
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
|
|||||||
type CompositeRouteRequest struct {
|
type CompositeRouteRequest struct {
|
||||||
PublicModel string `json:"public_model" binding:"required"`
|
PublicModel string `json:"public_model" binding:"required"`
|
||||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
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"`
|
UpstreamModel string `json:"upstream_model"`
|
||||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
|
// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的
|
||||||
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
|
// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加,
|
||||||
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
|
// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。
|
||||||
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
|||||||
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||||
allowed := []string{
|
allowed := []string{
|
||||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||||
"kimi", "zhipu", "deepseek", "minimax", "composite",
|
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
|
||||||
}
|
}
|
||||||
for _, platform := range allowed {
|
for _, platform := range allowed {
|
||||||
t.Run("create_"+platform, func(t *testing.T) {
|
t.Run("create_"+platform, func(t *testing.T) {
|
||||||
@@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(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
|
var req CompositeRouteRequest
|
||||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||||
require.Equal(t, platform, req.TargetPlatform)
|
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"}`))
|
||||||
|
}
|
||||||
@@ -315,7 +315,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
|||||||
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
|
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
|
||||||
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
|
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
|
||||||
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
|
// 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 != "" {
|
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
|
||||||
return endpoint
|
return endpoint
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
|||||||
result: &service.OpenAIForwardResult{},
|
result: &service.OpenAIForwardResult{},
|
||||||
want: EndpointResponses,
|
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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -1269,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
|||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
models := make([]string, 0)
|
models := make([]string, 0)
|
||||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
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)
|
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||||
if len(platformModels) == 0 {
|
if len(platformModels) == 0 {
|
||||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||||
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
|
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
|
||||||
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
|
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
|
||||||
platformModels = defaultModelIDsForPlatform(platform)
|
platformModels = defaultModelIDsForPlatform(platform)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1451,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string {
|
|||||||
return claude.DefaultModelIDs()
|
return claude.DefaultModelIDs()
|
||||||
case service.PlatformGrok:
|
case service.PlatformGrok:
|
||||||
return xai.DefaultModelIDs()
|
return xai.DefaultModelIDs()
|
||||||
|
case service.PlatformOpenCodeGo:
|
||||||
|
return service.DefaultOpenCodeGoModelIDs()
|
||||||
case service.PlatformComposite:
|
case service.PlatformComposite:
|
||||||
ids := make([]string, 0)
|
ids := make([]string, 0)
|
||||||
seen := make(map[string]struct{})
|
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) {
|
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||||
if _, ok := seen[id]; ok {
|
if _, ok := seen[id]; ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
||||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
|
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}}
|
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
|
||||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
||||||
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
||||||
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
|
|||||||
|
|
||||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
|
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||||
|
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
|
||||||
|
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||||
|
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
|
||||||
|
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
|
||||||
}
|
}
|
||||||
@@ -198,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
|
|||||||
if apiKey == nil || apiKey.Group == nil {
|
if apiKey == nil || apiKey.Group == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
|
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
|
||||||
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
||||||
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
||||||
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
||||||
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
|
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
|||||||
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
|
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
|
||||||
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
|
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
|
||||||
// 若不豁免,CN 分组将永远 403。
|
// 若不豁免,CN 分组将永远 403。
|
||||||
if service.IsCNProvider(apiKey.Group.Platform) {
|
if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免;
|
// composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免;
|
||||||
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
|
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
|
||||||
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
||||||
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
||||||
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
|
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
|
||||||
return true
|
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 {
|
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
|
||||||
return compositeTargetPlatformAllowed(c, apiKey, model,
|
return compositeTargetPlatformAllowed(c, apiKey, model,
|
||||||
service.PlatformOpenAI, service.PlatformGrok,
|
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
|
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ const (
|
|||||||
PlatformZhipu = domain.PlatformZhipu
|
PlatformZhipu = domain.PlatformZhipu
|
||||||
PlatformDeepseek = domain.PlatformDeepseek
|
PlatformDeepseek = domain.PlatformDeepseek
|
||||||
PlatformMiniMax = domain.PlatformMiniMax
|
PlatformMiniMax = domain.PlatformMiniMax
|
||||||
|
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllPlatforms 返回所有支持的平台列表
|
// AllPlatforms 返回所有支持的平台列表
|
||||||
@@ -59,6 +60,7 @@ func AllPlatforms() []string {
|
|||||||
PlatformZhipu,
|
PlatformZhipu,
|
||||||
PlatformDeepseek,
|
PlatformDeepseek,
|
||||||
PlatformMiniMax,
|
PlatformMiniMax,
|
||||||
|
PlatformOpenCodeGo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
|
|||||||
"zhipu",
|
"zhipu",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"minimax",
|
"minimax",
|
||||||
|
"opencode_go",
|
||||||
}, AllPlatforms())
|
}, AllPlatforms())
|
||||||
}
|
}
|
||||||
@@ -863,7 +863,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"force_email_on_third_party_signup": false,
|
"force_email_on_third_party_signup": false,
|
||||||
"default_concurrency": 5,
|
"default_concurrency": 5,
|
||||||
"default_balance": 1.25,
|
"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_email_platform_quotas": null,
|
||||||
"auth_source_default_github_platform_quotas": null,
|
"auth_source_default_github_platform_quotas": null,
|
||||||
"auth_source_default_google_platform_quotas": null,
|
"auth_source_default_google_platform_quotas": null,
|
||||||
@@ -1182,7 +1182,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"purchase_subscription_url": "",
|
"purchase_subscription_url": "",
|
||||||
"table_default_page_size": 20,
|
"table_default_page_size": 20,
|
||||||
"table_page_size_options": [10, 20, 50],
|
"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_email_platform_quotas": null,
|
||||||
"auth_source_default_github_platform_quotas": null,
|
"auth_source_default_github_platform_quotas": null,
|
||||||
"auth_source_default_google_platform_quotas": null,
|
"auth_source_default_google_platform_quotas": null,
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ func RegisterGatewayRoutes(
|
|||||||
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
|
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
|
||||||
switch getGroupPlatform(c) {
|
switch getGroupPlatform(c) {
|
||||||
case service.PlatformOpenAI, service.PlatformGrok,
|
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 网关转发。
|
// 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
@@ -57,7 +58,7 @@ func RegisterGatewayRoutes(
|
|||||||
}
|
}
|
||||||
countTokensHandler := func(c *gin.Context) {
|
countTokensHandler := func(c *gin.Context) {
|
||||||
switch getGroupPlatform(c) {
|
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)
|
h.OpenAIGateway.CountTokens(c)
|
||||||
case service.PlatformGrok:
|
case service.PlatformGrok:
|
||||||
h.OpenAIGateway.GrokCountTokens(c)
|
h.OpenAIGateway.GrokCountTokens(c)
|
||||||
|
|||||||
@@ -296,9 +296,9 @@ func (a *Account) IsCNProvider() bool {
|
|||||||
|
|
||||||
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
|
// IsOpenAICompatible 报告账号是否走 OpenAI 网关(OpenAI 协议族)。
|
||||||
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
|
// openai/grok 原生走 OpenAI 网关;国产供应商同为 OpenAI Chat Completions
|
||||||
// 兼容上游,也经 OpenAI 网关转发。
|
// 兼容上游,也经 OpenAI 网关转发。OpenCode 同样经 OpenAI 网关按模型分流。
|
||||||
func (a *Account) IsOpenAICompatible() bool {
|
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 {
|
func (a *Account) GeminiOAuthType() string {
|
||||||
@@ -1343,13 +1343,13 @@ func (a *Account) IsOpenAIApiKey() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
|
// GetOpenAIBaseURL 解析 OpenAI 协议族账号的上游 base_url。
|
||||||
// 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL,
|
// 适用 openai、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go;
|
||||||
// 此处对 grok 返回 "" 以保持原有行为。
|
// grok 走 GetGrokBaseURL,此处对 grok 返回 "" 以保持原有行为。
|
||||||
func (a *Account) GetOpenAIBaseURL() string {
|
func (a *Account) GetOpenAIBaseURL() string {
|
||||||
if !a.IsOpenAI() && !a.IsCNProvider() {
|
if !a.IsOpenAI() && !a.IsCNProvider() && !a.IsOpenCodeGo() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
|
if a.IsMultiProtocolAPIKey() && a.IsAdaptiveAPIProtocol() {
|
||||||
if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
|
if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
|
||||||
if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
|
if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
|
||||||
return strings.TrimSpace(baseURL)
|
return strings.TrimSpace(baseURL)
|
||||||
@@ -1377,6 +1377,8 @@ func (a *Account) GetOpenAIBaseURL() string {
|
|||||||
return DefaultDeepseekBaseURL
|
return DefaultDeepseekBaseURL
|
||||||
case PlatformMiniMax:
|
case PlatformMiniMax:
|
||||||
return DefaultMiniMaxBaseURL
|
return DefaultMiniMaxBaseURL
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return a.openCodeDefaultChatBaseURL()
|
||||||
default:
|
default:
|
||||||
return "https://api.openai.com"
|
return "https://api.openai.com"
|
||||||
}
|
}
|
||||||
@@ -1405,7 +1407,7 @@ func (a *Account) IsCodingPlan() bool {
|
|||||||
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
|
// (与既有行为完全一致)。responses 协议仅 deepseek / kimi / minimax 支持(官方原生
|
||||||
// Responses 端点,适配 Codex);zhipu 无此端点。
|
// Responses 端点,适配 Codex);zhipu 无此端点。
|
||||||
func (a *Account) GetAPIProtocol() string {
|
func (a *Account) GetAPIProtocol() string {
|
||||||
if a == nil || !a.IsCNProvider() {
|
if a == nil || !a.IsMultiProtocolAPIKey() {
|
||||||
return APIProtocolChatCompletions
|
return APIProtocolChatCompletions
|
||||||
}
|
}
|
||||||
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
|
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
|
||||||
@@ -1420,6 +1422,9 @@ func (a *Account) GetAPIProtocol() string {
|
|||||||
case APIProtocolChatCompletions:
|
case APIProtocolChatCompletions:
|
||||||
return APIProtocolChatCompletions
|
return APIProtocolChatCompletions
|
||||||
}
|
}
|
||||||
|
if a.IsOpenCodeGo() {
|
||||||
|
return APIProtocolAdaptive
|
||||||
|
}
|
||||||
return APIProtocolChatCompletions
|
return APIProtocolChatCompletions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1431,7 +1436,7 @@ func (a *Account) SupportsNativeCNResponses() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch a.Platform {
|
switch a.Platform {
|
||||||
case PlatformDeepseek, PlatformKimi, PlatformMiniMax:
|
case PlatformDeepseek, PlatformKimi, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1461,7 +1466,7 @@ func (a *Account) IsAdaptiveAPIProtocol() bool {
|
|||||||
// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
|
// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
|
||||||
// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
|
// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
|
||||||
func (a *Account) GetCNProtocolBaseURL(protocol string) string {
|
func (a *Account) GetCNProtocolBaseURL(protocol string) string {
|
||||||
if a == nil || !a.IsCNProvider() {
|
if a == nil || !a.IsMultiProtocolAPIKey() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if a.IsAdaptiveAPIProtocol() {
|
if a.IsAdaptiveAPIProtocol() {
|
||||||
@@ -1494,6 +1499,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
|
|||||||
return DefaultDeepseekAnthropicBaseURL
|
return DefaultDeepseekAnthropicBaseURL
|
||||||
case PlatformMiniMax:
|
case PlatformMiniMax:
|
||||||
return DefaultMiniMaxAnthropicBaseURL
|
return DefaultMiniMaxAnthropicBaseURL
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return a.openCodeDefaultAnthropicBaseURL()
|
||||||
}
|
}
|
||||||
case APIProtocolChatCompletions, APIProtocolResponses:
|
case APIProtocolChatCompletions, APIProtocolResponses:
|
||||||
switch a.Platform {
|
switch a.Platform {
|
||||||
@@ -1511,6 +1518,8 @@ func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
|
|||||||
return DefaultDeepseekBaseURL
|
return DefaultDeepseekBaseURL
|
||||||
case PlatformMiniMax:
|
case PlatformMiniMax:
|
||||||
return DefaultMiniMaxBaseURL
|
return DefaultMiniMaxBaseURL
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return a.openCodeDefaultChatBaseURL()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
@@ -1549,6 +1558,8 @@ func (a *Account) GetAnthropicProtocolBaseURL() string {
|
|||||||
return DefaultDeepseekAnthropicBaseURL
|
return DefaultDeepseekAnthropicBaseURL
|
||||||
case PlatformMiniMax:
|
case PlatformMiniMax:
|
||||||
return DefaultMiniMaxAnthropicBaseURL
|
return DefaultMiniMaxAnthropicBaseURL
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return a.openCodeDefaultAnthropicBaseURL()
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1578,6 +1589,8 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
|
|||||||
return DefaultDeepseekBaseURL
|
return DefaultDeepseekBaseURL
|
||||||
case PlatformMiniMax:
|
case PlatformMiniMax:
|
||||||
return DefaultMiniMaxBaseURL
|
return DefaultMiniMaxBaseURL
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return a.openCodeDefaultChatBaseURL()
|
||||||
default:
|
default:
|
||||||
return a.GetOpenAIBaseURL()
|
return a.GetOpenAIBaseURL()
|
||||||
}
|
}
|
||||||
@@ -1586,7 +1599,7 @@ func (a *Account) GetOpenAIFormatBaseURL() string {
|
|||||||
// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
|
// GetCNAPIKey 返回国产 OpenAI 兼容供应商账号的 api_key 凭据(kimi/zhipu/deepseek)。
|
||||||
// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
|
// 与 openai 的 GetOpenAIApiKey 区分:后者仅对 openai 平台返回。
|
||||||
func (a *Account) GetCNAPIKey() string {
|
func (a *Account) GetCNAPIKey() string {
|
||||||
if a == nil || !a.IsCNProvider() {
|
if a == nil || !a.IsMultiProtocolAPIKey() {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return a.GetCredential("api_key")
|
return a.GetCredential("api_key")
|
||||||
@@ -1596,7 +1609,13 @@ func (a *Account) GetCNAPIKey() string {
|
|||||||
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
|
// 用于路由到对应的额度查询端点。非 coding 模式或无法识别时返回空串。
|
||||||
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
|
// 只认官方域名:自定义中转不得把第三方 Key 发往厂商官方额度端点。
|
||||||
func (a *Account) GetCodingPlanProvider() string {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
baseURL := strings.ToLower(a.GetOpenAIBaseURL())
|
baseURL := strings.ToLower(a.GetOpenAIBaseURL())
|
||||||
@@ -1725,14 +1744,15 @@ func (a *Account) GetOpenAIApiKey() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
|
// GetOpenAIProtocolAPIKey 返回 OpenAI 协议族 APIKey 账号的密钥。
|
||||||
// 覆盖 openai 原生账号与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)账号,
|
// 覆盖 openai 原生账号、国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)
|
||||||
// 供转发鉴权、模型列表同步等协议族共用路径使用。注意 IsOpenAIApiKey 语义上
|
// 以及 OpenCode Go 账号,供转发鉴权、模型列表同步等协议族共用路径使用。
|
||||||
// 仅指 openai 平台账号,调度倍率/WS 能力门控继续以其为准,不受本方法影响。
|
// 注意 IsOpenAIApiKey 语义上仅指 openai 平台账号,调度倍率/WS 能力门控
|
||||||
|
// 继续以其为准,不受本方法影响。
|
||||||
func (a *Account) GetOpenAIProtocolAPIKey() string {
|
func (a *Account) GetOpenAIProtocolAPIKey() string {
|
||||||
if a == nil {
|
if a == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if a.IsCNProvider() {
|
if a.IsMultiProtocolAPIKey() {
|
||||||
if a.Type != AccountTypeAPIKey {
|
if a.Type != AccountTypeAPIKey {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
switch a.Platform {
|
switch a.Platform {
|
||||||
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
return a.Type == AccountTypeAPIKey
|
return a.Type == AccountTypeAPIKey
|
||||||
case PlatformGrok:
|
case PlatformGrok:
|
||||||
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
|
return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ func TestIsHeaderOverrideEligible(t *testing.T) {
|
|||||||
{"kimi apikey", PlatformKimi, AccountTypeAPIKey, true},
|
{"kimi apikey", PlatformKimi, AccountTypeAPIKey, true},
|
||||||
{"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true},
|
{"zhipu apikey", PlatformZhipu, AccountTypeAPIKey, true},
|
||||||
{"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true},
|
{"deepseek apikey", PlatformDeepseek, AccountTypeAPIKey, true},
|
||||||
|
{"opencode go apikey", PlatformOpenCodeGo, AccountTypeAPIKey, true},
|
||||||
{"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
|
{"anthropic oauth", PlatformAnthropic, AccountTypeOAuth, false},
|
||||||
{"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
|
{"openai oauth", PlatformOpenAI, AccountTypeOAuth, false},
|
||||||
{"kimi oauth", PlatformKimi, AccountTypeOAuth, false},
|
{"kimi oauth", PlatformKimi, AccountTypeOAuth, false},
|
||||||
{"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false},
|
{"zhipu oauth", PlatformZhipu, AccountTypeOAuth, false},
|
||||||
{"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false},
|
{"deepseek oauth", PlatformDeepseek, AccountTypeOAuth, false},
|
||||||
|
{"opencode go oauth", PlatformOpenCodeGo, AccountTypeOAuth, false},
|
||||||
{"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
|
{"gemini apikey", PlatformGemini, AccountTypeAPIKey, false},
|
||||||
{"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
|
{"grok apikey", PlatformGrok, AccountTypeAPIKey, true},
|
||||||
{"grok oauth", PlatformGrok, AccountTypeOAuth, true},
|
{"grok oauth", PlatformGrok, AccountTypeOAuth, true},
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func EvaluateAccountSchedulingThreshold(account *Account, thresholds map[string]
|
|||||||
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
|
winner = pickLatestResetSchedulingCandidate(anthropicThresholdCandidates(account), threshold, now)
|
||||||
case PlatformGrok:
|
case PlatformGrok:
|
||||||
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
|
winner = pickLatestResetSchedulingCandidate(grokThresholdCandidates(account), threshold, now)
|
||||||
case PlatformKimi, PlatformZhipu, PlatformMiniMax:
|
case PlatformKimi, PlatformZhipu, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
|
winner = pickLatestResetSchedulingCandidate(cnProviderThresholdCandidates(account, decision.Platform), threshold, now)
|
||||||
default:
|
default:
|
||||||
return decision
|
return decision
|
||||||
@@ -356,10 +356,14 @@ func cnProviderThresholdCandidates(account *Account, provider string) []*account
|
|||||||
if account == nil || len(account.Extra) == 0 {
|
if account == nil || len(account.Extra) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []*accountSchedulingThresholdCandidate{
|
candidates := []*accountSchedulingThresholdCandidate{
|
||||||
cnThresholdCandidate(account.Extra, provider, "5h"),
|
cnThresholdCandidate(account.Extra, provider, "5h"),
|
||||||
cnThresholdCandidate(account.Extra, provider, "weekly"),
|
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 {
|
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":
|
case "weekly":
|
||||||
usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed)
|
usedKey = cnExtraKey(provider, cnExtraSuffixWeeklyUsed)
|
||||||
resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset)
|
resetKey = cnExtraKey(provider, cnExtraSuffixWeeklyReset)
|
||||||
|
case "monthly":
|
||||||
|
usedKey = cnExtraKey(provider, cnExtraSuffixMonthlyUsed)
|
||||||
|
resetKey = cnExtraKey(provider, cnExtraSuffixMonthlyReset)
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -515,8 +515,8 @@ func (s *AccountService) TestCredentials(ctx context.Context, id int64) error {
|
|||||||
case PlatformGrok:
|
case PlatformGrok:
|
||||||
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
|
// Grok OAuth credentials are validated via token exchange/refresh and request-path probes.
|
||||||
return nil
|
return nil
|
||||||
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
// 国产 OpenAI 兼容供应商:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
|
// 国产 OpenAI 兼容供应商与 OpenCode:凭证为 API Key,实际可用性经余额/额度探测与转发路径验证。
|
||||||
return nil
|
return nil
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported platform: %s", account.Platform)
|
return fmt.Errorf("unsupported platform: %s", account.Platform)
|
||||||
|
|||||||
@@ -385,9 +385,56 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
|||||||
return s.routeAntigravityTest(c, account, modelID, prompt)
|
return s.routeAntigravityTest(c, account, modelID, prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if account.IsOpenCodeGo() {
|
||||||
|
return s.testOpenCodeGoAccountConnection(c, account, modelID, prompt)
|
||||||
|
}
|
||||||
|
|
||||||
return s.testClaudeAccountConnection(c, account, modelID)
|
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
|
||||||
|
}
|
||||||
|
testModelID = account.GetMappedModel(testModelID)
|
||||||
|
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 {
|
func (s *AccountTestService) testCNProviderChatCompletionsConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
|
||||||
testModelID := strings.TrimSpace(modelID)
|
testModelID := strings.TrimSpace(modelID)
|
||||||
if testModelID == "" {
|
if testModelID == "" {
|
||||||
@@ -2058,6 +2105,7 @@ func (s *AccountTestService) testOpenAIChatCompletionsConnection(
|
|||||||
|
|
||||||
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
|
// 账号级请求头覆写:测试请求与真实转发保持一致的最终头
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
|
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||||
|
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
if account.ProxyID != nil && account.Proxy != nil {
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Co
|
|||||||
// base_url 强制 Bearer,其余保持 extra/default 行为。
|
// base_url 强制 Bearer,其余保持 extra/default 行为。
|
||||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
|
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
|
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||||
|
|
||||||
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -178,6 +179,7 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co
|
|||||||
req.Header.Set("Authorization", "Bearer "+authToken)
|
req.Header.Set("Authorization", "Bearer "+authToken)
|
||||||
applyOpenAICodexProbeHeaders(req.Header)
|
applyOpenAICodexProbeHeaders(req.Header)
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
|
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||||
|
|
||||||
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -266,6 +268,7 @@ func (s *AccountTestService) testCNProviderAnthropicConnection(c *gin.Context, a
|
|||||||
// extra/default 行为。
|
// extra/default 行为。
|
||||||
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
|
setAnthropicAPIKeyAuthHeader(req.Header, account, authToken, account.GetAnthropicProtocolBaseURL())
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
|
applyOpenCodeSessionHeader(c, account, apiURL, req.Header, payloadBytes)
|
||||||
|
|
||||||
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
resp, err := s.doCNProviderAdaptiveRequest(req, account)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
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_OpenCodeGoAppliesModelMappingOnResponses(t *testing.T) {
|
||||||
|
account := openCodeGoTestAccount(406)
|
||||||
|
account.Credentials["model_mapping"] = map[string]any{
|
||||||
|
"opencode/muse-spark-1.3-contributior-free": "muse-spark-1.3-contributior-free",
|
||||||
|
}
|
||||||
|
svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNResponsesTestResponse())
|
||||||
|
c, recorder := newTestContext()
|
||||||
|
|
||||||
|
err := svc.TestAccountConnection(c, account.ID, "opencode/muse-spark-1.3-contributior-free", "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, "muse-spark-1.3-contributior-free", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||||
|
require.Contains(t, recorder.Body.String(), `"model":"muse-spark-1.3-contributior-free"`)
|
||||||
|
require.NotContains(t, string(upstream.lastBody), "opencode/muse-spark-1.3-contributior-free")
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -323,6 +323,9 @@ func (s *adminServiceImpl) DuplicateAccount(ctx context.Context, id int64, actor
|
|||||||
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
|
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
duplicate, err := buildAccountForCreate(input, accountExtra)
|
duplicate, err := buildAccountForCreate(input, accountExtra)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -514,6 +517,9 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
|||||||
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
|
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Never persist ephemeral SSO/password secrets after OAuth conversion.
|
// Never persist ephemeral SSO/password secrets after OAuth conversion.
|
||||||
input.Credentials = SanitizeStoredCredentials(input.Platform, input.Credentials)
|
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 {
|
if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil {
|
||||||
return nil, err
|
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.
|
// Strip SSO/password residue that must never sit next to OAuth tokens.
|
||||||
account.Credentials = SanitizeStoredCredentials(account.Platform, account.Credentials)
|
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 {
|
if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := NormalizeOpenCodeGoProtocolRulesCredentials(input.Credentials); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
// Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
|
// Bulk may mix platforms; always drop ephemeral SSO/password keys (cookie
|
||||||
// only when platform is known Grok — empty platform still strips password/*).
|
// only when platform is known Grok — empty platform still strips password/*).
|
||||||
if input.Credentials != nil {
|
if input.Credentials != nil {
|
||||||
|
|||||||
@@ -294,6 +294,8 @@ func defaultModelsListCandidateIDs(platform string) []string {
|
|||||||
return ids
|
return ids
|
||||||
case PlatformGrok:
|
case PlatformGrok:
|
||||||
return xai.DefaultModelIDs()
|
return xai.DefaultModelIDs()
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return DefaultOpenCodeGoModelIDs()
|
||||||
case PlatformComposite:
|
case PlatformComposite:
|
||||||
return compositeDefaultModelsListCandidateIDs()
|
return compositeDefaultModelsListCandidateIDs()
|
||||||
default:
|
default:
|
||||||
@@ -314,7 +316,7 @@ func defaultAllowImageGenerationForPlatform(platform string) bool {
|
|||||||
func compositeDefaultModelsListCandidateIDs() []string {
|
func compositeDefaultModelsListCandidateIDs() []string {
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
ids := make([]string, 0)
|
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) {
|
for _, id := range defaultModelsListCandidateIDs(platform) {
|
||||||
if _, ok := seen[id]; ok {
|
if _, ok := seen[id]; ok {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -221,6 +221,8 @@ func (f *ChannelMonitorQuotaFetcher) fetchUncached(ctx context.Context, accountI
|
|||||||
return f.fetchCNQuota(ctx, account, now)
|
return f.fetchCNQuota(ctx, account, now)
|
||||||
}
|
}
|
||||||
return f.fetchCNBalance(ctx, account, now)
|
return f.fetchCNBalance(ctx, account, now)
|
||||||
|
case domain.PlatformOpenCodeGo:
|
||||||
|
return f.fetchCNQuota(ctx, account, now)
|
||||||
default:
|
default:
|
||||||
return f.fetchUsage(ctx, account, now)
|
return f.fetchUsage(ctx, account, now)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ func normalizeMonitorPrimaryModel(provider, checkMode, model string) string {
|
|||||||
// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
|
// - gemini/grok/antigravity:本地统计/值通道降级,不会永久 error,放行
|
||||||
func monitorAccountQuotaCapability(account *Account) error {
|
func monitorAccountQuotaCapability(account *Account) error {
|
||||||
switch account.Platform {
|
switch account.Platform {
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
return nil
|
||||||
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
case PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
||||||
if account.IsCodingPlan() {
|
if account.IsCodingPlan() {
|
||||||
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax {
|
if p := account.GetCodingPlanProvider(); p != PlatformKimi && p != PlatformZhipu && p != PlatformMiniMax {
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ func isPlatformPricingMatch(groupPlatform, pricingPlatform string) bool {
|
|||||||
// fallback used before a request target has been resolved.
|
// fallback used before a request target has been resolved.
|
||||||
func matchingPlatforms(groupPlatform string) []string {
|
func matchingPlatforms(groupPlatform string) []string {
|
||||||
if groupPlatform == PlatformComposite {
|
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}
|
return []string{groupPlatform}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2127,7 +2127,7 @@ func TestMatchingPlatforms(t *testing.T) {
|
|||||||
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
|
{"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}},
|
||||||
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
|
{"gemini returns itself", PlatformGemini, []string{PlatformGemini}},
|
||||||
{"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}},
|
{"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 {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const (
|
|||||||
cnExtraSuffix5hReset = "5h_reset_at"
|
cnExtraSuffix5hReset = "5h_reset_at"
|
||||||
cnExtraSuffixWeeklyUsed = "weekly_used_percent"
|
cnExtraSuffixWeeklyUsed = "weekly_used_percent"
|
||||||
cnExtraSuffixWeeklyReset = "weekly_reset_at"
|
cnExtraSuffixWeeklyReset = "weekly_reset_at"
|
||||||
|
cnExtraSuffixMonthlyUsed = "monthly_used_percent"
|
||||||
|
cnExtraSuffixMonthlyReset = "monthly_reset_at"
|
||||||
cnExtraSuffixUsageUpdated = "usage_updated_at"
|
cnExtraSuffixUsageUpdated = "usage_updated_at"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ func cnExtraKey(provider, suffix string) string { return provider + "_" + suffix
|
|||||||
|
|
||||||
// CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。
|
// CNQuotaTier 表示一个滚动用量窗口档位(5h / weekly)。
|
||||||
type CNQuotaTier struct {
|
type CNQuotaTier struct {
|
||||||
Window string `json:"window"` // "5h" | "weekly"
|
Window string `json:"window"` // "5h" | "weekly" | "monthly"
|
||||||
UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪)
|
UsedPercent float64 `json:"used_percent"` // 已用百分比(0-100+,不做裁剪)
|
||||||
ResetAt string `json:"reset_at,omitempty"` // RFC3339,空表示无重置时间
|
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) {
|
func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, account *Account) (*CNProviderQuotaProbeResult, error) {
|
||||||
provider := account.GetCodingPlanProvider()
|
provider := account.GetCodingPlanProvider()
|
||||||
if provider != PlatformKimi && provider != PlatformZhipu && provider != PlatformMiniMax {
|
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 account")
|
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())
|
apiKey := strings.TrimSpace(account.GetCNAPIKey())
|
||||||
@@ -148,6 +150,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
|
|||||||
case PlatformKimi:
|
case PlatformKimi:
|
||||||
targetURL = kimiQuotaURL(baseURL)
|
targetURL = kimiQuotaURL(baseURL)
|
||||||
authHeader = "Bearer " + apiKey
|
authHeader = "Bearer " + apiKey
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
targetURL = openCodeGoQuotaURL(baseURL)
|
||||||
|
authHeader = "Bearer " + apiKey
|
||||||
case PlatformZhipu:
|
case PlatformZhipu:
|
||||||
targetURL = zhipuQuotaURL(baseURL)
|
targetURL = zhipuQuotaURL(baseURL)
|
||||||
authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀
|
authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀
|
||||||
@@ -234,6 +239,9 @@ func (s *CNProviderQuotaService) queryUsageForAccount(ctx context.Context, accou
|
|||||||
switch provider {
|
switch provider {
|
||||||
case PlatformKimi:
|
case PlatformKimi:
|
||||||
tiers = parseKimiUsageTiers(bodyBytes)
|
tiers = parseKimiUsageTiers(bodyBytes)
|
||||||
|
case PlatformOpenCodeGo:
|
||||||
|
tiers = parseOpenCodeGoUsageTiers(bodyBytes)
|
||||||
|
result.PlanLevel = "OpenCode Go"
|
||||||
case PlatformZhipu:
|
case PlatformZhipu:
|
||||||
tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data"))
|
tiers = parseZhipuTokenTiers(gjson.GetBytes(bodyBytes, "data"))
|
||||||
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String())
|
result.PlanLevel = strings.TrimSpace(gjson.GetBytes(bodyBytes, "data.level").String())
|
||||||
@@ -279,6 +287,12 @@ func validateCodingPlanAccount(account *Account) error {
|
|||||||
if account == nil {
|
if account == nil {
|
||||||
return infraerrors.New(http.StatusNotFound, "CN_QUOTA_ACCOUNT_NOT_FOUND", "account not found")
|
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() {
|
if !account.IsCNProvider() {
|
||||||
return infraerrors.New(http.StatusBadRequest, "CN_QUOTA_INVALID_PLATFORM", "account is not a CN provider account")
|
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 != "" {
|
if t.ResetAt != "" {
|
||||||
updates[cnExtraKey(provider, cnExtraSuffixWeeklyReset)] = 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
|
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)。
|
// cnParseF64 把 JSON 数值或字符串解析为 float64(兼容 "100" 与 100)。
|
||||||
func cnParseF64(raw any) (float64, bool) {
|
func cnParseF64(raw any) (float64, bool) {
|
||||||
switch v := raw.(type) {
|
switch v := raw.(type) {
|
||||||
|
|||||||
@@ -177,6 +177,34 @@ func TestCNQuotaExtraUpdates(t *testing.T) {
|
|||||||
require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"])
|
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 覆盖中英文余额不足文案与否定用例。
|
// TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。
|
||||||
func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) {
|
func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -502,6 +530,7 @@ func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) {
|
|||||||
require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi))
|
require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi))
|
||||||
require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu))
|
require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu))
|
||||||
require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek))
|
require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek))
|
||||||
|
require.Equal(t, PlatformOpenCodeGo, NormalizeOpenAICompatiblePlatform(PlatformOpenCodeGo))
|
||||||
// 其他平台(含空、anthropic、未知)一律归一为 openai。
|
// 其他平台(含空、anthropic、未知)一律归一为 openai。
|
||||||
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(""))
|
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(""))
|
||||||
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic))
|
require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic))
|
||||||
@@ -536,6 +565,14 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) {
|
|||||||
Credentials: map[string]any{"api_key": "sk-openai"},
|
Credentials: map[string]any{"api_key": "sk-openai"},
|
||||||
}
|
}
|
||||||
require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey())
|
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 验证“同步上游支持的模型”对国产供应商可用:
|
// TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用:
|
||||||
@@ -557,6 +594,8 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
|
|||||||
{"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"},
|
{"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"},
|
||||||
{"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"},
|
{"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"},
|
||||||
{"minimax coding", PlatformMiniMax, AccountModeCoding, "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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
|
||||||
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
|
require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
|
||||||
require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认")
|
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) {
|
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}).SupportsNativeCNResponses())
|
||||||
require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).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: PlatformMiniMax}).SupportsNativeCNResponses())
|
||||||
|
require.True(t, (&Account{Platform: PlatformOpenCodeGo}).SupportsNativeCNResponses())
|
||||||
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
|
require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
|
||||||
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
|
require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
|
||||||
}
|
}
|
||||||
@@ -631,6 +673,8 @@ func TestAdaptiveProtocolBaseURLs(t *testing.T) {
|
|||||||
{"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
|
{"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
|
||||||
{"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
|
{"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
|
||||||
{"minimax coding", PlatformMiniMax, AccountModeCoding, 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 {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ func (s *GatewayService) resolveCompositeRouteDecision(ctx context.Context, grou
|
|||||||
func isConcreteRequestPlatform(platform string) bool {
|
func isConcreteRequestPlatform(platform string) bool {
|
||||||
switch platform {
|
switch platform {
|
||||||
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
|
case PlatformAnthropic, PlatformOpenAI, PlatformGemini, PlatformAntigravity, PlatformGrok,
|
||||||
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -216,13 +216,13 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) {
|
|||||||
platforms = append(platforms, platform)
|
platforms = append(platforms, platform)
|
||||||
}
|
}
|
||||||
require.ElementsMatch(t,
|
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,
|
platforms,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCompositeConcretePlatformsIncludeCNProviders(t *testing.T) {
|
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, isConcreteRequestPlatform(platform))
|
||||||
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
|
require.True(t, canCopyAccountsFromGroupPlatform(PlatformComposite, platform))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ const (
|
|||||||
PlatformAntigravity = domain.PlatformAntigravity
|
PlatformAntigravity = domain.PlatformAntigravity
|
||||||
PlatformGrok = domain.PlatformGrok
|
PlatformGrok = domain.PlatformGrok
|
||||||
// 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。
|
// 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。
|
||||||
PlatformKimi = domain.PlatformKimi
|
PlatformKimi = domain.PlatformKimi
|
||||||
PlatformZhipu = domain.PlatformZhipu
|
PlatformZhipu = domain.PlatformZhipu
|
||||||
PlatformDeepseek = domain.PlatformDeepseek
|
PlatformDeepseek = domain.PlatformDeepseek
|
||||||
PlatformMiniMax = domain.PlatformMiniMax
|
PlatformMiniMax = domain.PlatformMiniMax
|
||||||
PlatformComposite = domain.PlatformComposite
|
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
|
||||||
|
PlatformComposite = domain.PlatformComposite
|
||||||
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
|
// PlatformKiro is retained for unsupported-platform threshold tests and legacy
|
||||||
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
|
// account rows. Scheduling-threshold evaluation never pauses kiro accounts.
|
||||||
PlatformKiro = "kiro"
|
PlatformKiro = "kiro"
|
||||||
@@ -58,6 +59,8 @@ const (
|
|||||||
const (
|
const (
|
||||||
AccountModePayG = domain.AccountModePayG
|
AccountModePayG = domain.AccountModePayG
|
||||||
AccountModeCoding = domain.AccountModeCoding
|
AccountModeCoding = domain.AccountModeCoding
|
||||||
|
AccountModeZen = domain.AccountModeZen
|
||||||
|
AccountModeGo = domain.AccountModeGo
|
||||||
)
|
)
|
||||||
|
|
||||||
// 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。
|
// 上游 API 协议(国产供应商):决定转发端点与格式,与接入模式正交。
|
||||||
@@ -78,6 +81,10 @@ const (
|
|||||||
DefaultDeepseekBaseURL = "https://api.deepseek.com"
|
DefaultDeepseekBaseURL = "https://api.deepseek.com"
|
||||||
// MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。
|
// MiniMax 按量付费与 Coding/Token Plan 共用推理域名,靠 API Key 区分套餐。
|
||||||
DefaultMiniMaxBaseURL = "https://api.minimaxi.com/v1"
|
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)。
|
// 国产供应商 Anthropic 协议端点的默认 base_url(上游路径为 {base}/v1/messages)。
|
||||||
@@ -88,6 +95,9 @@ const (
|
|||||||
DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic"
|
DefaultZhipuAnthropicBaseURL = "https://open.bigmodel.cn/api/anthropic"
|
||||||
DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic"
|
DefaultDeepseekAnthropicBaseURL = "https://api.deepseek.com/anthropic"
|
||||||
DefaultMiniMaxAnthropicBaseURL = "https://api.minimaxi.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)。
|
// 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 的平台列表(单一权威来源)。
|
// AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。
|
||||||
// ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束),
|
// ent/schema/user_platform_quota.go 的 Validate 函数独立维护(构建期约束),
|
||||||
// 若新增平台需同步修改该 schema。
|
// 若新增平台需同步修改该 schema。
|
||||||
@@ -113,6 +134,7 @@ var AllowedQuotaPlatforms = []string{
|
|||||||
PlatformZhipu,
|
PlatformZhipu,
|
||||||
PlatformDeepseek,
|
PlatformDeepseek,
|
||||||
PlatformMiniMax,
|
PlatformMiniMax,
|
||||||
|
PlatformOpenCodeGo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
|
// AllowedSchedulingThresholdPlatforms 是允许设置账号自动停调阈值的平台列表。
|
||||||
@@ -125,6 +147,7 @@ var AllowedSchedulingThresholdPlatforms = []string{
|
|||||||
PlatformKimi,
|
PlatformKimi,
|
||||||
PlatformZhipu,
|
PlatformZhipu,
|
||||||
PlatformMiniMax,
|
PlatformMiniMax,
|
||||||
|
PlatformOpenCodeGo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。
|
// IsAllowedQuotaPlatform 报告 s 是否为合法的 quota platform 标识。
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
|
|||||||
// 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用,
|
// 账号级请求头覆写:放在所有内置默认头(含 Grok CLI 身份头)之后应用,
|
||||||
// 使配置值获得除共享传输层强制头之外的最高优先级。
|
// 使配置值获得除共享传输层强制头之外的最高优先级。
|
||||||
account.ApplyHeaderOverrides(upstreamReq.Header)
|
account.ApplyHeaderOverrides(upstreamReq.Header)
|
||||||
applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header)
|
applyOpenCodeSessionHeader(c, account, targetURL, upstreamReq.Header, body)
|
||||||
|
|
||||||
proxyURL := ""
|
proxyURL := ""
|
||||||
if account.ProxyID != nil && account.Proxy != nil {
|
if account.ProxyID != nil && account.Proxy != nil {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
|||||||
defaultMappedModel string,
|
defaultMappedModel string,
|
||||||
compatPromptCacheTenantIsolated bool,
|
compatPromptCacheTenantIsolated bool,
|
||||||
) (*OpenAIForwardResult, error) {
|
) (*OpenAIForwardResult, error) {
|
||||||
|
rememberOpenCodeInboundBody(c, body)
|
||||||
beginUpstreamResponseModelObservation(c)
|
beginUpstreamResponseModelObservation(c)
|
||||||
ClearActualOpenAIUpstreamEndpoint(c)
|
ClearActualOpenAIUpstreamEndpoint(c)
|
||||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||||
@@ -113,10 +114,44 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
|||||||
// accounts never forward the body unchanged to a Chat Completions endpoint.
|
// accounts never forward the body unchanged to a Chat Completions endpoint.
|
||||||
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
|
isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
|
||||||
|
|
||||||
|
// OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。
|
||||||
|
// 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。
|
||||||
|
if account.IsOpenCodeGo() {
|
||||||
|
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
|
||||||
|
proto := openCodeGoNativeProtocol(account, mapped)
|
||||||
|
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 端点。
|
// 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。
|
||||||
// Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM
|
// Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM
|
||||||
// 没有 Responses 端点,先转换成 Chat Completions 再直转。
|
// 没有 Responses 端点,先转换成 Chat Completions 再直转。
|
||||||
if account.IsAdaptiveAPIProtocol() {
|
if account.IsAdaptiveAPIProtocol() && !account.IsOpenCodeGo() {
|
||||||
if !isResponsesShape {
|
if !isResponsesShape {
|
||||||
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||||
}
|
}
|
||||||
@@ -154,6 +189,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
|||||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||||
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||||
}
|
}
|
||||||
|
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@@ -376,6 +412,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
|||||||
return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated)
|
return s.forwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel, compatPromptCacheTenantIsolated)
|
||||||
}
|
}
|
||||||
if account.Type == AccountTypeAPIKey &&
|
if account.Type == AccountTypeAPIKey &&
|
||||||
|
!account.IsOpenCodeGo() &&
|
||||||
openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown &&
|
openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown &&
|
||||||
!isResponsesEndpointSupportedByStatus(resp.StatusCode) {
|
!isResponsesEndpointSupportedByStatus(resp.StatusCode) {
|
||||||
logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions",
|
logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions",
|
||||||
@@ -399,6 +436,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
|||||||
} else {
|
} else {
|
||||||
result, handleErr = s.handleChatBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
|
result, handleErr = s.handleChatBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
|
||||||
}
|
}
|
||||||
|
stampOpenAIResponsesUpstreamEndpoint(c, result)
|
||||||
|
|
||||||
// cyber_policy:标记已设、error 已按 Chat Completions 格式发给客户端。丢弃 result、
|
// cyber_policy:标记已设、error 已按 Chat Completions 格式发给客户端。丢弃 result、
|
||||||
// 返回哨兵,使 handler 落入 tokens=0 免费用量行(对齐 /v1/responses),不计费、不 failover。
|
// 返回哨兵,使 handler 落入 tokens=0 免费用量行(对齐 /v1/responses),不计费、不 failover。
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
|
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()
|
releaseUpstreamCtx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
|
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
|
||||||
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
|
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
|
||||||
g := &Group{Platform: platform}
|
g := &Group{Platform: platform}
|
||||||
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
|
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
|
||||||
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
|
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
|
||||||
@@ -57,6 +57,12 @@ func TestFilterCNProviderBillingModelCandidates(t *testing.T) {
|
|||||||
require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough)
|
require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough)
|
||||||
|
|
||||||
require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil))
|
require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil))
|
||||||
|
|
||||||
|
openCodeAccount := &Account{ID: 3, Platform: PlatformOpenCodeGo}
|
||||||
|
openCodeFiltered := svc.filterCNProviderBillingModelCandidates(context.Background(), openCodeAccount, apiKey,
|
||||||
|
[]string{"claude-sonnet-4-5", "muse-spark-1.3-contributor-free"})
|
||||||
|
require.Equal(t, []string{"muse-spark-1.3-contributor-free"}, openCodeFiltered,
|
||||||
|
"OpenCode 无显式定价时不得按 Claude 原价计费 claude-*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) {
|
func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) {
|
||||||
|
|||||||
@@ -264,14 +264,14 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
|
|||||||
return fmt.Errorf("count_tokens: missing account")
|
return fmt.Errorf("count_tokens: missing account")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。
|
// 国产供应商与 OpenCode(全部协议,含 anthropic):一律本地估算,不发上游请求。
|
||||||
// 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供
|
// 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供
|
||||||
// /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点
|
// /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点
|
||||||
// (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注
|
// (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注
|
||||||
// count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游
|
// count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游
|
||||||
// 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code
|
// 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code
|
||||||
// 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。
|
// 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。
|
||||||
if account.IsCNProvider() {
|
if account.IsCNProvider() || account.IsOpenCodeGo() {
|
||||||
estimated, err := estimateAnthropicCountTokensLocally(body)
|
estimated, err := estimateAnthropicCountTokensLocally(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
}
|
}
|
||||||
|
|
||||||
originalBody := body
|
originalBody := body
|
||||||
|
rememberOpenCodeInboundBody(c, originalBody)
|
||||||
requestView := newOpenAIRequestView(body)
|
requestView := newOpenAIRequestView(body)
|
||||||
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||||
originalModel := reqModel
|
originalModel := reqModel
|
||||||
@@ -158,6 +159,18 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
|
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if account.IsOpenCodeGo() {
|
||||||
|
mapped := resolveOpenCodeGoMappedModel(account, body, "")
|
||||||
|
switch openCodeGoNativeProtocol(account, mapped) {
|
||||||
|
case APIProtocolAnthropic:
|
||||||
|
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "")
|
||||||
|
case APIProtocolResponses:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
|
// CN 供应商 anthropic 协议账号:/v1/responses 入站是交叉协议组合
|
||||||
// (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
|
// (Responses 客户端 × Anthropic 上游),转成 Anthropic 请求走原生端点。
|
||||||
// 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
|
// 不能落到下面的 raw-CC 分支——其 URL 构造会把 anthropic base 当 CC base 用。
|
||||||
@@ -185,6 +198,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||||
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
|
||||||
}
|
}
|
||||||
|
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
|
||||||
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
|
if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) {
|
||||||
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
|
normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body)
|
||||||
if reasoningErr != nil {
|
if reasoningErr != nil {
|
||||||
@@ -1322,6 +1336,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
|||||||
if searchCount > 0 && account != nil && account.IsGrok() {
|
if searchCount > 0 && account != nil && account.IsGrok() {
|
||||||
forwardResult.SearchCount = searchCount
|
forwardResult.SearchCount = searchCount
|
||||||
}
|
}
|
||||||
|
stampOpenAIResponsesUpstreamEndpoint(c, forwardResult)
|
||||||
return forwardResult, nil
|
return forwardResult, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1330,6 +1345,11 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
|
|||||||
if account == nil || account.Type != AccountTypeAPIKey {
|
if account == nil || account.Type != AccountTypeAPIKey {
|
||||||
return false
|
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() {
|
if account.IsCNProvider() {
|
||||||
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
|
// CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi
|
||||||
// 有原生 Responses,GLM 回退 Chat Completions。
|
// 有原生 Responses,GLM 回退 Chat Completions。
|
||||||
@@ -1490,7 +1510,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
|
|||||||
|
|
||||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
|
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body, openCodeSessionHintBody(promptCacheKey))
|
||||||
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
||||||
// 保证不被覆盖丢失)。
|
// 保证不被覆盖丢失)。
|
||||||
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
|||||||
promptCacheKey string,
|
promptCacheKey string,
|
||||||
defaultMappedModel string,
|
defaultMappedModel string,
|
||||||
) (*OpenAIForwardResult, error) {
|
) (*OpenAIForwardResult, error) {
|
||||||
|
rememberOpenCodeInboundBody(c, body)
|
||||||
beginUpstreamResponseModelObservation(c)
|
beginUpstreamResponseModelObservation(c)
|
||||||
ClearActualOpenAIUpstreamEndpoint(c)
|
ClearActualOpenAIUpstreamEndpoint(c)
|
||||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||||
@@ -43,12 +44,23 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
|
// OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。
|
||||||
// /v1/messages 请求零转换直通(仅模型名映射 + 少量 body 清洗),完整保留
|
if account.IsOpenCodeGo() {
|
||||||
// thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。
|
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
|
||||||
// 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标
|
switch openCodeGoNativeProtocol(account, mapped) {
|
||||||
// openai_responses_supported=false,会先命中下方的 CC 直转分支。
|
case APIProtocolAnthropic:
|
||||||
if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() {
|
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)
|
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +69,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
|||||||
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
|
||||||
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||||
}
|
}
|
||||||
|
SetActualOpenAIUpstreamEndpoint(c, openAIResponsesUpstreamEndpoint)
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
@@ -530,6 +543,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
|||||||
notifyOpenAIAutoReset(*account.ParentAccountID)
|
notifyOpenAIAutoReset(*account.ParentAccountID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stampOpenAIResponsesUpstreamEndpoint(c, result)
|
||||||
return result, handleErr
|
return result, handleErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,19 @@ func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (strin
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("invalid base_url: %w", err)
|
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
|
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(
|
func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
@@ -143,6 +153,7 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
|||||||
body []byte,
|
body []byte,
|
||||||
apiKey string,
|
apiKey string,
|
||||||
targetURL string,
|
targetURL string,
|
||||||
|
sessionBodies ...[]byte,
|
||||||
) (*http.Request, []byte, error) {
|
) (*http.Request, []byte, error) {
|
||||||
// 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta
|
// 能力维度 body sanitize:与 Anthropic 平台 passthrough 相同,按 beta
|
||||||
// header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但
|
// header 决定是否保留 body 中的 beta 能力字段,避免客户端"body 带字段但
|
||||||
@@ -199,6 +210,8 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
|||||||
|
|
||||||
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
|
// 账号级请求头覆写(最终生效,覆盖上面所有来源的同名头)
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
|
payloads := append([][]byte{body}, sessionBodies...)
|
||||||
|
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, payloads...)
|
||||||
|
|
||||||
return req, body, nil
|
return req, body, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openCodeMappedTestAccount() *Account {
|
||||||
|
return &Account{
|
||||||
|
ID: 801,
|
||||||
|
Name: "oc",
|
||||||
|
Platform: PlatformOpenCodeGo,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"api_key": "sk-opencode",
|
||||||
|
"account_mode": AccountModeZen,
|
||||||
|
"api_protocol": APIProtocolAdaptive,
|
||||||
|
"base_url": "https://opencode.ai/zen/v1",
|
||||||
|
"api_base_urls": map[string]any{
|
||||||
|
APIProtocolChatCompletions: "https://opencode.ai/zen/v1",
|
||||||
|
APIProtocolAnthropic: "https://opencode.ai/zen",
|
||||||
|
APIProtocolResponses: "https://opencode.ai/zen/v1",
|
||||||
|
},
|
||||||
|
"model_mapping": map[string]any{
|
||||||
|
"opencode/muse-spark-1.3-contributior-free": "muse-spark-1.3-contributior-free",
|
||||||
|
"opencode/claude-sonnet-4": "claude-sonnet-4",
|
||||||
|
"opencode/glm-5.3": "glm-5.3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCodeGatewayAppliesMappedModelOnAllIngresses(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
wantURL string
|
||||||
|
wantModel string
|
||||||
|
wantNotModel string
|
||||||
|
forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error
|
||||||
|
upstream *http.Response
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "responses muse-spark",
|
||||||
|
path: "/v1/responses",
|
||||||
|
body: `{"model":"opencode/muse-spark-1.3-contributior-free","stream":false,"input":"hi"}`,
|
||||||
|
wantURL: "https://opencode.ai/zen/v1/responses",
|
||||||
|
wantModel: "muse-spark-1.3-contributior-free",
|
||||||
|
wantNotModel: "opencode/muse-spark-1.3-contributior-free",
|
||||||
|
upstream: &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-responses"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"id":"resp_oc","object":"response","model":"muse-spark-1.3-contributior-free","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}`,
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
|
||||||
|
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||||
|
_, err := svc.Forward(context.Background(), c, account, body)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "chat completions glm",
|
||||||
|
path: "/v1/chat/completions",
|
||||||
|
body: `{"model":"opencode/glm-5.3","stream":false,"messages":[{"role":"user","content":"hi"}]}`,
|
||||||
|
wantURL: "https://opencode.ai/zen/v1/chat/completions",
|
||||||
|
wantModel: "glm-5.3",
|
||||||
|
wantNotModel: "opencode/glm-5.3",
|
||||||
|
upstream: &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid-oc-cc"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(
|
||||||
|
`{"id":"chatcmpl_oc","object":"chat.completion","model":"glm-5.3","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`,
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
|
||||||
|
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "messages claude",
|
||||||
|
path: "/v1/messages",
|
||||||
|
body: `{"model":"opencode/claude-sonnet-4","max_tokens":32,"stream":false,"messages":[{"role":"user","content":"hi"}]}`,
|
||||||
|
wantURL: "https://opencode.ai/zen/v1/messages",
|
||||||
|
wantModel: "claude-sonnet-4",
|
||||||
|
wantNotModel: "opencode/claude-sonnet-4",
|
||||||
|
upstream: nativeAnthropicBufferedResponse(),
|
||||||
|
forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
|
||||||
|
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
upstream := &httpUpstreamRecorder{resp: tt.upstream}
|
||||||
|
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
|
||||||
|
c := adaptiveProtocolTestContext(tt.path, []byte(tt.body))
|
||||||
|
|
||||||
|
err := tt.forward(svc, c, openCodeMappedTestAccount(), []byte(tt.body))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, upstream.lastReq)
|
||||||
|
require.Equal(t, tt.wantURL, upstream.lastReq.URL.String())
|
||||||
|
require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String())
|
||||||
|
require.NotContains(t, string(upstream.lastBody), tt.wantNotModel)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -721,7 +721,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough(
|
|||||||
|
|
||||||
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
// 账号级请求头覆写(仅 openai api_key 账号启用时生效;OAuth 路径 no-op)
|
||||||
account.ApplyHeaderOverrides(req.Header)
|
account.ApplyHeaderOverrides(req.Header)
|
||||||
applyOpenCodeSessionHeader(c, account, targetURL, req.Header)
|
applyOpenCodeSessionHeader(c, account, targetURL, req.Header, body)
|
||||||
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
// x-codex-beta-features:按真实 Codex 的会话级行为补注(在账号级覆写之后,
|
||||||
// 保证不被覆盖丢失)。
|
// 保证不被覆盖丢失)。
|
||||||
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
applyOpenAICodexBetaFeatures(c, account, req.Header)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, reqStream)
|
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()
|
releaseUpstreamCtx()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("build upstream request: %w", err)
|
return nil, fmt.Errorf("build upstream request: %w", err)
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount(
|
|||||||
// handler 调度入口仍需导出,保持导出名。)
|
// handler 调度入口仍需导出,保持导出名。)
|
||||||
func NormalizeOpenAICompatiblePlatform(platform string) string {
|
func NormalizeOpenAICompatiblePlatform(platform string) string {
|
||||||
switch platform {
|
switch platform {
|
||||||
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
return platform
|
return platform
|
||||||
default:
|
default:
|
||||||
return PlatformOpenAI
|
return PlatformOpenAI
|
||||||
|
|||||||
@@ -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
|
// SetActualOpenAIUpstreamEndpoint records the endpoint selected by the current
|
||||||
// forwarding attempt. It covers error paths where no OpenAIForwardResult is
|
// forwarding attempt. It covers error paths where no OpenAIForwardResult is
|
||||||
// available for usage and operations logging.
|
// available for usage and operations logging.
|
||||||
|
|||||||
@@ -918,7 +918,7 @@ func groupMediaPricingLooksIncomplete(group *Group) bool {
|
|||||||
// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
|
// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
|
||||||
// 分组/渠道显式定价。
|
// 分组/渠道显式定价。
|
||||||
func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
|
func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
|
||||||
if account == nil || !account.IsCNProvider() {
|
if account == nil || (!account.IsCNProvider() && !account.IsOpenCodeGo()) {
|
||||||
return candidates
|
return candidates
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(candidates))
|
out := make([]string, 0, len(candidates))
|
||||||
|
|||||||
@@ -79,10 +79,10 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string {
|
|||||||
return xai.ModelMappingWithOptions(opts)["claude-*"]
|
return xai.ModelMappingWithOptions(opts)["claude-*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的
|
// 国产供应商 / OpenCode 分组:调度级模型映射不适用(其配置被 sanitize 置空,
|
||||||
// gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级
|
// 且下方的 gpt-5.x 默认值是 openai 专属,发给这些上游必错)。模型改写完全
|
||||||
// model_mapping;anthropic 协议上游本身接受 claude-* 模型名。
|
// 交给账号级 model_mapping;Anthropic 协议上游本身接受 claude-* 模型名。
|
||||||
if IsCNProvider(g.Platform) {
|
if IsMultiProtocolAPIKeyProvider(g.Platform) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,28 +3,61 @@ package service
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// rememberOpenCodeInboundBody keeps the client request body so protocol
|
||||||
// only to OpenCode's official API origin. The caller applies this after account
|
// conversion (Responses↔Anthropic↔Chat Completions) can still recover
|
||||||
// header overrides so a per-conversation value cannot be replaced by a fixed
|
// prompt_cache_key / metadata.user_id after those fields are dropped.
|
||||||
// account-wide override.
|
func rememberOpenCodeInboundBody(c *gin.Context, body []byte) {
|
||||||
func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL string, headers http.Header) {
|
if c == nil || len(body) == 0 {
|
||||||
if c == nil || c.Request == nil || account == nil || account.Type != AccountTypeAPIKey || headers == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := url.Parse(targetURL)
|
payloads := append(openCodeInboundBodies(c), bodies...)
|
||||||
if err != nil || !strings.EqualFold(parsed.Scheme, "https") || !strings.EqualFold(parsed.Hostname(), "opencode.ai") {
|
sessionID := resolveOpenCodeSessionID(c, headers, shouldGenerateOpenCodeSession(account, targetURL), payloads...)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader))
|
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -35,3 +68,105 @@ func applyOpenCodeSessionHeader(c *gin.Context, account *Account, targetURL stri
|
|||||||
}
|
}
|
||||||
headers.Set(openCodeSessionHeader, sessionID)
|
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 := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
if sessionID := sanitizeSessionID(explicitOpenAIHeaderSessionID(c)); sessionID != "" {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
if sessionID := sanitizeSessionID(ClaudeCodeSessionIDFromHeader(c)); sessionID != "" {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, body := range bodies {
|
||||||
|
if sessionID := sanitizeSessionID(openCodeSessionIDFromPayload(body)); sessionID != "" {
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sessionID := sanitizeSessionID(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 ""
|
||||||
|
}
|
||||||
@@ -92,7 +92,13 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) {
|
|||||||
incoming: "conversation-123",
|
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: "<generated>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zen endpoint does not invent a session",
|
||||||
account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"),
|
account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"),
|
||||||
targetURL: "https://opencode.ai/zen/v1/responses",
|
targetURL: "https://opencode.ai/zen/v1/responses",
|
||||||
},
|
},
|
||||||
@@ -108,11 +114,116 @@ func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
headers := make(http.Header)
|
headers := make(http.Header)
|
||||||
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers)
|
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 == "<generated>" {
|
||||||
|
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 TestApplyOpenCodeSessionHeaderRejectsControlCharsInPromptCacheKey(t *testing.T) {
|
||||||
|
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
|
||||||
|
body := []byte("{\"model\":\"glm-5.3\",\"prompt_cache_key\":\"a\\nb\",\"input\":\"hello\"}")
|
||||||
|
headers := make(http.Header)
|
||||||
|
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers, body)
|
||||||
|
got := headers.Get(openCodeSessionHeader)
|
||||||
|
require.NotEmpty(t, got)
|
||||||
|
require.NotContains(t, got, "\n")
|
||||||
|
require.NotEqual(t, "a\nb", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
svc := openCodeSessionTestService()
|
svc := openCodeSessionTestService()
|
||||||
@@ -147,6 +258,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) {
|
func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
svc := openCodeSessionTestService()
|
svc := openCodeSessionTestService()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -127,12 +127,19 @@ func (s *RateLimitService) cnBalanceCooldownDuration() time.Duration {
|
|||||||
// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。
|
// 周期额度探测刷新快照后阈值评估会再次停调到正确的时间点。
|
||||||
// 无快照或均已过期返回 nil。
|
// 无快照或均已过期返回 nil。
|
||||||
func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
provider := account.Platform
|
provider := account.Platform
|
||||||
|
suffixes := []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset}
|
||||||
|
if account.IsOpenCodeGo() {
|
||||||
|
suffixes = append(suffixes, cnExtraSuffixMonthlyReset)
|
||||||
|
}
|
||||||
var earliest *time.Time
|
var earliest *time.Time
|
||||||
for _, suffix := range []string{cnExtraSuffix5hReset, cnExtraSuffixWeeklyReset} {
|
for _, suffix := range suffixes {
|
||||||
t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)])
|
t := parseSchedulingResetAt(account.Extra[cnExtraKey(provider, suffix)])
|
||||||
if t == nil || !t.After(now) {
|
if t == nil || !t.After(now) {
|
||||||
continue
|
continue
|
||||||
@@ -152,6 +159,36 @@ func (s *RateLimitService) applyCNProviderReactive429(
|
|||||||
headers http.Header,
|
headers http.Header,
|
||||||
responseBody []byte,
|
responseBody []byte,
|
||||||
) bool {
|
) 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() {
|
if !account.IsCNProvider() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
|||||||
case 402:
|
case 402:
|
||||||
// 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除),
|
// 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除),
|
||||||
// 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。
|
// 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。
|
||||||
if account.IsCNProvider() {
|
if account.IsCNProvider() || account.IsOpenCodeZen() {
|
||||||
s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg)
|
s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg)
|
||||||
shouldDisable = true
|
shouldDisable = true
|
||||||
break
|
break
|
||||||
@@ -990,7 +990,7 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
|
|||||||
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
|
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
|
||||||
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
|
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
|
||||||
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
|
// 一层坏代理连环永久禁用整组账号。走 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)
|
return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody)
|
||||||
}
|
}
|
||||||
// 非 Antigravity 平台:保持原有行为
|
// 非 Antigravity 平台:保持原有行为
|
||||||
@@ -1154,7 +1154,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head
|
|||||||
}
|
}
|
||||||
// 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调,
|
// 国产供应商(kimi/zhipu/deepseek)的 429 走专用可恢复路径:余额不足 → 临时停调,
|
||||||
// Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。
|
// Coding Plan 窗口耗尽 → 冷却到快照重置点。未命中则继续默认 429 逻辑。
|
||||||
if account.IsCNProvider() {
|
if account.IsCNProvider() || account.IsOpenCodeGo() {
|
||||||
if s.applyCNProviderReactive429(ctx, account, headers, responseBody) {
|
if s.applyCNProviderReactive429(ctx, account, headers, responseBody) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func TestSchedulerBulkAccountEventScopesOpenAIRebuildToFreshPlatform(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSchedulerBulkAccountEventScopesCNRebuildToFreshPlatform(t *testing.T) {
|
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) {
|
t.Run(platform, func(t *testing.T) {
|
||||||
cache := newBulkEventSnapshotCache()
|
cache := newBulkEventSnapshotCache()
|
||||||
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
|
repo := newBulkEventAccountRepo(&Account{ID: 1, Platform: platform, GroupIDs: []int64{12}})
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p
|
|||||||
}
|
}
|
||||||
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
|
accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs)
|
||||||
switch account.Platform {
|
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)
|
addPlatformGroups(account.Platform, accountGroupIDs)
|
||||||
case PlatformAntigravity:
|
case PlatformAntigravity:
|
||||||
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
|
// 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。
|
||||||
@@ -824,8 +824,8 @@ func (s *SchedulerSnapshotService) rebuildByAccount(ctx context.Context, account
|
|||||||
return s.rebuildBuckets(ctx, buckets, reason)
|
return s.rebuildBuckets(ctx, buckets, reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
func schedulerSnapshotPlatforms() [9]string {
|
func schedulerSnapshotPlatforms() [10]string {
|
||||||
return [9]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax}
|
return [10]string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
|
// 生命周期辅助函数有意排除 group0;full rebuild 构造 group0 canonical 集时必须显式调用 canonical helper。
|
||||||
|
|||||||
@@ -1013,7 +1013,7 @@ func IsUpstreamBillingProbeIdentity(platform, accountType string) bool {
|
|||||||
}
|
}
|
||||||
switch platform {
|
switch platform {
|
||||||
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
|
case PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformAntigravity, PlatformGrok,
|
||||||
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax:
|
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1051,6 +1051,7 @@ var upstreamBillingProbeOfficialAPIDomains = []string{
|
|||||||
"kimi.com",
|
"kimi.com",
|
||||||
"bigmodel.cn",
|
"bigmodel.cn",
|
||||||
"deepseek.com",
|
"deepseek.com",
|
||||||
|
"opencode.ai",
|
||||||
}
|
}
|
||||||
|
|
||||||
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
|
func upstreamBillingProbeTargetIsOfficialAPI(baseURL string) bool {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
|
func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) {
|
||||||
for _, platform := range []string{
|
for _, platform := range []string{
|
||||||
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
|
PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity,
|
||||||
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
|
PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo,
|
||||||
} {
|
} {
|
||||||
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
|
require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform)
|
||||||
require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: 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"},
|
{PlatformZhipu, "https://open.bigmodel.cn/api/anthropic"},
|
||||||
{PlatformDeepseek, "https://api.deepseek.com"},
|
{PlatformDeepseek, "https://api.deepseek.com"},
|
||||||
{PlatformDeepseek, "https://api.deepseek.com/anthropic"},
|
{PlatformDeepseek, "https://api.deepseek.com/anthropic"},
|
||||||
|
{PlatformOpenCodeGo, "https://opencode.ai/zen/go/v1"},
|
||||||
|
{PlatformOpenCodeGo, "https://opencode.ai/zen/go"},
|
||||||
}
|
}
|
||||||
for i, tc := range cases {
|
for i, tc := range cases {
|
||||||
account := &Account{
|
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://api.kimi.com/coding/v1"))
|
||||||
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic"))
|
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://open.bigmodel.cn/api/anthropic"))
|
||||||
require.True(t, upstreamBillingProbeTargetIsOfficialAPI("https://api.deepseek.com/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 之外的任何名字。
|
// 相似但不同的注册域不拦:中转完全可能叫 *-x.ai 之外的任何名字。
|
||||||
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1"))
|
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://relay.example/v1"))
|
||||||
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai"))
|
require.False(t, upstreamBillingProbeTargetIsOfficialAPI("https://notx.ai"))
|
||||||
|
|||||||
@@ -617,7 +617,7 @@ func upstreamModelRegistryBaseURL(account *Account) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case account.IsOpenAI() || account.IsCNProvider():
|
case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo():
|
||||||
return account.GetOpenAIFormatBaseURL()
|
return account.GetOpenAIFormatBaseURL()
|
||||||
case account.IsGrok():
|
case account.IsGrok():
|
||||||
return account.GetGrokBaseURL()
|
return account.GetGrokBaseURL()
|
||||||
@@ -677,6 +677,8 @@ func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, ac
|
|||||||
switch host {
|
switch host {
|
||||||
case "api.openai.com", "chatgpt.com":
|
case "api.openai.com", "chatgpt.com":
|
||||||
providerID = "openai"
|
providerID = "openai"
|
||||||
|
case "opencode.ai":
|
||||||
|
providerID = "opencode-go"
|
||||||
default:
|
default:
|
||||||
return modelsDevProvider{}, false
|
return modelsDevProvider{}, false
|
||||||
}
|
}
|
||||||
@@ -782,8 +784,9 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc
|
|||||||
return s.buildAntigravityAPIKeyModelsRequest(ctx, account)
|
return s.buildAntigravityAPIKeyModelsRequest(ctx, account)
|
||||||
case account.IsGrok():
|
case account.IsGrok():
|
||||||
return s.buildGrokUpstreamModelsRequest(ctx, account)
|
return s.buildGrokUpstreamModelsRequest(ctx, account)
|
||||||
case account.IsOpenAI() || account.IsCNProvider():
|
case account.IsOpenAI() || account.IsCNProvider() || account.IsOpenCodeGo():
|
||||||
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。
|
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 OpenCode Go
|
||||||
|
// 复用 OpenAI /v1/models 探测。
|
||||||
return s.buildOpenAIUpstreamModelsRequest(ctx, account)
|
return s.buildOpenAIUpstreamModelsRequest(ctx, account)
|
||||||
case account.IsGemini():
|
case account.IsGemini():
|
||||||
return s.buildGeminiUpstreamModelsRequest(ctx, account)
|
return s.buildGeminiUpstreamModelsRequest(ctx, account)
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
@@ -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'))")
|
||||||
|
}
|
||||||
@@ -147,6 +147,7 @@ security:
|
|||||||
- "api.moonshot.cn"
|
- "api.moonshot.cn"
|
||||||
- "open.bigmodel.cn"
|
- "open.bigmodel.cn"
|
||||||
- "api.minimaxi.com"
|
- "api.minimaxi.com"
|
||||||
|
- "opencode.ai"
|
||||||
- "generativelanguage.googleapis.com"
|
- "generativelanguage.googleapis.com"
|
||||||
- "cloudcode-pa.googleapis.com"
|
- "cloudcode-pa.googleapis.com"
|
||||||
- "*.openai.azure.com"
|
- "*.openai.azure.com"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type Provider =
|
|||||||
| 'zhipu'
|
| 'zhipu'
|
||||||
| 'deepseek'
|
| 'deepseek'
|
||||||
| 'minimax'
|
| 'minimax'
|
||||||
|
| 'opencode_go'
|
||||||
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
|
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
|
||||||
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
|
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
|
||||||
export type APIMode = 'chat_completions' | 'responses'
|
export type APIMode = 'chat_completions' | 'responses'
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { apiClient } from '../client'
|
|||||||
|
|
||||||
/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */
|
/** 滚动用量窗口档(5 小时 / 每周),对齐后端 service.CNQuotaTier。 */
|
||||||
export interface CNQuotaTier {
|
export interface CNQuotaTier {
|
||||||
window: '5h' | 'weekly'
|
window: '5h' | 'weekly' | 'monthly'
|
||||||
used_percent: number
|
used_percent: number
|
||||||
reset_at?: string
|
reset_at?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,11 +39,12 @@ export type SchedulingThresholdPlatformType =
|
|||||||
| "kimi"
|
| "kimi"
|
||||||
| "zhipu"
|
| "zhipu"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
|
| "opencode_go"
|
||||||
|
|
||||||
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
|
export type AccountSchedulingThresholdsMap = Record<SchedulingThresholdPlatformType, number>
|
||||||
|
|
||||||
// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型,
|
// 与后端 AllowedSchedulingThresholdPlatforms 保持一致(deepseek 为余额型,
|
||||||
// 走余额检测而非用量阈值;minimax Coding/Token Plan 有 5h/weekly 窗口)。
|
// 走余额检测而非用量阈值;minimax Coding/Token Plan 与 OpenCode GO 有滚动窗口)。
|
||||||
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
|
export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] = [
|
||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -51,6 +52,7 @@ export const SCHEDULING_THRESHOLD_PLATFORMS: SchedulingThresholdPlatformType[] =
|
|||||||
"kimi",
|
"kimi",
|
||||||
"zhipu",
|
"zhipu",
|
||||||
"minimax",
|
"minimax",
|
||||||
|
"opencode_go",
|
||||||
]
|
]
|
||||||
|
|
||||||
export function normalizeAccountSchedulingThresholdsMap(
|
export function normalizeAccountSchedulingThresholdsMap(
|
||||||
|
|||||||
@@ -431,7 +431,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CN providers (Kimi / Zhipu / DeepSeek): coding-plan quota or payg balance -->
|
<!-- CN providers (Kimi / Zhipu / DeepSeek): coding-plan quota or payg balance -->
|
||||||
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek' || account.platform === 'minimax'">
|
<template v-else-if="account.platform === 'kimi' || account.platform === 'zhipu' || account.platform === 'deepseek' || account.platform === 'minimax' || account.platform === 'opencode_go'">
|
||||||
<!-- 挂在 CN 平台下的 Ollama Cloud 账号(资格由后端下发 eligible):用量由
|
<!-- 挂在 CN 平台下的 Ollama Cloud 账号(资格由后端下发 eligible):用量由
|
||||||
Ollama 用量窗口负责。这类账号不是国产厂商订阅,CN 的额度/余额探测端点由
|
Ollama 用量窗口负责。这类账号不是国产厂商订阅,CN 的额度/余额探测端点由
|
||||||
base_url 衍生,对 ollama.com 会被后端出站 URL 白名单拒绝,渲染出来只会
|
base_url 衍生,对 ollama.com 会被后端出站 URL 白名单拒绝,渲染出来只会
|
||||||
@@ -729,7 +729,8 @@ const showUsageWindows = computed(() => {
|
|||||||
props.account.platform === 'kimi' ||
|
props.account.platform === 'kimi' ||
|
||||||
props.account.platform === 'zhipu' ||
|
props.account.platform === 'zhipu' ||
|
||||||
props.account.platform === 'deepseek' ||
|
props.account.platform === 'deepseek' ||
|
||||||
props.account.platform === 'minimax'
|
props.account.platform === 'minimax' ||
|
||||||
|
props.account.platform === 'opencode_go'
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ const snapshotData = computed<CNProviderQuotaProbeResult | null>(() => {
|
|||||||
const platform = props.account.platform
|
const platform = props.account.platform
|
||||||
const used5h = readExtraNumber(`${platform}_5h_used_percent`)
|
const used5h = readExtraNumber(`${platform}_5h_used_percent`)
|
||||||
const usedWeekly = readExtraNumber(`${platform}_weekly_used_percent`)
|
const usedWeekly = readExtraNumber(`${platform}_weekly_used_percent`)
|
||||||
if (used5h == null && usedWeekly == null) return null
|
const usedMonthly = readExtraNumber(`${platform}_monthly_used_percent`)
|
||||||
|
if (used5h == null && usedWeekly == null && usedMonthly == null) return null
|
||||||
const tiers: CNProviderQuotaProbeResult['tiers'] = []
|
const tiers: CNProviderQuotaProbeResult['tiers'] = []
|
||||||
if (used5h != null) {
|
if (used5h != null) {
|
||||||
tiers.push({ window: '5h', used_percent: used5h, reset_at: readExtraString(`${platform}_5h_reset_at`) || undefined })
|
tiers.push({ window: '5h', used_percent: used5h, reset_at: readExtraString(`${platform}_5h_reset_at`) || undefined })
|
||||||
@@ -116,6 +117,9 @@ const snapshotData = computed<CNProviderQuotaProbeResult | null>(() => {
|
|||||||
if (usedWeekly != null) {
|
if (usedWeekly != null) {
|
||||||
tiers.push({ window: 'weekly', used_percent: usedWeekly, reset_at: readExtraString(`${platform}_weekly_reset_at`) || undefined })
|
tiers.push({ window: 'weekly', used_percent: usedWeekly, reset_at: readExtraString(`${platform}_weekly_reset_at`) || undefined })
|
||||||
}
|
}
|
||||||
|
if (usedMonthly != null) {
|
||||||
|
tiers.push({ window: 'monthly', used_percent: usedMonthly, reset_at: readExtraString(`${platform}_monthly_reset_at`) || undefined })
|
||||||
|
}
|
||||||
return { success: true, tiers } as CNProviderQuotaProbeResult
|
return { success: true, tiers } as CNProviderQuotaProbeResult
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,10 +165,11 @@ const truncatedError = computed(() => {
|
|||||||
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
|
return error.value.length > 80 ? `${error.value.slice(0, 80)}...` : error.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const windowLabel = (window: string) =>
|
const windowLabel = (window: string) => {
|
||||||
window === 'weekly'
|
if (window === 'weekly') return t('admin.accounts.cnProviders.windowWeekly')
|
||||||
? t('admin.accounts.cnProviders.windowWeekly')
|
if (window === 'monthly') return t('admin.accounts.cnProviders.windowMonthly')
|
||||||
: t('admin.accounts.cnProviders.window5h')
|
return t('admin.accounts.cnProviders.window5h')
|
||||||
|
}
|
||||||
|
|
||||||
const handleProbe = async () => {
|
const handleProbe = async () => {
|
||||||
if (loading.value) return
|
if (loading.value) return
|
||||||
|
|||||||
@@ -161,13 +161,13 @@
|
|||||||
Grok
|
Grok
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- CN providers row: Kimi / Zhipu GLM / DeepSeek -->
|
<!-- Multi-protocol API-key providers: Kimi / Zhipu GLM / DeepSeek / OpenCode -->
|
||||||
<div class="mt-2 flex flex-wrap rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
<div class="mt-2 flex flex-wrap rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectCNPlatform('kimi')"
|
@click="selectCNPlatform('kimi')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
form.platform === 'kimi'
|
form.platform === 'kimi'
|
||||||
? 'bg-white text-pink-600 shadow-sm dark:bg-dark-600 dark:text-pink-400'
|
? 'bg-white text-pink-600 shadow-sm dark:bg-dark-600 dark:text-pink-400'
|
||||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="selectCNPlatform('zhipu')"
|
@click="selectCNPlatform('zhipu')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
form.platform === 'zhipu'
|
form.platform === 'zhipu'
|
||||||
? 'bg-white text-indigo-600 shadow-sm dark:bg-dark-600 dark:text-indigo-400'
|
? 'bg-white text-indigo-600 shadow-sm dark:bg-dark-600 dark:text-indigo-400'
|
||||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="selectCNPlatform('deepseek')"
|
@click="selectCNPlatform('deepseek')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
form.platform === 'deepseek'
|
form.platform === 'deepseek'
|
||||||
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
|
? 'bg-white text-teal-600 shadow-sm dark:bg-dark-600 dark:text-teal-400'
|
||||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
@@ -206,7 +206,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
@click="selectCNPlatform('minimax')"
|
@click="selectCNPlatform('minimax')"
|
||||||
:class="[
|
:class="[
|
||||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
form.platform === 'minimax'
|
form.platform === 'minimax'
|
||||||
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
|
? 'bg-white text-rose-600 shadow-sm dark:bg-dark-600 dark:text-rose-400'
|
||||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
@@ -215,6 +215,19 @@
|
|||||||
<PlatformIcon platform="minimax" size="sm" />
|
<PlatformIcon platform="minimax" size="sm" />
|
||||||
MiniMax
|
MiniMax
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="selectOpenCodeGoPlatform()"
|
||||||
|
:class="[
|
||||||
|
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm font-medium transition-all',
|
||||||
|
form.platform === 'opencode_go'
|
||||||
|
? 'bg-white text-amber-700 shadow-sm dark:bg-dark-600 dark:text-amber-300'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlatformIcon platform="opencode_go" size="sm" />
|
||||||
|
OpenCode
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -466,8 +479,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OpenCode Zen vs Go -->
|
||||||
|
<div v-if="isOpenCodeGoPlatform">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
||||||
|
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openCodeAccountMode = 'zen'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
openCodeAccountMode === 'zen'
|
||||||
|
? cnAccentActiveClass
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
openCodeAccountMode === 'zen' ? cnAccentIconClass : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="creditCard" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.opencodeGo.accountMode.zen') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.accountMode.zenDesc') }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="openCodeAccountMode = 'go'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
openCodeAccountMode === 'go'
|
||||||
|
? cnAccentActiveClass
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
openCodeAccountMode === 'go' ? cnAccentIconClass : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="bolt" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.opencodeGo.accountMode.go') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.accountMode.goDesc') }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Mode Selection (Kimi / Zhipu / DeepSeek) -->
|
<!-- Account Mode Selection (Kimi / Zhipu / DeepSeek) -->
|
||||||
<div v-if="isCNPlatform">
|
<div v-if="isCNPlatform && !isOpenCodeGoPlatform">
|
||||||
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
||||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-mode">
|
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2" data-tour="account-form-mode">
|
||||||
<!-- Pay-as-you-go (token balance) -->
|
<!-- Pay-as-you-go (token balance) -->
|
||||||
@@ -526,8 +592,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Protocol Selection (Kimi / Zhipu / DeepSeek) -->
|
<!-- API Protocol Selection (Kimi / Zhipu / DeepSeek / OpenCode) -->
|
||||||
<div v-if="isCNPlatform" class="mt-4">
|
<div v-if="isMultiProtocolPlatform" class="mt-4">
|
||||||
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
|
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
|
||||||
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<button
|
<button
|
||||||
@@ -1293,7 +1359,7 @@
|
|||||||
|
|
||||||
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
|
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
|
||||||
<div v-if="form.type === 'apikey' && form.platform !== 'antigravity'" class="space-y-4">
|
<div v-if="form.type === 'apikey' && form.platform !== 'antigravity'" class="space-y-4">
|
||||||
<div v-if="!isCNPlatform || apiProtocol !== 'adaptive'">
|
<div v-if="!isMultiProtocolPlatform || apiProtocol !== 'adaptive'">
|
||||||
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
|
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
|
||||||
<input
|
<input
|
||||||
v-model="apiKeyBaseUrl"
|
v-model="apiKeyBaseUrl"
|
||||||
@@ -1308,7 +1374,7 @@
|
|||||||
@select="apiKeyBaseUrl = $event"
|
@select="apiKeyBaseUrl = $event"
|
||||||
/>
|
/>
|
||||||
<CnBaseUrlPresets
|
<CnBaseUrlPresets
|
||||||
v-if="isCNPlatform"
|
v-if="isCNPlatform && !isOpenCodeGoPlatform"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
:platform="cnPresetPlatform"
|
:platform="cnPresetPlatform"
|
||||||
:mode="accountMode"
|
:mode="accountMode"
|
||||||
@@ -1336,6 +1402,11 @@
|
|||||||
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
|
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<OpenCodeGoProtocolRulesEditor
|
||||||
|
v-if="isOpenCodeGoPlatform && apiProtocol === 'adaptive'"
|
||||||
|
v-model:rows="openCodeGoProtocolRules"
|
||||||
|
:plan="openCodeAccountMode"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
|
||||||
<input
|
<input
|
||||||
@@ -3866,15 +3937,19 @@ import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
|||||||
import Toggle from '@/components/common/Toggle.vue'
|
import Toggle from '@/components/common/Toggle.vue'
|
||||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||||
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
|
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
|
||||||
|
import OpenCodeGoProtocolRulesEditor from '@/components/account/OpenCodeGoProtocolRulesEditor.vue'
|
||||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||||
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
|
import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling'
|
||||||
import {
|
import {
|
||||||
applyAntigravityProjectID,
|
applyAntigravityProjectID,
|
||||||
applyHeaderOverride,
|
applyHeaderOverride,
|
||||||
applyInterceptWarmup,
|
applyInterceptWarmup,
|
||||||
|
applyOpenCodeGoProtocolRules,
|
||||||
|
cloneOpenCodeGoProtocolRules,
|
||||||
cnSupportsNativeResponses,
|
cnSupportsNativeResponses,
|
||||||
defaultCNAdaptiveBaseUrls,
|
defaultCNAdaptiveBaseUrls,
|
||||||
defaultCNBaseUrl,
|
defaultCNBaseUrl,
|
||||||
|
defaultOpenCodeProtocolRules,
|
||||||
isCNProviderPlatform,
|
isCNProviderPlatform,
|
||||||
isHeaderOverrideCapable,
|
isHeaderOverrideCapable,
|
||||||
validateHeaderOverrideRows,
|
validateHeaderOverrideRows,
|
||||||
@@ -3882,7 +3957,9 @@ import {
|
|||||||
type CnApiProtocol,
|
type CnApiProtocol,
|
||||||
type CnNativeApiProtocol,
|
type CnNativeApiProtocol,
|
||||||
type CnProviderPlatform,
|
type CnProviderPlatform,
|
||||||
type HeaderOverrideRow
|
type HeaderOverrideRow,
|
||||||
|
type OpenCodeAccountMode,
|
||||||
|
type OpenCodeGoProtocolRule
|
||||||
} from '@/components/account/credentialsBuilder'
|
} from '@/components/account/credentialsBuilder'
|
||||||
import {
|
import {
|
||||||
formatDateTimeLocalInput,
|
formatDateTimeLocalInput,
|
||||||
@@ -3955,8 +4032,9 @@ const apiKeyHint = computed(() => {
|
|||||||
|
|
||||||
// Base URL / API Key 占位符:国产供应商随账号类型变化。
|
// Base URL / API Key 占位符:国产供应商随账号类型变化。
|
||||||
const apiKeyBaseUrlPlaceholder = computed(() => {
|
const apiKeyBaseUrlPlaceholder = computed(() => {
|
||||||
if (isCNPlatform.value) {
|
if (isMultiProtocolPlatform.value) {
|
||||||
return defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value) || 'https://api.example.com'
|
const mode = form.platform === 'opencode_go' ? openCodeAccountMode.value : accountMode.value
|
||||||
|
return defaultCNBaseUrl(form.platform, mode, apiProtocol.value) || 'https://api.example.com'
|
||||||
}
|
}
|
||||||
switch (form.platform) {
|
switch (form.platform) {
|
||||||
case 'openai':
|
case 'openai':
|
||||||
@@ -3985,6 +4063,7 @@ const apiKeyValuePlaceholder = computed(() => {
|
|||||||
case 'deepseek':
|
case 'deepseek':
|
||||||
return 'sk-...'
|
return 'sk-...'
|
||||||
case 'minimax':
|
case 'minimax':
|
||||||
|
case 'opencode_go':
|
||||||
return 'sk-...'
|
return 'sk-...'
|
||||||
default:
|
default:
|
||||||
return 'sk-ant-...'
|
return 'sk-ant-...'
|
||||||
@@ -4076,9 +4155,13 @@ const upstreamBillingAutoProbeEnabled = ref(true)
|
|||||||
|
|
||||||
// ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ──
|
// ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ──
|
||||||
const accountMode = ref<CnAccountMode>('payg')
|
const accountMode = ref<CnAccountMode>('payg')
|
||||||
|
const openCodeAccountMode = ref<OpenCodeAccountMode>('zen')
|
||||||
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
|
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
|
||||||
// responses=deepseek / kimi 原生 Responses 端点(Codex)。与账号类型正交。
|
// responses=deepseek / kimi 原生 Responses 端点(Codex)。与账号类型正交。
|
||||||
const apiProtocol = ref<CnApiProtocol>('adaptive')
|
const apiProtocol = ref<CnApiProtocol>('adaptive')
|
||||||
|
const openCodeGoProtocolRules = ref<OpenCodeGoProtocolRule[]>(
|
||||||
|
cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules('zen'))
|
||||||
|
)
|
||||||
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
|
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
|
||||||
const zhipuOrganization = ref('')
|
const zhipuOrganization = ref('')
|
||||||
const zhipuProject = ref('')
|
const zhipuProject = ref('')
|
||||||
@@ -4088,6 +4171,11 @@ const adaptiveBaseUrls = ref<Record<CnNativeApiProtocol, string>>({
|
|||||||
responses: ''
|
responses: ''
|
||||||
})
|
})
|
||||||
const isCNPlatform = computed(() => isCNProviderPlatform(form.platform))
|
const isCNPlatform = computed(() => isCNProviderPlatform(form.platform))
|
||||||
|
const isOpenCodeGoPlatform = computed(() => form.platform === 'opencode_go')
|
||||||
|
const isMultiProtocolPlatform = computed(() => isCNPlatform.value || isOpenCodeGoPlatform.value)
|
||||||
|
function currentOpenCodeOrCNMode(): CnAccountMode | OpenCodeAccountMode {
|
||||||
|
return isOpenCodeGoPlatform.value ? openCodeAccountMode.value : accountMode.value
|
||||||
|
}
|
||||||
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
|
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
|
||||||
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
|
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
|
||||||
const cnPresetPlatform = computed<CnProviderPlatform>(() => {
|
const cnPresetPlatform = computed<CnProviderPlatform>(() => {
|
||||||
@@ -4096,6 +4184,10 @@ const cnPresetPlatform = computed<CnProviderPlatform>(() => {
|
|||||||
}
|
}
|
||||||
return 'kimi'
|
return 'kimi'
|
||||||
})
|
})
|
||||||
|
const adaptivePresetPlatform = computed<CnProviderPlatform | 'opencode_go'>(() => {
|
||||||
|
if (form.platform === 'opencode_go') return 'opencode_go'
|
||||||
|
return cnPresetPlatform.value
|
||||||
|
})
|
||||||
// 当前平台可选的协议档(responses 仅 deepseek / kimi)。
|
// 当前平台可选的协议档(responses 仅 deepseek / kimi)。
|
||||||
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
|
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
|
||||||
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
|
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
|
||||||
@@ -4117,7 +4209,10 @@ const cnAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol; l
|
|||||||
return opts
|
return opts
|
||||||
})
|
})
|
||||||
|
|
||||||
function resetAdaptiveBaseUrls(platform: CnProviderPlatform, mode: CnAccountMode) {
|
function resetAdaptiveBaseUrls(
|
||||||
|
platform: CnProviderPlatform | 'opencode_go',
|
||||||
|
mode: CnAccountMode | OpenCodeAccountMode
|
||||||
|
) {
|
||||||
adaptiveBaseUrls.value = defaultCNAdaptiveBaseUrls(platform, mode)
|
adaptiveBaseUrls.value = defaultCNAdaptiveBaseUrls(platform, mode)
|
||||||
}
|
}
|
||||||
// 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。
|
// 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。
|
||||||
@@ -4131,6 +4226,8 @@ const cnAccentActiveClass = computed(() => {
|
|||||||
return 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
return 'border-teal-500 bg-teal-50 dark:bg-teal-900/20'
|
||||||
case 'minimax':
|
case 'minimax':
|
||||||
return 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
return 'border-rose-500 bg-rose-50 dark:bg-rose-900/20'
|
||||||
|
case 'opencode_go':
|
||||||
|
return 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
|
||||||
default:
|
default:
|
||||||
return 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
return 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
}
|
}
|
||||||
@@ -4145,6 +4242,8 @@ const cnAccentIconClass = computed(() => {
|
|||||||
return 'bg-teal-500 text-white'
|
return 'bg-teal-500 text-white'
|
||||||
case 'minimax':
|
case 'minimax':
|
||||||
return 'bg-rose-500 text-white'
|
return 'bg-rose-500 text-white'
|
||||||
|
case 'opencode_go':
|
||||||
|
return 'bg-amber-500 text-white'
|
||||||
default:
|
default:
|
||||||
return 'bg-primary-500 text-white'
|
return 'bg-primary-500 text-white'
|
||||||
}
|
}
|
||||||
@@ -4162,12 +4261,41 @@ function selectCNPlatform(platform: CnProviderPlatform) {
|
|||||||
apiKeyBaseUrl.value = defaultCNBaseUrl(platform, accountMode.value, apiProtocol.value)
|
apiKeyBaseUrl.value = defaultCNBaseUrl(platform, accountMode.value, apiProtocol.value)
|
||||||
resetAdaptiveBaseUrls(platform, accountMode.value)
|
resetAdaptiveBaseUrls(platform, accountMode.value)
|
||||||
}
|
}
|
||||||
|
function selectOpenCodeGoPlatform() {
|
||||||
|
form.platform = 'opencode_go'
|
||||||
|
form.type = 'apikey'
|
||||||
|
accountCategory.value = 'apikey'
|
||||||
|
apiProtocol.value = 'adaptive'
|
||||||
|
openCodeAccountMode.value = 'zen'
|
||||||
|
apiKeyBaseUrl.value = defaultCNBaseUrl('opencode_go', openCodeAccountMode.value, 'adaptive')
|
||||||
|
resetAdaptiveBaseUrls('opencode_go', openCodeAccountMode.value)
|
||||||
|
openCodeGoProtocolRules.value = cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(openCodeAccountMode.value))
|
||||||
|
}
|
||||||
// 账号类型 / 协议变更时同步默认 base url。
|
// 账号类型 / 协议变更时同步默认 base url。
|
||||||
watch(accountMode, (mode, previousMode) => {
|
watch(openCodeAccountMode, (mode, previousMode) => {
|
||||||
if (!isCNPlatform.value) return
|
if (!isOpenCodeGoPlatform.value) return
|
||||||
if (apiProtocol.value === 'adaptive') {
|
if (apiProtocol.value === 'adaptive') {
|
||||||
const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode)
|
const previousDefaults = defaultCNAdaptiveBaseUrls('opencode_go', previousMode)
|
||||||
const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode)
|
const nextDefaults = defaultCNAdaptiveBaseUrls('opencode_go', mode)
|
||||||
|
for (const item of cnAdaptiveProtocolOptions.value) {
|
||||||
|
if (!adaptiveBaseUrls.value[item.value] || adaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
||||||
|
adaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions
|
||||||
|
} else {
|
||||||
|
apiKeyBaseUrl.value = defaultCNBaseUrl('opencode_go', mode, apiProtocol.value)
|
||||||
|
}
|
||||||
|
const previousRules = JSON.stringify(defaultOpenCodeProtocolRules(previousMode))
|
||||||
|
if (JSON.stringify(openCodeGoProtocolRules.value) === previousRules) {
|
||||||
|
openCodeGoProtocolRules.value = cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(mode))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(accountMode, (mode, previousMode) => {
|
||||||
|
if (!isMultiProtocolPlatform.value || isOpenCodeGoPlatform.value) return
|
||||||
|
if (apiProtocol.value === 'adaptive') {
|
||||||
|
const previousDefaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, previousMode)
|
||||||
|
const nextDefaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, mode)
|
||||||
for (const item of cnAdaptiveProtocolOptions.value) {
|
for (const item of cnAdaptiveProtocolOptions.value) {
|
||||||
if (!adaptiveBaseUrls.value[item.value] || adaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
if (!adaptiveBaseUrls.value[item.value] || adaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
||||||
adaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
adaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
||||||
@@ -4179,16 +4307,16 @@ watch(accountMode, (mode, previousMode) => {
|
|||||||
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, mode, apiProtocol.value)
|
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, mode, apiProtocol.value)
|
||||||
})
|
})
|
||||||
watch(apiProtocol, (protocol) => {
|
watch(apiProtocol, (protocol) => {
|
||||||
if (!isCNPlatform.value) return
|
if (!isMultiProtocolPlatform.value) return
|
||||||
if (protocol === 'adaptive') {
|
if (protocol === 'adaptive') {
|
||||||
const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, accountMode.value)
|
const defaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, currentOpenCodeOrCNMode())
|
||||||
for (const item of cnAdaptiveProtocolOptions.value) {
|
for (const item of cnAdaptiveProtocolOptions.value) {
|
||||||
if (!adaptiveBaseUrls.value[item.value]) adaptiveBaseUrls.value[item.value] = defaults[item.value]
|
if (!adaptiveBaseUrls.value[item.value]) adaptiveBaseUrls.value[item.value] = defaults[item.value]
|
||||||
}
|
}
|
||||||
apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions
|
apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, accountMode.value, protocol)
|
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, currentOpenCodeOrCNMode(), protocol)
|
||||||
})
|
})
|
||||||
// 点击预设端点:同时回填 base url、账号类型与协议。
|
// 点击预设端点:同时回填 base url、账号类型与协议。
|
||||||
function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol; url: string }) {
|
function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol; url: string }) {
|
||||||
@@ -4199,7 +4327,7 @@ function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol
|
|||||||
|
|
||||||
const syncPreviewCredentials = computed(() => {
|
const syncPreviewCredentials = computed(() => {
|
||||||
if (!apiKeyValue.value) return undefined
|
if (!apiKeyValue.value) return undefined
|
||||||
const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive'
|
const baseUrl = isMultiProtocolPlatform.value && apiProtocol.value === 'adaptive'
|
||||||
? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim()
|
? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim()
|
||||||
: apiKeyBaseUrl.value.trim()
|
: apiKeyBaseUrl.value.trim()
|
||||||
const modelMapping = buildModelMappingObject(
|
const modelMapping = buildModelMappingObject(
|
||||||
@@ -4704,8 +4832,9 @@ watch(
|
|||||||
() => form.platform,
|
() => form.platform,
|
||||||
(newPlatform) => {
|
(newPlatform) => {
|
||||||
// Reset base URL based on platform
|
// Reset base URL based on platform
|
||||||
if (isCNProviderPlatform(newPlatform)) {
|
if (isCNProviderPlatform(newPlatform) || newPlatform === 'opencode_go') {
|
||||||
apiKeyBaseUrl.value = defaultCNBaseUrl(newPlatform, accountMode.value, apiProtocol.value)
|
const mode = newPlatform === 'opencode_go' ? openCodeAccountMode.value : accountMode.value
|
||||||
|
apiKeyBaseUrl.value = defaultCNBaseUrl(newPlatform, mode, apiProtocol.value)
|
||||||
} else {
|
} else {
|
||||||
apiKeyBaseUrl.value =
|
apiKeyBaseUrl.value =
|
||||||
(newPlatform === 'openai')
|
(newPlatform === 'openai')
|
||||||
@@ -5179,7 +5308,9 @@ const resetForm = () => {
|
|||||||
accountCategory.value = 'oauth-based'
|
accountCategory.value = 'oauth-based'
|
||||||
addMethod.value = 'oauth'
|
addMethod.value = 'oauth'
|
||||||
accountMode.value = 'payg'
|
accountMode.value = 'payg'
|
||||||
|
openCodeAccountMode.value = 'zen'
|
||||||
apiProtocol.value = 'adaptive'
|
apiProtocol.value = 'adaptive'
|
||||||
|
openCodeGoProtocolRules.value = cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules('zen'))
|
||||||
adaptiveBaseUrls.value = { chat_completions: '', anthropic: '', responses: '' }
|
adaptiveBaseUrls.value = { chat_completions: '', anthropic: '', responses: '' }
|
||||||
apiKeyBaseUrl.value = 'https://api.anthropic.com'
|
apiKeyBaseUrl.value = 'https://api.anthropic.com'
|
||||||
apiKeyValue.value = ''
|
apiKeyValue.value = ''
|
||||||
@@ -5654,11 +5785,14 @@ const handleSubmit = async () => {
|
|||||||
// 国产供应商:账号模式 + 协议 + 对应端点写入凭据;后端按 account_mode 路由
|
// 国产供应商:账号模式 + 协议 + 对应端点写入凭据;后端按 account_mode 路由
|
||||||
// 额度/余额探测,按 api_protocol 路由转发端点与格式。注意 CN apikey 走本函数
|
// 额度/余额探测,按 api_protocol 路由转发端点与格式。注意 CN apikey 走本函数
|
||||||
// 的通用路径(直接 doCreateAccount),不经过 createAccountAndFinish。
|
// 的通用路径(直接 doCreateAccount),不经过 createAccountAndFinish。
|
||||||
if (isCNProviderPlatform(form.platform)) {
|
if (isCNProviderPlatform(form.platform) || form.platform === 'opencode_go') {
|
||||||
credentials.account_mode = accountMode.value
|
credentials.account_mode = form.platform === 'opencode_go' ? openCodeAccountMode.value : accountMode.value
|
||||||
credentials.api_protocol = apiProtocol.value
|
credentials.api_protocol = apiProtocol.value
|
||||||
if (apiProtocol.value === 'adaptive') {
|
if (apiProtocol.value === 'adaptive') {
|
||||||
const defaults = defaultCNAdaptiveBaseUrls(form.platform, accountMode.value)
|
const defaults = defaultCNAdaptiveBaseUrls(
|
||||||
|
form.platform,
|
||||||
|
form.platform === 'opencode_go' ? openCodeAccountMode.value : accountMode.value
|
||||||
|
)
|
||||||
const protocolBaseUrls: Record<string, string> = {}
|
const protocolBaseUrls: Record<string, string> = {}
|
||||||
for (const item of cnAdaptiveProtocolOptions.value) {
|
for (const item of cnAdaptiveProtocolOptions.value) {
|
||||||
protocolBaseUrls[item.value] = (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
|
protocolBaseUrls[item.value] = (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
|
||||||
@@ -5667,7 +5801,7 @@ const handleSubmit = async () => {
|
|||||||
credentials.base_url = protocolBaseUrls.chat_completions
|
credentials.base_url = protocolBaseUrls.chat_completions
|
||||||
}
|
}
|
||||||
const resolvedCNBase = (
|
const resolvedCNBase = (
|
||||||
apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value)
|
apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, currentOpenCodeOrCNMode(), apiProtocol.value)
|
||||||
).trim()
|
).trim()
|
||||||
if (apiProtocol.value !== 'adaptive' && resolvedCNBase) {
|
if (apiProtocol.value !== 'adaptive' && resolvedCNBase) {
|
||||||
credentials.base_url = resolvedCNBase
|
credentials.base_url = resolvedCNBase
|
||||||
@@ -5677,6 +5811,9 @@ const handleSubmit = async () => {
|
|||||||
if (zhipuOrganization.value.trim()) credentials.zhipu_organization = zhipuOrganization.value.trim()
|
if (zhipuOrganization.value.trim()) credentials.zhipu_organization = zhipuOrganization.value.trim()
|
||||||
if (zhipuProject.value.trim()) credentials.zhipu_project = zhipuProject.value.trim()
|
if (zhipuProject.value.trim()) credentials.zhipu_project = zhipuProject.value.trim()
|
||||||
}
|
}
|
||||||
|
if (form.platform === 'opencode_go') {
|
||||||
|
applyOpenCodeGoProtocolRules(credentials, openCodeGoProtocolRules.value, 'create')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add model mapping if configured(OpenAI 开启自动透传时不应用)
|
// Add model mapping if configured(OpenAI 开启自动透传时不应用)
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
@select="editBaseUrl = $event"
|
@select="editBaseUrl = $event"
|
||||||
/>
|
/>
|
||||||
<CnBaseUrlPresets
|
<CnBaseUrlPresets
|
||||||
v-if="isCNApiKeyAccount"
|
v-if="isCNApiKeyAccount && account.platform !== 'opencode_go'"
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
:platform="cnPresetPlatform"
|
:platform="cnPresetPlatform"
|
||||||
:mode="editAccountMode"
|
:mode="editAccountMode"
|
||||||
@@ -76,8 +76,60 @@
|
|||||||
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
|
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- OpenCode Zen vs GO -->
|
||||||
|
<div v-if="isCNApiKeyAccount && account.platform === 'opencode_go'">
|
||||||
|
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
||||||
|
<div class="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editOpenCodeAccountMode = 'zen'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
editOpenCodeAccountMode === 'zen'
|
||||||
|
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
editOpenCodeAccountMode === 'zen' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="creditCard" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.opencodeGo.accountMode.zen') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.accountMode.zenDesc') }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="editOpenCodeAccountMode = 'go'"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||||
|
editOpenCodeAccountMode === 'go'
|
||||||
|
? 'border-amber-500 bg-amber-50 dark:bg-amber-900/20'
|
||||||
|
: 'border-gray-200 hover:border-gray-400 dark:border-dark-600 dark:hover:border-gray-600'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
editOpenCodeAccountMode === 'go' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<Icon name="bolt" size="sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.opencodeGo.accountMode.go') }}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.opencodeGo.accountMode.goDesc') }}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Account Mode Selection (CN providers) -->
|
<!-- Account Mode Selection (CN providers) -->
|
||||||
<div v-if="isCNApiKeyAccount">
|
<div v-if="isCNApiKeyAccount && account.platform !== 'opencode_go'">
|
||||||
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -97,7 +149,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="input-hint">{{ t(`admin.accounts.cnProviders.accountMode.${editAccountMode}Desc`) }}</p>
|
<p class="input-hint">{{ t(`admin.accounts.cnProviders.accountMode.${editAccountMode}Desc`) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- API Protocol Selection (CN providers) -->
|
<!-- API Protocol Selection (CN providers / OpenCode) -->
|
||||||
<div v-if="isCNApiKeyAccount">
|
<div v-if="isCNApiKeyAccount">
|
||||||
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
|
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.title') }}</label>
|
||||||
<div class="mt-2 flex flex-wrap gap-2">
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
@@ -118,6 +170,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="input-hint">{{ t(`admin.accounts.cnProviders.apiProtocol.${cnProtocolDescKey}Desc`) }}</p>
|
<p class="input-hint">{{ t(`admin.accounts.cnProviders.apiProtocol.${cnProtocolDescKey}Desc`) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<OpenCodeGoProtocolRulesEditor
|
||||||
|
v-if="account.platform === 'opencode_go' && editApiProtocol === 'adaptive'"
|
||||||
|
v-model:rows="editOpenCodeGoProtocolRules"
|
||||||
|
:plan="editOpenCodeAccountMode"
|
||||||
|
/>
|
||||||
<!-- Zhipu 团队版 Coding Plan:组织/项目 ID(可选,填写后用量查询走团队版端点) -->
|
<!-- Zhipu 团队版 Coding Plan:组织/项目 ID(可选,填写后用量查询走团队版端点) -->
|
||||||
<div v-if="account.platform === 'zhipu' && editAccountMode === 'coding'">
|
<div v-if="account.platform === 'zhipu' && editAccountMode === 'coding'">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -2995,15 +3052,21 @@ import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.
|
|||||||
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
import QuotaLimitCard from '@/components/account/QuotaLimitCard.vue'
|
||||||
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
import GrokBaseUrlPresets from '@/components/account/GrokBaseUrlPresets.vue'
|
||||||
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
|
import CnBaseUrlPresets from '@/components/account/CnBaseUrlPresets.vue'
|
||||||
|
import OpenCodeGoProtocolRulesEditor from '@/components/account/OpenCodeGoProtocolRulesEditor.vue'
|
||||||
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
import HeaderOverrideEditor from '@/components/account/HeaderOverrideEditor.vue'
|
||||||
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
|
import OllamaCloudUsageSettings from '@/components/account/OllamaCloudUsageSettings.vue'
|
||||||
import {
|
import {
|
||||||
applyAntigravityProjectID,
|
applyAntigravityProjectID,
|
||||||
applyHeaderOverride,
|
applyHeaderOverride,
|
||||||
applyInterceptWarmup,
|
applyInterceptWarmup,
|
||||||
|
applyOpenCodeGoProtocolRules,
|
||||||
applyPlanType,
|
applyPlanType,
|
||||||
buildPlanTypeOptions,
|
buildPlanTypeOptions,
|
||||||
|
cloneOpenCodeGoProtocolRules,
|
||||||
|
defaultOpenCodeProtocolRules,
|
||||||
|
parseOpenCodeGoProtocolRules,
|
||||||
readPlanType,
|
readPlanType,
|
||||||
|
resolveOpenCodeAccountMode,
|
||||||
isCustomGrokBaseUrl,
|
isCustomGrokBaseUrl,
|
||||||
isHeaderOverrideCapable,
|
isHeaderOverrideCapable,
|
||||||
splitHeaderOverridesObject,
|
splitHeaderOverridesObject,
|
||||||
@@ -3018,7 +3081,9 @@ import {
|
|||||||
type CnApiProtocol,
|
type CnApiProtocol,
|
||||||
type CnNativeApiProtocol,
|
type CnNativeApiProtocol,
|
||||||
type CnProviderPlatform,
|
type CnProviderPlatform,
|
||||||
type HeaderOverrideRow
|
type HeaderOverrideRow,
|
||||||
|
type OpenCodeAccountMode,
|
||||||
|
type OpenCodeGoProtocolRule
|
||||||
} from '@/components/account/credentialsBuilder'
|
} from '@/components/account/credentialsBuilder'
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@@ -3122,7 +3187,9 @@ const editApiKey = ref('')
|
|||||||
// account_mode 决定额度/余额监控路径,api_protocol 决定转发端点与格式;
|
// account_mode 决定额度/余额监控路径,api_protocol 决定转发端点与格式;
|
||||||
// 二者均可修正(早期创建的账号可能存错默认值),切换时重置 base_url 预置。
|
// 二者均可修正(早期创建的账号可能存错默认值),切换时重置 base_url 预置。
|
||||||
const isCNApiKeyAccount = computed(
|
const isCNApiKeyAccount = computed(
|
||||||
() => props.account?.type === 'apikey' && isCNProviderPlatform(props.account.platform)
|
() =>
|
||||||
|
props.account?.type === 'apikey' &&
|
||||||
|
(isCNProviderPlatform(props.account.platform) || props.account.platform === 'opencode_go')
|
||||||
)
|
)
|
||||||
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
|
// CnBaseUrlPresets 的 platform prop 是平台字面量联合类型,模板里不能写
|
||||||
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
|
// `as` 断言(其中的 `|` 会被 eslint 误判为 Vue2 filter 语法),经此 computed 传递。
|
||||||
@@ -3133,8 +3200,17 @@ const cnPresetPlatform = computed<CnProviderPlatform>(() => {
|
|||||||
}
|
}
|
||||||
return 'kimi'
|
return 'kimi'
|
||||||
})
|
})
|
||||||
|
const adaptivePresetPlatform = computed<CnProviderPlatform | 'opencode_go'>(() => {
|
||||||
|
if (props.account?.platform === 'opencode_go') return 'opencode_go'
|
||||||
|
return cnPresetPlatform.value
|
||||||
|
})
|
||||||
const editApiProtocol = ref<CnApiProtocol>('adaptive')
|
const editApiProtocol = ref<CnApiProtocol>('adaptive')
|
||||||
|
const editOpenCodeGoProtocolRules = ref<OpenCodeGoProtocolRule[]>(cloneOpenCodeGoProtocolRules())
|
||||||
const editAccountMode = ref<CnAccountMode>('payg')
|
const editAccountMode = ref<CnAccountMode>('payg')
|
||||||
|
const editOpenCodeAccountMode = ref<OpenCodeAccountMode>('go')
|
||||||
|
function currentOpenCodeOrCNMode(): CnAccountMode | OpenCodeAccountMode {
|
||||||
|
return props.account?.platform === 'opencode_go' ? editOpenCodeAccountMode.value : editAccountMode.value
|
||||||
|
}
|
||||||
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
|
// 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点
|
||||||
const editZhipuOrganization = ref('')
|
const editZhipuOrganization = ref('')
|
||||||
const editZhipuProject = ref('')
|
const editZhipuProject = ref('')
|
||||||
@@ -3182,7 +3258,7 @@ const editAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol;
|
|||||||
watch(editApiProtocol, (protocol, previousProtocol) => {
|
watch(editApiProtocol, (protocol, previousProtocol) => {
|
||||||
if (!isCNApiKeyAccount.value || syncingForm.value) return
|
if (!isCNApiKeyAccount.value || syncingForm.value) return
|
||||||
if (protocol === 'adaptive') {
|
if (protocol === 'adaptive') {
|
||||||
const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value)
|
const defaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, currentOpenCodeOrCNMode())
|
||||||
for (const item of editAdaptiveProtocolOptions.value) {
|
for (const item of editAdaptiveProtocolOptions.value) {
|
||||||
if (!editAdaptiveBaseUrls.value[item.value]) editAdaptiveBaseUrls.value[item.value] = defaults[item.value]
|
if (!editAdaptiveBaseUrls.value[item.value]) editAdaptiveBaseUrls.value[item.value] = defaults[item.value]
|
||||||
}
|
}
|
||||||
@@ -3194,13 +3270,14 @@ watch(editApiProtocol, (protocol, previousProtocol) => {
|
|||||||
}
|
}
|
||||||
if (previousProtocol === 'adaptive') {
|
if (previousProtocol === 'adaptive') {
|
||||||
editBaseUrl.value = editAdaptiveBaseUrls.value[protocol] ||
|
editBaseUrl.value = editAdaptiveBaseUrls.value[protocol] ||
|
||||||
defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol)
|
defaultCNBaseUrl(props.account!.platform, currentOpenCodeOrCNMode(), protocol)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol)
|
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, currentOpenCodeOrCNMode(), protocol)
|
||||||
})
|
})
|
||||||
watch(editAccountMode, (mode, previousMode) => {
|
watch(editAccountMode, (mode, previousMode) => {
|
||||||
if (!isCNApiKeyAccount.value || syncingForm.value) return
|
if (!isCNApiKeyAccount.value || syncingForm.value) return
|
||||||
|
if (props.account?.platform === 'opencode_go') return
|
||||||
// deepseek 无 coding 套餐:防御性回退(UI 已隐藏该选项)。
|
// deepseek 无 coding 套餐:防御性回退(UI 已隐藏该选项)。
|
||||||
const effectiveMode = props.account!.platform === 'deepseek' && mode === 'coding' ? 'payg' : mode
|
const effectiveMode = props.account!.platform === 'deepseek' && mode === 'coding' ? 'payg' : mode
|
||||||
if (effectiveMode !== mode) {
|
if (effectiveMode !== mode) {
|
||||||
@@ -3208,8 +3285,8 @@ watch(editAccountMode, (mode, previousMode) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (editApiProtocol.value === 'adaptive') {
|
if (editApiProtocol.value === 'adaptive') {
|
||||||
const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode)
|
const previousDefaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, previousMode)
|
||||||
const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode)
|
const nextDefaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, mode)
|
||||||
for (const item of editAdaptiveProtocolOptions.value) {
|
for (const item of editAdaptiveProtocolOptions.value) {
|
||||||
if (!editAdaptiveBaseUrls.value[item.value] || editAdaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
if (!editAdaptiveBaseUrls.value[item.value] || editAdaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
||||||
editAdaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
editAdaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
||||||
@@ -3220,6 +3297,25 @@ watch(editAccountMode, (mode, previousMode) => {
|
|||||||
}
|
}
|
||||||
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, mode, editApiProtocol.value)
|
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, mode, editApiProtocol.value)
|
||||||
})
|
})
|
||||||
|
watch(editOpenCodeAccountMode, (mode, previousMode) => {
|
||||||
|
if (!isCNApiKeyAccount.value || props.account?.platform !== 'opencode_go' || syncingForm.value) return
|
||||||
|
if (editApiProtocol.value === 'adaptive') {
|
||||||
|
const previousDefaults = defaultCNAdaptiveBaseUrls('opencode_go', previousMode)
|
||||||
|
const nextDefaults = defaultCNAdaptiveBaseUrls('opencode_go', mode)
|
||||||
|
for (const item of editAdaptiveProtocolOptions.value) {
|
||||||
|
if (!editAdaptiveBaseUrls.value[item.value] || editAdaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
|
||||||
|
editAdaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editBaseUrl.value = editAdaptiveBaseUrls.value.chat_completions
|
||||||
|
} else {
|
||||||
|
editBaseUrl.value = defaultCNBaseUrl('opencode_go', mode, editApiProtocol.value)
|
||||||
|
}
|
||||||
|
const previousRules = JSON.stringify(defaultOpenCodeProtocolRules(previousMode))
|
||||||
|
if (JSON.stringify(editOpenCodeGoProtocolRules.value) === previousRules) {
|
||||||
|
editOpenCodeGoProtocolRules.value = cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(mode))
|
||||||
|
}
|
||||||
|
})
|
||||||
const cnProtocolDescKey = computed(
|
const cnProtocolDescKey = computed(
|
||||||
() => cnProtocolOptions.value.find(o => o.value === editApiProtocol.value)?.labelKey ?? 'chatCompletions'
|
() => cnProtocolOptions.value.find(o => o.value === editApiProtocol.value)?.labelKey ?? 'chatCompletions'
|
||||||
)
|
)
|
||||||
@@ -3729,9 +3825,10 @@ const defaultBaseUrl = computed(() => {
|
|||||||
if (
|
if (
|
||||||
props.account?.platform === 'kimi' ||
|
props.account?.platform === 'kimi' ||
|
||||||
props.account?.platform === 'zhipu' ||
|
props.account?.platform === 'zhipu' ||
|
||||||
props.account?.platform === 'deepseek'
|
props.account?.platform === 'deepseek' ||
|
||||||
|
props.account?.platform === 'opencode_go'
|
||||||
) {
|
) {
|
||||||
return defaultCNBaseUrl(props.account.platform, editAccountMode.value, editApiProtocol.value)
|
return defaultCNBaseUrl(props.account.platform, currentOpenCodeOrCNMode(), editApiProtocol.value)
|
||||||
}
|
}
|
||||||
return 'https://api.anthropic.com'
|
return 'https://api.anthropic.com'
|
||||||
})
|
})
|
||||||
@@ -4106,8 +4203,12 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
const credentials = newAccount.credentials as Record<string, unknown>
|
const credentials = newAccount.credentials as Record<string, unknown>
|
||||||
// 国产供应商:读取 account_mode 与 api_protocol 作为可编辑初始值
|
// 国产供应商:读取 account_mode 与 api_protocol 作为可编辑初始值
|
||||||
// (编辑弹窗允许修正两者,用于修复早期存错默认值的账号)。
|
// (编辑弹窗允许修正两者,用于修复早期存错默认值的账号)。
|
||||||
if (isCNProviderPlatform(newAccount.platform)) {
|
if (isCNProviderPlatform(newAccount.platform) || newAccount.platform === 'opencode_go') {
|
||||||
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
|
if (newAccount.platform === 'opencode_go') {
|
||||||
|
editOpenCodeAccountMode.value = resolveOpenCodeAccountMode(credentials.account_mode)
|
||||||
|
} else {
|
||||||
|
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
|
||||||
|
}
|
||||||
const storedProtocol = credentials.api_protocol
|
const storedProtocol = credentials.api_protocol
|
||||||
editApiProtocol.value =
|
editApiProtocol.value =
|
||||||
storedProtocol === 'adaptive' ||
|
storedProtocol === 'adaptive' ||
|
||||||
@@ -4119,7 +4220,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
if (!cnSupportsNativeResponses(newAccount.platform) && editApiProtocol.value === 'responses') {
|
if (!cnSupportsNativeResponses(newAccount.platform) && editApiProtocol.value === 'responses') {
|
||||||
editApiProtocol.value = 'chat_completions'
|
editApiProtocol.value = 'chat_completions'
|
||||||
}
|
}
|
||||||
const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value)
|
const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, currentOpenCodeOrCNMode())
|
||||||
const storedBaseUrls = (credentials.api_base_urls as Record<string, unknown> | undefined) || {}
|
const storedBaseUrls = (credentials.api_base_urls as Record<string, unknown> | undefined) || {}
|
||||||
const legacyBaseUrl = typeof credentials.base_url === 'string' ? credentials.base_url.trim() : ''
|
const legacyBaseUrl = typeof credentials.base_url === 'string' ? credentials.base_url.trim() : ''
|
||||||
const storedChatBaseUrl = typeof storedBaseUrls.chat_completions === 'string'
|
const storedChatBaseUrl = typeof storedBaseUrls.chat_completions === 'string'
|
||||||
@@ -4155,6 +4256,11 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
editZhipuOrganization.value = typeof credentials.zhipu_organization === 'string' ? credentials.zhipu_organization : ''
|
editZhipuOrganization.value = typeof credentials.zhipu_organization === 'string' ? credentials.zhipu_organization : ''
|
||||||
editZhipuProject.value = typeof credentials.zhipu_project === 'string' ? credentials.zhipu_project : ''
|
editZhipuProject.value = typeof credentials.zhipu_project === 'string' ? credentials.zhipu_project : ''
|
||||||
}
|
}
|
||||||
|
if (newAccount.platform === 'opencode_go') {
|
||||||
|
editOpenCodeGoProtocolRules.value =
|
||||||
|
parseOpenCodeGoProtocolRules(credentials.protocol_rules) ??
|
||||||
|
cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(editOpenCodeAccountMode.value))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const platformDefaultUrl =
|
const platformDefaultUrl =
|
||||||
newAccount.platform === 'openai'
|
newAccount.platform === 'openai'
|
||||||
@@ -4165,8 +4271,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
|||||||
? 'https://api.x.ai/v1'
|
? 'https://api.x.ai/v1'
|
||||||
: newAccount.platform === 'kimi' ||
|
: newAccount.platform === 'kimi' ||
|
||||||
newAccount.platform === 'zhipu' ||
|
newAccount.platform === 'zhipu' ||
|
||||||
newAccount.platform === 'deepseek'
|
newAccount.platform === 'deepseek' ||
|
||||||
? defaultCNBaseUrl(newAccount.platform, editAccountMode.value, editApiProtocol.value)
|
newAccount.platform === 'opencode_go'
|
||||||
|
? defaultCNBaseUrl(newAccount.platform, currentOpenCodeOrCNMode(), editApiProtocol.value)
|
||||||
: 'https://api.anthropic.com'
|
: 'https://api.anthropic.com'
|
||||||
editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive'
|
editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive'
|
||||||
? editAdaptiveBaseUrls.value.chat_completions
|
? editAdaptiveBaseUrls.value.chat_completions
|
||||||
@@ -4899,10 +5006,10 @@ const handleSubmit = async () => {
|
|||||||
|
|
||||||
// 国产供应商:模式与协议写入凭据(决定额度/余额探测与转发端点/格式)。
|
// 国产供应商:模式与协议写入凭据(决定额度/余额探测与转发端点/格式)。
|
||||||
if (isCNApiKeyAccount.value) {
|
if (isCNApiKeyAccount.value) {
|
||||||
newCredentials.account_mode = editAccountMode.value
|
newCredentials.account_mode = currentOpenCodeOrCNMode()
|
||||||
newCredentials.api_protocol = editApiProtocol.value
|
newCredentials.api_protocol = editApiProtocol.value
|
||||||
if (editApiProtocol.value === 'adaptive') {
|
if (editApiProtocol.value === 'adaptive') {
|
||||||
const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value)
|
const defaults = defaultCNAdaptiveBaseUrls(adaptivePresetPlatform.value, currentOpenCodeOrCNMode())
|
||||||
const protocolBaseUrls: Record<string, string> = {}
|
const protocolBaseUrls: Record<string, string> = {}
|
||||||
for (const item of editAdaptiveProtocolOptions.value) {
|
for (const item of editAdaptiveProtocolOptions.value) {
|
||||||
protocolBaseUrls[item.value] = (editAdaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
|
protocolBaseUrls[item.value] = (editAdaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
|
||||||
@@ -4912,6 +5019,9 @@ const handleSubmit = async () => {
|
|||||||
} else {
|
} else {
|
||||||
delete newCredentials.api_base_urls
|
delete newCredentials.api_base_urls
|
||||||
}
|
}
|
||||||
|
if (props.account.platform === 'opencode_go') {
|
||||||
|
applyOpenCodeGoProtocolRules(newCredentials, editOpenCodeGoProtocolRules.value, 'edit')
|
||||||
|
}
|
||||||
// 智谱团队版 Coding Plan:组织/项目 ID 写入凭据(非空才写,清空即移除回落个人版路径)
|
// 智谱团队版 Coding Plan:组织/项目 ID 写入凭据(非空才写,清空即移除回落个人版路径)
|
||||||
if (props.account.platform === 'zhipu') {
|
if (props.account.platform === 'zhipu') {
|
||||||
const org = editZhipuOrganization.value.trim()
|
const org = editZhipuOrganization.value.trim()
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ const upstreamSyncPlatforms = new Set([
|
|||||||
'kimi',
|
'kimi',
|
||||||
'zhipu',
|
'zhipu',
|
||||||
'deepseek',
|
'deepseek',
|
||||||
'minimax'
|
'minimax',
|
||||||
|
'opencode_go'
|
||||||
])
|
])
|
||||||
const canSyncUpstream = computed(() => {
|
const canSyncUpstream = computed(() => {
|
||||||
if (props.accountId) {
|
if (props.accountId) {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<label class="input-label mb-0">{{ t('admin.accounts.opencodeGo.protocolRules.title') }}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400"
|
||||||
|
@click="restoreDefaults"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.opencodeGo.protocolRules.restoreDefaults') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="input-hint mb-2">{{ t('admin.accounts.opencodeGo.protocolRules.hint') }}</p>
|
||||||
|
<div v-if="rows.length > 0" class="mb-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(row, index) in rows"
|
||||||
|
:key="getRowKey(row)"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="row.pattern"
|
||||||
|
type="text"
|
||||||
|
class="input flex-1 font-mono text-sm"
|
||||||
|
:placeholder="t('admin.accounts.opencodeGo.protocolRules.patternPlaceholder')"
|
||||||
|
:data-testid="`opencode-go-protocol-pattern-${index}`"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="row.protocol"
|
||||||
|
class="input w-44 shrink-0"
|
||||||
|
:data-testid="`opencode-go-protocol-select-${index}`"
|
||||||
|
>
|
||||||
|
<option value="chat_completions">
|
||||||
|
{{ t('admin.accounts.cnProviders.apiProtocol.chatCompletions') }}
|
||||||
|
</option>
|
||||||
|
<option value="responses">
|
||||||
|
{{ t('admin.accounts.cnProviders.apiProtocol.responses') }}
|
||||||
|
</option>
|
||||||
|
<option value="anthropic">
|
||||||
|
{{ t('admin.accounts.cnProviders.apiProtocol.anthropic') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||||
|
:aria-label="t('admin.accounts.opencodeGo.protocolRules.remove')"
|
||||||
|
@click="removeRow(index)"
|
||||||
|
>
|
||||||
|
<Icon name="trash" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mb-2 flex items-center gap-2 rounded-lg border border-dashed border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-500 dark:border-dark-600 dark:bg-dark-800/60 dark:text-gray-400"
|
||||||
|
data-testid="opencode-go-protocol-fallback"
|
||||||
|
>
|
||||||
|
<span class="flex-1 font-mono">*</span>
|
||||||
|
<span>{{ t('admin.accounts.opencodeGo.protocolRules.fallback') }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
|
||||||
|
data-testid="opencode-go-protocol-add-rule"
|
||||||
|
@click="addRow"
|
||||||
|
>
|
||||||
|
{{ t('admin.accounts.opencodeGo.protocolRules.add') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||||
|
import {
|
||||||
|
cloneOpenCodeGoProtocolRules,
|
||||||
|
defaultOpenCodeProtocolRules,
|
||||||
|
type OpenCodeAccountMode,
|
||||||
|
type OpenCodeGoProtocolRule
|
||||||
|
} from '@/components/account/credentialsBuilder'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
rows: OpenCodeGoProtocolRule[]
|
||||||
|
plan?: OpenCodeAccountMode
|
||||||
|
}>(), {
|
||||||
|
plan: 'go'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:rows', rows: OpenCodeGoProtocolRule[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const getRowKey = createStableObjectKeyResolver<OpenCodeGoProtocolRule>('opencode-go-protocol-rule')
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
emit('update:rows', [...props.rows, { pattern: '', protocol: 'chat_completions' }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRow = (index: number) => {
|
||||||
|
emit('update:rows', props.rows.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoreDefaults = () => {
|
||||||
|
emit('update:rows', cloneOpenCodeGoProtocolRules(defaultOpenCodeProtocolRules(props.plan)))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -445,6 +445,65 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
|||||||
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
|
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('submits OpenCode Zen default protocol rules with adaptive endpoints', async () => {
|
||||||
|
const wrapper = mountModal()
|
||||||
|
await selectButtonByText(wrapper, 'OpenCode')
|
||||||
|
await wrapper.get('form#create-account-form input[type="text"]').setValue('oc')
|
||||||
|
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-opencode-zen')
|
||||||
|
|
||||||
|
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
|
||||||
|
account_mode: 'zen',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/v1',
|
||||||
|
api_base_urls: {
|
||||||
|
chat_completions: 'https://opencode.ai/zen/v1',
|
||||||
|
anthropic: 'https://opencode.ai/zen',
|
||||||
|
responses: 'https://opencode.ai/zen/v1'
|
||||||
|
},
|
||||||
|
protocol_rules: [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'claude-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits OpenCode GO endpoints after switching account type', async () => {
|
||||||
|
const wrapper = mountModal()
|
||||||
|
await selectButtonByText(wrapper, 'OpenCode')
|
||||||
|
await selectButtonByText(wrapper, 'admin.accounts.opencodeGo.accountMode.go')
|
||||||
|
await wrapper.get('form#create-account-form input[type="text"]').setValue('oc-go')
|
||||||
|
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-opencode-go')
|
||||||
|
|
||||||
|
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(createAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({
|
||||||
|
account_mode: 'go',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/go/v1',
|
||||||
|
api_base_urls: {
|
||||||
|
chat_completions: 'https://opencode.ai/zen/go/v1',
|
||||||
|
anthropic: 'https://opencode.ai/zen/go',
|
||||||
|
responses: 'https://opencode.ai/zen/go/v1'
|
||||||
|
},
|
||||||
|
protocol_rules: [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'minimax-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('submits adaptive Kimi protocol endpoints', async () => {
|
it('submits adaptive Kimi protocol endpoints', async () => {
|
||||||
const wrapper = mountModal()
|
const wrapper = mountModal()
|
||||||
await selectButtonByText(wrapper, 'Kimi')
|
await selectButtonByText(wrapper, 'Kimi')
|
||||||
|
|||||||
@@ -445,6 +445,80 @@ describe('EditAccountModal', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('preserves OpenCode Zen account type and endpoints on submit', async () => {
|
||||||
|
const account = buildAccount()
|
||||||
|
account.platform = 'opencode_go'
|
||||||
|
account.credentials = {
|
||||||
|
api_key: 'sk-opencode',
|
||||||
|
account_mode: 'zen',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/v1',
|
||||||
|
api_base_urls: {
|
||||||
|
chat_completions: 'https://opencode.ai/zen/v1',
|
||||||
|
anthropic: 'https://opencode.ai/zen',
|
||||||
|
responses: 'https://opencode.ai/zen/v1'
|
||||||
|
},
|
||||||
|
protocol_rules: [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'claude-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
updateAccountMock.mockReset().mockResolvedValue(account)
|
||||||
|
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||||
|
|
||||||
|
const wrapper = mountModal(account)
|
||||||
|
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
|
||||||
|
account_mode: 'zen',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/v1',
|
||||||
|
api_base_urls: {
|
||||||
|
chat_completions: 'https://opencode.ai/zen/v1',
|
||||||
|
anthropic: 'https://opencode.ai/zen',
|
||||||
|
responses: 'https://opencode.ai/zen/v1'
|
||||||
|
},
|
||||||
|
protocol_rules: [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'claude-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a legacy OpenCode account without account_mode as GO', async () => {
|
||||||
|
const account = buildAccount()
|
||||||
|
account.platform = 'opencode_go'
|
||||||
|
account.credentials = {
|
||||||
|
api_key: 'sk-opencode',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/go/v1',
|
||||||
|
api_base_urls: {
|
||||||
|
chat_completions: 'https://opencode.ai/zen/go/v1',
|
||||||
|
anthropic: 'https://opencode.ai/zen/go',
|
||||||
|
responses: 'https://opencode.ai/zen/go/v1'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateAccountMock.mockReset().mockResolvedValue(account)
|
||||||
|
checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
|
||||||
|
|
||||||
|
const wrapper = mountModal(account)
|
||||||
|
await wrapper.get('form#edit-account-form').trigger('submit.prevent')
|
||||||
|
|
||||||
|
expect(updateAccountMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
|
||||||
|
account_mode: 'go',
|
||||||
|
api_protocol: 'adaptive',
|
||||||
|
base_url: 'https://opencode.ai/zen/go/v1'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('preserves adaptive Kimi Responses endpoint on submit', async () => {
|
it('preserves adaptive Kimi Responses endpoint on submit', async () => {
|
||||||
const account = buildAccount()
|
const account = buildAccount()
|
||||||
account.platform = 'kimi'
|
account.platform = 'kimi'
|
||||||
|
|||||||
@@ -219,4 +219,22 @@ describe('ModelWhitelistSelector', () => {
|
|||||||
expect(wrapper.emitted('upstream-synced')).toEqual([[]])
|
expect(wrapper.emitted('upstream-synced')).toEqual([[]])
|
||||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
|
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the upstream sync button for OpenCode Go create-account credentials', () => {
|
||||||
|
const wrapper = mountSelector({
|
||||||
|
platform: 'opencode_go',
|
||||||
|
syncCredentials: {
|
||||||
|
platform: 'opencode_go',
|
||||||
|
type: 'apikey',
|
||||||
|
base_url: 'https://opencode.ai/zen/go/v1',
|
||||||
|
api_key: 'sk-test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const syncButton = wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find(button => button.text() === 'admin.accounts.syncUpstreamModels')
|
||||||
|
|
||||||
|
expect(syncButton).toBeDefined()
|
||||||
|
expect(syncButton?.exists()).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -3,16 +3,24 @@ import {
|
|||||||
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
|
ANTIGRAVITY_PROJECT_ID_CREDENTIAL_KEY,
|
||||||
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
|
||||||
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
HEADER_OVERRIDES_CREDENTIAL_KEY,
|
||||||
|
OPENCODE_GO_PROTOCOL_RULES_KEY,
|
||||||
applyAntigravityProjectID,
|
applyAntigravityProjectID,
|
||||||
applyHeaderOverride,
|
applyHeaderOverride,
|
||||||
applyInterceptWarmup,
|
applyInterceptWarmup,
|
||||||
|
applyOpenCodeGoProtocolRules,
|
||||||
applyPlanType,
|
applyPlanType,
|
||||||
buildHeaderOverridesObject,
|
buildHeaderOverridesObject,
|
||||||
buildPlanTypeOptions,
|
buildPlanTypeOptions,
|
||||||
|
cloneOpenCodeGoProtocolRules,
|
||||||
|
cnQuotaCellVisible,
|
||||||
|
defaultCNBaseUrl,
|
||||||
|
defaultOpenCodeProtocolRules,
|
||||||
isCustomGrokBaseUrl,
|
isCustomGrokBaseUrl,
|
||||||
|
resolveOpenCodeAccountMode,
|
||||||
isHeaderOverrideCapable,
|
isHeaderOverrideCapable,
|
||||||
GROK_BASE_URL_PRESETS,
|
GROK_BASE_URL_PRESETS,
|
||||||
parseHeaderOverridesJson,
|
parseHeaderOverridesJson,
|
||||||
|
parseOpenCodeGoProtocolRules,
|
||||||
planTypeDisplayLabel,
|
planTypeDisplayLabel,
|
||||||
readPlanType,
|
readPlanType,
|
||||||
serializeHeaderOverrideRows,
|
serializeHeaderOverrideRows,
|
||||||
@@ -98,6 +106,53 @@ describe('applyAntigravityProjectID', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('openCodeGo protocol rules', () => {
|
||||||
|
it('resolves missing OpenCode account_mode as GO and zen as Zen', () => {
|
||||||
|
expect(resolveOpenCodeAccountMode(undefined)).toBe('go')
|
||||||
|
expect(resolveOpenCodeAccountMode('coding')).toBe('go')
|
||||||
|
expect(resolveOpenCodeAccountMode('zen')).toBe('zen')
|
||||||
|
expect(resolveOpenCodeAccountMode('go')).toBe('go')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses Zen vs GO default endpoints and protocol rules', () => {
|
||||||
|
expect(defaultCNBaseUrl('opencode_go', 'zen', 'adaptive')).toBe('https://opencode.ai/zen/v1')
|
||||||
|
expect(defaultCNBaseUrl('opencode_go', 'zen', 'anthropic')).toBe('https://opencode.ai/zen')
|
||||||
|
expect(defaultCNBaseUrl('opencode_go', 'go', 'adaptive')).toBe('https://opencode.ai/zen/go/v1')
|
||||||
|
expect(defaultCNBaseUrl('opencode_go', 'go', 'anthropic')).toBe('https://opencode.ai/zen/go')
|
||||||
|
expect(defaultOpenCodeProtocolRules('zen').some(rule => rule.pattern === 'claude-*')).toBe(true)
|
||||||
|
expect(defaultOpenCodeProtocolRules('go').some(rule => rule.pattern === 'minimax-*')).toBe(true)
|
||||||
|
expect(cnQuotaCellVisible('opencode_go', 'zen')).toBe(false)
|
||||||
|
expect(cnQuotaCellVisible('opencode_go', 'go')).toBe(true)
|
||||||
|
expect(cnQuotaCellVisible('opencode_go', '')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses stored rules and skips invalid entries', () => {
|
||||||
|
expect(parseOpenCodeGoProtocolRules(null)).toBeNull()
|
||||||
|
expect(parseOpenCodeGoProtocolRules([])).toEqual([])
|
||||||
|
expect(
|
||||||
|
parseOpenCodeGoProtocolRules([
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: '', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'adaptive' },
|
||||||
|
{ pattern: 'minimax-*', protocol: 'anthropic' }
|
||||||
|
])
|
||||||
|
).toEqual([
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'minimax-*', protocol: 'anthropic' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('writes lowercase patterns on create and keeps an empty list on edit', () => {
|
||||||
|
const created: Record<string, unknown> = {}
|
||||||
|
applyOpenCodeGoProtocolRules(created, cloneOpenCodeGoProtocolRules(), 'create')
|
||||||
|
expect(created[OPENCODE_GO_PROTOCOL_RULES_KEY]).toEqual(cloneOpenCodeGoProtocolRules())
|
||||||
|
|
||||||
|
const edited: Record<string, unknown> = { api_key: 'sk' }
|
||||||
|
applyOpenCodeGoProtocolRules(edited, [], 'edit')
|
||||||
|
expect(edited[OPENCODE_GO_PROTOCOL_RULES_KEY]).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('isHeaderOverrideCapable', () => {
|
describe('isHeaderOverrideCapable', () => {
|
||||||
it('anthropic/openai only support apikey accounts', () => {
|
it('anthropic/openai only support apikey accounts', () => {
|
||||||
expect(isHeaderOverrideCapable('anthropic', 'apikey')).toBe(true)
|
expect(isHeaderOverrideCapable('anthropic', 'apikey')).toBe(true)
|
||||||
@@ -107,7 +162,7 @@ describe('isHeaderOverrideCapable', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('kimi/zhipu/deepseek only support apikey accounts', () => {
|
it('kimi/zhipu/deepseek only support apikey accounts', () => {
|
||||||
for (const platform of ['kimi', 'zhipu', 'deepseek', 'minimax']) {
|
for (const platform of ['kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go']) {
|
||||||
expect(isHeaderOverrideCapable(platform, 'apikey')).toBe(true)
|
expect(isHeaderOverrideCapable(platform, 'apikey')).toBe(true)
|
||||||
expect(isHeaderOverrideCapable(platform, 'oauth')).toBe(false)
|
expect(isHeaderOverrideCapable(platform, 'oauth')).toBe(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export function isHeaderOverrideCapable(platform: string, type: string): boolean
|
|||||||
platform === 'kimi' ||
|
platform === 'kimi' ||
|
||||||
platform === 'zhipu' ||
|
platform === 'zhipu' ||
|
||||||
platform === 'deepseek' ||
|
platform === 'deepseek' ||
|
||||||
platform === 'minimax'
|
platform === 'minimax' ||
|
||||||
|
platform === 'opencode_go'
|
||||||
) {
|
) {
|
||||||
return type === 'apikey'
|
return type === 'apikey'
|
||||||
}
|
}
|
||||||
@@ -258,6 +259,7 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
|
|||||||
// 两者正交。同协议请求零转换直通,跨协议组合才走转换链。
|
// 两者正交。同协议请求零转换直通,跨协议组合才走转换链。
|
||||||
|
|
||||||
export type CnAccountMode = 'payg' | 'coding'
|
export type CnAccountMode = 'payg' | 'coding'
|
||||||
|
export type OpenCodeAccountMode = 'zen' | 'go'
|
||||||
export type CnProviderPlatform = 'kimi' | 'zhipu' | 'deepseek' | 'minimax'
|
export type CnProviderPlatform = 'kimi' | 'zhipu' | 'deepseek' | 'minimax'
|
||||||
|
|
||||||
/** deepseek / kimi / minimax 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
|
/** deepseek / kimi / minimax 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
|
||||||
@@ -270,7 +272,93 @@ export function isCNProviderPlatform(platform: string): platform is CnProviderPl
|
|||||||
|
|
||||||
/** DeepSeek、Kimi 与 MiniMax 提供原生 Responses 端点。 */
|
/** DeepSeek、Kimi 与 MiniMax 提供原生 Responses 端点。 */
|
||||||
export function cnSupportsNativeResponses(platform: string): boolean {
|
export function cnSupportsNativeResponses(platform: string): boolean {
|
||||||
return platform === 'deepseek' || platform === 'kimi' || platform === 'minimax'
|
return platform === 'deepseek' || platform === 'kimi' || platform === 'minimax' || platform === 'opencode_go'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPENCODE_GO_BASE_URL = 'https://opencode.ai/zen/go/v1'
|
||||||
|
export const OPENCODE_GO_ANTHROPIC_BASE_URL = 'https://opencode.ai/zen/go'
|
||||||
|
export const OPENCODE_ZEN_BASE_URL = 'https://opencode.ai/zen/v1'
|
||||||
|
export const OPENCODE_ZEN_ANTHROPIC_BASE_URL = 'https://opencode.ai/zen'
|
||||||
|
|
||||||
|
export function isOpenCodeGoPlatform(platform: string): boolean {
|
||||||
|
return platform === 'opencode_go'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPENCODE_GO_PROTOCOL_RULES_KEY = 'protocol_rules'
|
||||||
|
|
||||||
|
export interface OpenCodeGoProtocolRule {
|
||||||
|
pattern: string
|
||||||
|
protocol: CnNativeApiProtocol
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_GO_PROTOCOL_RULES: OpenCodeGoProtocolRule[] = [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'minimax-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_OPENCODE_ZEN_PROTOCOL_RULES: OpenCodeGoProtocolRule[] = [
|
||||||
|
{ pattern: 'grok-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'gpt-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'muse-spark-*', protocol: 'responses' },
|
||||||
|
{ pattern: 'claude-*', protocol: 'anthropic' },
|
||||||
|
{ pattern: 'qwen*', protocol: 'anthropic' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function resolveOpenCodeAccountMode(value: unknown): OpenCodeAccountMode {
|
||||||
|
return value === 'zen' ? 'zen' : 'go'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultOpenCodeProtocolRules(mode: OpenCodeAccountMode = 'go'): OpenCodeGoProtocolRule[] {
|
||||||
|
return mode === 'zen' ? DEFAULT_OPENCODE_ZEN_PROTOCOL_RULES : DEFAULT_OPENCODE_GO_PROTOCOL_RULES
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneOpenCodeGoProtocolRules(
|
||||||
|
rules: OpenCodeGoProtocolRule[] = DEFAULT_OPENCODE_GO_PROTOCOL_RULES
|
||||||
|
): OpenCodeGoProtocolRule[] {
|
||||||
|
return rules.map(rule => ({ pattern: rule.pattern, protocol: rule.protocol }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNativeOpenCodeGoProtocol(value: unknown): value is CnNativeApiProtocol {
|
||||||
|
return value === 'chat_completions' || value === 'anthropic' || value === 'responses'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpenCodeGoProtocolRules(raw: unknown): OpenCodeGoProtocolRule[] | null {
|
||||||
|
if (raw == null) return null
|
||||||
|
if (!Array.isArray(raw)) return cloneOpenCodeGoProtocolRules()
|
||||||
|
const rules: OpenCodeGoProtocolRule[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const pattern = typeof (item as { pattern?: unknown }).pattern === 'string'
|
||||||
|
? (item as { pattern: string }).pattern.trim()
|
||||||
|
: ''
|
||||||
|
const protocol = (item as { protocol?: unknown }).protocol
|
||||||
|
if (!pattern || !isNativeOpenCodeGoProtocol(protocol)) continue
|
||||||
|
rules.push({ pattern, protocol })
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOpenCodeGoProtocolRules(
|
||||||
|
credentials: Record<string, unknown>,
|
||||||
|
rules: OpenCodeGoProtocolRule[],
|
||||||
|
mode: 'create' | 'edit'
|
||||||
|
): void {
|
||||||
|
const serialized = rules
|
||||||
|
.map(rule => ({
|
||||||
|
pattern: rule.pattern.trim().toLowerCase(),
|
||||||
|
protocol: rule.protocol
|
||||||
|
}))
|
||||||
|
.filter(rule => rule.pattern.length > 0 && isNativeOpenCodeGoProtocol(rule.protocol))
|
||||||
|
if (serialized.length > 0 || mode === 'edit') {
|
||||||
|
credentials[OPENCODE_GO_PROTOCOL_RULES_KEY] = serialized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMultiProtocolApiKeyPlatform(platform: string): boolean {
|
||||||
|
return platform === 'kimi' || platform === 'zhipu' || platform === 'deepseek' || platform === 'minimax' || platform === 'opencode_go'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CnBaseUrlPreset {
|
export interface CnBaseUrlPreset {
|
||||||
@@ -321,7 +409,7 @@ export const CN_BASE_URL_PRESETS: Record<CnProviderPlatform, CnBaseUrlPreset[]>
|
|||||||
/** 返回指定供应商 + 账号类型 + API 协议的默认 base url。 */
|
/** 返回指定供应商 + 账号类型 + API 协议的默认 base url。 */
|
||||||
export function defaultCNBaseUrl(
|
export function defaultCNBaseUrl(
|
||||||
platform: string,
|
platform: string,
|
||||||
mode: CnAccountMode,
|
mode: CnAccountMode | OpenCodeAccountMode,
|
||||||
protocol: CnApiProtocol = 'chat_completions'
|
protocol: CnApiProtocol = 'chat_completions'
|
||||||
): string {
|
): string {
|
||||||
if (protocol === 'anthropic') {
|
if (protocol === 'anthropic') {
|
||||||
@@ -334,6 +422,8 @@ export function defaultCNBaseUrl(
|
|||||||
return 'https://api.deepseek.com/anthropic'
|
return 'https://api.deepseek.com/anthropic'
|
||||||
case 'minimax':
|
case 'minimax':
|
||||||
return 'https://api.minimaxi.com/anthropic'
|
return 'https://api.minimaxi.com/anthropic'
|
||||||
|
case 'opencode_go':
|
||||||
|
return mode === 'zen' ? OPENCODE_ZEN_ANTHROPIC_BASE_URL : OPENCODE_GO_ANTHROPIC_BASE_URL
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -350,6 +440,8 @@ export function defaultCNBaseUrl(
|
|||||||
return 'https://api.deepseek.com'
|
return 'https://api.deepseek.com'
|
||||||
case 'minimax':
|
case 'minimax':
|
||||||
return 'https://api.minimaxi.com/v1'
|
return 'https://api.minimaxi.com/v1'
|
||||||
|
case 'opencode_go':
|
||||||
|
return mode === 'zen' ? OPENCODE_ZEN_BASE_URL : OPENCODE_GO_BASE_URL
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@@ -357,8 +449,8 @@ export function defaultCNBaseUrl(
|
|||||||
|
|
||||||
/** 返回自适应模式下需要配置的原生协议及其默认端点。 */
|
/** 返回自适应模式下需要配置的原生协议及其默认端点。 */
|
||||||
export function defaultCNAdaptiveBaseUrls(
|
export function defaultCNAdaptiveBaseUrls(
|
||||||
platform: CnProviderPlatform,
|
platform: CnProviderPlatform | 'opencode_go',
|
||||||
mode: CnAccountMode
|
mode: CnAccountMode | OpenCodeAccountMode
|
||||||
): Record<CnNativeApiProtocol, string> {
|
): Record<CnNativeApiProtocol, string> {
|
||||||
return {
|
return {
|
||||||
chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
|
chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
|
||||||
@@ -372,6 +464,7 @@ export function defaultCNAdaptiveBaseUrls(
|
|||||||
// 共用,避免多处复制条件后一处改另一处漏改。
|
// 共用,避免多处复制条件后一处改另一处漏改。
|
||||||
|
|
||||||
export function cnQuotaCellVisible(platform: string, accountMode: string): boolean {
|
export function cnQuotaCellVisible(platform: string, accountMode: string): boolean {
|
||||||
|
if (platform === 'opencode_go') return accountMode !== 'zen'
|
||||||
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && accountMode === 'coding'
|
return (platform === 'kimi' || platform === 'zhipu' || platform === 'minimax') && accountMode === 'coding'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
PROVIDER_ZHIPU,
|
PROVIDER_ZHIPU,
|
||||||
PROVIDER_DEEPSEEK,
|
PROVIDER_DEEPSEEK,
|
||||||
PROVIDER_MINIMAX,
|
PROVIDER_MINIMAX,
|
||||||
|
PROVIDER_OPENCODE_GO,
|
||||||
} from '@/constants/channelMonitor'
|
} from '@/constants/channelMonitor'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -106,6 +107,7 @@ const providerFilterOptions = computed(() => [
|
|||||||
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
||||||
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
||||||
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
||||||
|
{ value: PROVIDER_OPENCODE_GO, label: t('monitorCommon.providers.opencode_go') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const enabledFilterOptions = computed(() => [
|
const enabledFilterOptions = computed(() => [
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ import {
|
|||||||
PROVIDER_ZHIPU,
|
PROVIDER_ZHIPU,
|
||||||
PROVIDER_DEEPSEEK,
|
PROVIDER_DEEPSEEK,
|
||||||
PROVIDER_MINIMAX,
|
PROVIDER_MINIMAX,
|
||||||
|
PROVIDER_OPENCODE_GO,
|
||||||
API_MODE_CHAT_COMPLETIONS,
|
API_MODE_CHAT_COMPLETIONS,
|
||||||
API_MODE_RESPONSES,
|
API_MODE_RESPONSES,
|
||||||
CHECK_MODE_PROBE,
|
CHECK_MODE_PROBE,
|
||||||
@@ -281,6 +282,7 @@ import {
|
|||||||
DEFAULT_ZHIPU_ENDPOINT,
|
DEFAULT_ZHIPU_ENDPOINT,
|
||||||
DEFAULT_DEEPSEEK_ENDPOINT,
|
DEFAULT_DEEPSEEK_ENDPOINT,
|
||||||
DEFAULT_MINIMAX_ENDPOINT,
|
DEFAULT_MINIMAX_ENDPOINT,
|
||||||
|
DEFAULT_OPENCODE_GO_ENDPOINT,
|
||||||
DEFAULT_INTERVAL_SECONDS,
|
DEFAULT_INTERVAL_SECONDS,
|
||||||
} from '@/constants/channelMonitor'
|
} from '@/constants/channelMonitor'
|
||||||
|
|
||||||
@@ -476,6 +478,7 @@ const providerOptions = computed<ProviderOption[]>(() => [
|
|||||||
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
||||||
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
||||||
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
||||||
|
{ value: PROVIDER_OPENCODE_GO, label: t('monitorCommon.providers.opencode_go') },
|
||||||
])
|
])
|
||||||
|
|
||||||
// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。
|
// 国产 provider 预填的官方 endpoint(仅探活侧;配额模式 endpoint 可留空)。
|
||||||
@@ -484,6 +487,7 @@ const PROVIDER_DEFAULT_ENDPOINTS: Partial<Record<Provider, string>> = {
|
|||||||
[PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT,
|
[PROVIDER_ZHIPU]: DEFAULT_ZHIPU_ENDPOINT,
|
||||||
[PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT,
|
[PROVIDER_DEEPSEEK]: DEFAULT_DEEPSEEK_ENDPOINT,
|
||||||
[PROVIDER_MINIMAX]: DEFAULT_MINIMAX_ENDPOINT,
|
[PROVIDER_MINIMAX]: DEFAULT_MINIMAX_ENDPOINT,
|
||||||
|
[PROVIDER_OPENCODE_GO]: DEFAULT_OPENCODE_GO_ENDPOINT,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckModeOption {
|
interface CheckModeOption {
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ import {
|
|||||||
PROVIDER_ZHIPU,
|
PROVIDER_ZHIPU,
|
||||||
PROVIDER_DEEPSEEK,
|
PROVIDER_DEEPSEEK,
|
||||||
PROVIDER_MINIMAX,
|
PROVIDER_MINIMAX,
|
||||||
|
PROVIDER_OPENCODE_GO,
|
||||||
PROVIDERS,
|
PROVIDERS,
|
||||||
API_MODE_CHAT_COMPLETIONS,
|
API_MODE_CHAT_COMPLETIONS,
|
||||||
API_MODE_RESPONSES,
|
API_MODE_RESPONSES,
|
||||||
@@ -280,6 +281,7 @@ const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
|
|||||||
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
{ value: PROVIDER_ZHIPU, label: t('monitorCommon.providers.zhipu') },
|
||||||
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
{ value: PROVIDER_DEEPSEEK, label: t('monitorCommon.providers.deepseek') },
|
||||||
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
{ value: PROVIDER_MINIMAX, label: t('monitorCommon.providers.minimax') },
|
||||||
|
{ value: PROVIDER_OPENCODE_GO, label: t('monitorCommon.providers.opencode_go') },
|
||||||
])
|
])
|
||||||
|
|
||||||
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
|
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
|
d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<!-- OpenCode official mark -->
|
||||||
|
<svg v-else-if="platform === 'opencode_go'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor" fill-rule="evenodd">
|
||||||
|
<path d="M16 6H8v12h8V6zm4 16H4V2h16v20z" />
|
||||||
|
</svg>
|
||||||
<!-- DeepSeek official logo mark (whale) -->
|
<!-- DeepSeek official logo mark (whale) -->
|
||||||
<svg v-else-if="platform === 'deepseek'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
<svg v-else-if="platform === 'deepseek'" :class="sizeClass" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1236,6 +1236,7 @@ function generateRoutedCodexFiles(
|
|||||||
zhipu: 'glm-4.7',
|
zhipu: 'glm-4.7',
|
||||||
deepseek: 'deepseek-v4-pro',
|
deepseek: 'deepseek-v4-pro',
|
||||||
minimax: 'MiniMax-M3',
|
minimax: 'MiniMax-M3',
|
||||||
|
opencode_go: 'glm-5.3',
|
||||||
composite: 'gpt-5.5'
|
composite: 'gpt-5.5'
|
||||||
}
|
}
|
||||||
const preferredModel = preferredModels[platform] || ''
|
const preferredModel = preferredModels[platform] || ''
|
||||||
@@ -1250,6 +1251,7 @@ function generateRoutedCodexFiles(
|
|||||||
zhipu: 'Zhipu',
|
zhipu: 'Zhipu',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
minimax: 'MiniMax',
|
minimax: 'MiniMax',
|
||||||
|
opencode_go: 'OpenCode',
|
||||||
composite: 'Composite'
|
composite: 'Composite'
|
||||||
}
|
}
|
||||||
const label = labels[platform]
|
const label = labels[platform]
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const PROVIDER_TINT: Record<string, string> = {
|
|||||||
kimi: 'text-pink-600 dark:text-pink-300',
|
kimi: 'text-pink-600 dark:text-pink-300',
|
||||||
zhipu: 'text-indigo-600 dark:text-indigo-300',
|
zhipu: 'text-indigo-600 dark:text-indigo-300',
|
||||||
deepseek: 'text-teal-600 dark:text-teal-300',
|
deepseek: 'text-teal-600 dark:text-teal-300',
|
||||||
|
opencode_go: 'text-amber-700 dark:text-amber-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ const PROVIDER_ICONS: Record<Provider, IconData> = {
|
|||||||
'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
|
'M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
opencode_go: {
|
||||||
|
paths: [
|
||||||
|
'M16 6H8v12h8V6zm4 16H4V2h16v20z',
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
PROVIDER_ZHIPU,
|
PROVIDER_ZHIPU,
|
||||||
PROVIDER_DEEPSEEK,
|
PROVIDER_DEEPSEEK,
|
||||||
PROVIDER_MINIMAX,
|
PROVIDER_MINIMAX,
|
||||||
|
PROVIDER_OPENCODE_GO,
|
||||||
PROVIDERS,
|
PROVIDERS,
|
||||||
STATUS_OPERATIONAL,
|
STATUS_OPERATIONAL,
|
||||||
STATUS_DEGRADED,
|
STATUS_DEGRADED,
|
||||||
@@ -117,6 +118,8 @@ export function useChannelMonitorFormat() {
|
|||||||
return 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300'
|
return 'bg-teal-100 text-teal-700 dark:bg-teal-500/15 dark:text-teal-300'
|
||||||
case PROVIDER_MINIMAX:
|
case PROVIDER_MINIMAX:
|
||||||
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300'
|
return 'bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300'
|
||||||
|
case PROVIDER_OPENCODE_GO:
|
||||||
|
return 'bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300'
|
||||||
default:
|
default:
|
||||||
return NEUTRAL_BADGE
|
return NEUTRAL_BADGE
|
||||||
}
|
}
|
||||||
@@ -181,6 +184,10 @@ export function useChannelMonitorFormat() {
|
|||||||
return active
|
return active
|
||||||
? 'border-rose-500 bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-400'
|
? 'border-rose-500 bg-rose-50 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300 dark:border-rose-400'
|
||||||
: 'border-gray-200 bg-white text-gray-600 hover:border-rose-300 hover:text-rose-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-rose-500/50'
|
: 'border-gray-200 bg-white text-gray-600 hover:border-rose-300 hover:text-rose-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-rose-500/50'
|
||||||
|
case PROVIDER_OPENCODE_GO:
|
||||||
|
return active
|
||||||
|
? 'border-amber-500 bg-amber-50 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-400'
|
||||||
|
: 'border-gray-200 bg-white text-gray-600 hover:border-amber-300 hover:text-amber-700 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400 dark:hover:border-amber-500/50'
|
||||||
default:
|
default:
|
||||||
return active
|
return active
|
||||||
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
|
? 'border-gray-400 bg-gray-50 text-gray-700 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-200'
|
||||||
@@ -267,6 +274,8 @@ export function providerGradient(provider: string): string {
|
|||||||
return 'bg-gradient-to-br from-teal-50 to-teal-100 dark:from-teal-500/10 dark:to-teal-500/20'
|
return 'bg-gradient-to-br from-teal-50 to-teal-100 dark:from-teal-500/10 dark:to-teal-500/20'
|
||||||
case PROVIDER_MINIMAX:
|
case PROVIDER_MINIMAX:
|
||||||
return 'bg-gradient-to-br from-rose-50 to-rose-100 dark:from-rose-500/10 dark:to-rose-500/20'
|
return 'bg-gradient-to-br from-rose-50 to-rose-100 dark:from-rose-500/10 dark:to-rose-500/20'
|
||||||
|
case PROVIDER_OPENCODE_GO:
|
||||||
|
return 'bg-gradient-to-br from-amber-50 to-amber-100 dark:from-amber-500/10 dark:to-amber-500/20'
|
||||||
default:
|
default:
|
||||||
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
return 'bg-gradient-to-br from-gray-100 to-gray-200 dark:from-dark-700 dark:to-dark-600'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -453,6 +453,18 @@ export function getModelsByPlatform(platform: string): string[] {
|
|||||||
case 'yi': return yiModels
|
case 'yi': return yiModels
|
||||||
case 'moonshot':
|
case 'moonshot':
|
||||||
case 'kimi': return moonshotModels
|
case 'kimi': return moonshotModels
|
||||||
|
case 'opencode_go': return [
|
||||||
|
'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'
|
||||||
|
]
|
||||||
case 'doubao': return doubaoModels
|
case 'doubao': return doubaoModels
|
||||||
case 'minimax': return minimaxModels
|
case 'minimax': return minimaxModels
|
||||||
case 'baidu': return baiduModels
|
case 'baidu': return baiduModels
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const concretePlatforms = [
|
|||||||
'kimi',
|
'kimi',
|
||||||
'zhipu',
|
'zhipu',
|
||||||
'deepseek',
|
'deepseek',
|
||||||
'minimax'
|
'minimax',
|
||||||
|
'opencode_go'
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('platform option catalogs', () => {
|
describe('platform option catalogs', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const PROVIDER_KIMI: Provider = 'kimi'
|
|||||||
export const PROVIDER_ZHIPU: Provider = 'zhipu'
|
export const PROVIDER_ZHIPU: Provider = 'zhipu'
|
||||||
export const PROVIDER_DEEPSEEK: Provider = 'deepseek'
|
export const PROVIDER_DEEPSEEK: Provider = 'deepseek'
|
||||||
export const PROVIDER_MINIMAX: Provider = 'minimax'
|
export const PROVIDER_MINIMAX: Provider = 'minimax'
|
||||||
|
export const PROVIDER_OPENCODE_GO: Provider = 'opencode_go'
|
||||||
|
|
||||||
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
|
export const DEFAULT_GROK_ENDPOINT = 'https://api.x.ai'
|
||||||
export const DEFAULT_GROK_MODEL = 'grok-4.5'
|
export const DEFAULT_GROK_MODEL = 'grok-4.5'
|
||||||
@@ -27,6 +28,7 @@ export const DEFAULT_KIMI_ENDPOINT = 'https://api.moonshot.cn'
|
|||||||
export const DEFAULT_ZHIPU_ENDPOINT = 'https://open.bigmodel.cn'
|
export const DEFAULT_ZHIPU_ENDPOINT = 'https://open.bigmodel.cn'
|
||||||
export const DEFAULT_DEEPSEEK_ENDPOINT = 'https://api.deepseek.com'
|
export const DEFAULT_DEEPSEEK_ENDPOINT = 'https://api.deepseek.com'
|
||||||
export const DEFAULT_MINIMAX_ENDPOINT = 'https://api.minimaxi.com'
|
export const DEFAULT_MINIMAX_ENDPOINT = 'https://api.minimaxi.com'
|
||||||
|
export const DEFAULT_OPENCODE_GO_ENDPOINT = 'https://opencode.ai/zen/go/v1'
|
||||||
|
|
||||||
export const CHECK_MODE_PROBE: CheckMode = 'probe'
|
export const CHECK_MODE_PROBE: CheckMode = 'probe'
|
||||||
export const CHECK_MODE_QUOTA: CheckMode = 'quota'
|
export const CHECK_MODE_QUOTA: CheckMode = 'quota'
|
||||||
@@ -45,6 +47,7 @@ export const PROVIDERS: readonly Provider[] = [
|
|||||||
PROVIDER_ZHIPU,
|
PROVIDER_ZHIPU,
|
||||||
PROVIDER_DEEPSEEK,
|
PROVIDER_DEEPSEEK,
|
||||||
PROVIDER_MINIMAX,
|
PROVIDER_MINIMAX,
|
||||||
|
PROVIDER_OPENCODE_GO,
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 仅支持配额模式(无探活 adapter)的 provider。 */
|
/** 仅支持配额模式(无探活 adapter)的 provider。 */
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export const CONCRETE_PLATFORM_OPTIONS = [
|
|||||||
{ value: 'kimi', label: 'Kimi' },
|
{ value: 'kimi', label: 'Kimi' },
|
||||||
{ value: 'zhipu', label: 'Zhipu GLM' },
|
{ value: 'zhipu', label: 'Zhipu GLM' },
|
||||||
{ value: 'deepseek', label: 'DeepSeek' },
|
{ value: 'deepseek', label: 'DeepSeek' },
|
||||||
{ value: 'minimax', label: 'MiniMax' }
|
{ value: 'minimax', label: 'MiniMax' },
|
||||||
|
{ value: 'opencode_go', label: 'OpenCode' }
|
||||||
] as const satisfies readonly PlatformOption<AccountPlatform>[]
|
] as const satisfies readonly PlatformOption<AccountPlatform>[]
|
||||||
|
|
||||||
/** Platforms that can own a group. */
|
/** Platforms that can own a group. */
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export default {
|
|||||||
zhipu: 'Zhipu GLM',
|
zhipu: 'Zhipu GLM',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
minimax: 'MiniMax',
|
minimax: 'MiniMax',
|
||||||
|
opencode_go: 'OpenCode',
|
||||||
},
|
},
|
||||||
cnProviders: {
|
cnProviders: {
|
||||||
accountMode: {
|
accountMode: {
|
||||||
@@ -149,12 +150,30 @@ export default {
|
|||||||
balance: 'Balance --',
|
balance: 'Balance --',
|
||||||
window5h: '5h',
|
window5h: '5h',
|
||||||
windowWeekly: '7d',
|
windowWeekly: '7d',
|
||||||
|
windowMonthly: '30d',
|
||||||
probe: 'Query',
|
probe: 'Query',
|
||||||
probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage',
|
probeTooltip: 'Query the provider quota endpoint for 5-hour / weekly rolling window usage',
|
||||||
balanceProbeTooltip: 'Query the provider balance endpoint for the account balance',
|
balanceProbeTooltip: 'Query the provider balance endpoint for the account balance',
|
||||||
balanceLow: 'Insufficient balance',
|
balanceLow: 'Insufficient balance',
|
||||||
noBalanceEndpoint: 'This platform has no balance query endpoint',
|
noBalanceEndpoint: 'This platform has no balance query endpoint',
|
||||||
},
|
},
|
||||||
|
opencodeGo: {
|
||||||
|
accountMode: {
|
||||||
|
zen: 'Zen',
|
||||||
|
zenDesc: 'Pay-as-you-go gateway. Consumes account credits, billed per token.',
|
||||||
|
go: 'GO',
|
||||||
|
goDesc: 'Subscription gateway, rate-limited by 5-hour / weekly / monthly usage windows.',
|
||||||
|
},
|
||||||
|
protocolRules: {
|
||||||
|
title: 'Model protocol routing',
|
||||||
|
hint: 'In adaptive mode, each model is sent to a native upstream protocol. Use an exact ID or a trailing * glob (e.g. grok-*, qwen*). The first matching rule wins; unmatched models use Chat Completions.',
|
||||||
|
patternPlaceholder: 'grok-* or deepseek-v4-flash',
|
||||||
|
add: 'Add rule',
|
||||||
|
remove: 'Remove rule',
|
||||||
|
restoreDefaults: 'Restore defaults',
|
||||||
|
fallback: 'Unmatched models → Chat Completions (/v1/chat/completions)',
|
||||||
|
},
|
||||||
|
},
|
||||||
types: {
|
types: {
|
||||||
oauth: 'OAuth',
|
oauth: 'OAuth',
|
||||||
chatgptOauth: 'ChatGPT OAuth',
|
chatgptOauth: 'ChatGPT OAuth',
|
||||||
|
|||||||
@@ -983,6 +983,7 @@ export default {
|
|||||||
zhipu: 'Zhipu GLM',
|
zhipu: 'Zhipu GLM',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
minimax: 'MiniMax',
|
minimax: 'MiniMax',
|
||||||
|
opencode_go: 'OpenCode',
|
||||||
composite: 'Composite',
|
composite: 'Composite',
|
||||||
},
|
},
|
||||||
deleteConfirm:
|
deleteConfirm:
|
||||||
|
|||||||
@@ -473,7 +473,8 @@ export default {
|
|||||||
kimi: 'Kimi',
|
kimi: 'Kimi',
|
||||||
zhipu: 'Zhipu GLM',
|
zhipu: 'Zhipu GLM',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
minimax: 'MiniMax'
|
minimax: 'MiniMax',
|
||||||
|
opencode_go: 'OpenCode'
|
||||||
},
|
},
|
||||||
// Check modes (how a monitor performs its checks)
|
// Check modes (how a monitor performs its checks)
|
||||||
checkMode: {
|
checkMode: {
|
||||||
@@ -490,6 +491,7 @@ export default {
|
|||||||
'7dSonnet': '7d Sonnet',
|
'7dSonnet': '7d Sonnet',
|
||||||
'7dFable': '7d Fable',
|
'7dFable': '7d Fable',
|
||||||
weekly: 'Weekly',
|
weekly: 'Weekly',
|
||||||
|
monthly: 'Monthly',
|
||||||
daily: 'Daily',
|
daily: 'Daily',
|
||||||
'30d': '30d',
|
'30d': '30d',
|
||||||
total: 'Total'
|
total: 'Total'
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ export default {
|
|||||||
zhipu: 'Zhipu GLM',
|
zhipu: 'Zhipu GLM',
|
||||||
deepseek: 'DeepSeek',
|
deepseek: 'DeepSeek',
|
||||||
minimax: 'MiniMax',
|
minimax: 'MiniMax',
|
||||||
|
opencode_go: 'OpenCode',
|
||||||
},
|
},
|
||||||
cnProviders: {
|
cnProviders: {
|
||||||
accountMode: {
|
accountMode: {
|
||||||
@@ -352,12 +353,30 @@ export default {
|
|||||||
balance: '余额 --',
|
balance: '余额 --',
|
||||||
window5h: '5h',
|
window5h: '5h',
|
||||||
windowWeekly: '7d',
|
windowWeekly: '7d',
|
||||||
|
windowMonthly: '月',
|
||||||
probe: '查询',
|
probe: '查询',
|
||||||
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
|
probeTooltip: '请求供应商额度端点,查询 5 小时 / 每周滚动窗口用量',
|
||||||
balanceProbeTooltip: '请求供应商余额端点,查询账户余额',
|
balanceProbeTooltip: '请求供应商余额端点,查询账户余额',
|
||||||
balanceLow: '余额不足',
|
balanceLow: '余额不足',
|
||||||
noBalanceEndpoint: '该平台暂无余额查询接口',
|
noBalanceEndpoint: '该平台暂无余额查询接口',
|
||||||
},
|
},
|
||||||
|
opencodeGo: {
|
||||||
|
accountMode: {
|
||||||
|
zen: 'Zen',
|
||||||
|
zenDesc: '按量付费网关,消耗账户余额,按 Token 计费。',
|
||||||
|
go: 'GO',
|
||||||
|
goDesc: '订阅制网关,按 5 小时 / 周 / 月滚动用量窗口限流。',
|
||||||
|
},
|
||||||
|
protocolRules: {
|
||||||
|
title: '模型协议分流',
|
||||||
|
hint: '自适应模式下按模型匹配上游协议。支持精确 ID 或末尾 * 通配(如 grok-*、qwen*);自上而下第一条命中生效;未命中走 Chat Completions。',
|
||||||
|
patternPlaceholder: 'grok-* 或 deepseek-v4-flash',
|
||||||
|
add: '添加规则',
|
||||||
|
remove: '删除规则',
|
||||||
|
restoreDefaults: '恢复默认',
|
||||||
|
fallback: '未命中以上规则 → Chat Completions(/v1/chat/completions)',
|
||||||
|
},
|
||||||
|
},
|
||||||
types: {
|
types: {
|
||||||
oauth: 'OAuth',
|
oauth: 'OAuth',
|
||||||
chatgptOauth: 'ChatGPT OAuth',
|
chatgptOauth: 'ChatGPT OAuth',
|
||||||
|
|||||||
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user