feat(model):responses协议和chat的优化处理
This commit is contained in:
1 parent
b5a1770018
commit
bf7123148e
25 files changed
+849
-205
No files matched your search
@@ -40,7 +40,6 @@ type ChannelItem struct {
|
||||
Remark string `json:"remark"`
|
||||
IsVIP bool `json:"is_vip"`
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
UpstreamResponses bool `json:"upstream_responses" dc:"上游使用 OpenAI Responses 协议(/v1/responses)"`
|
||||
SharingThreshold *float64 `json:"sharing_threshold"`
|
||||
PreemptionThreshold *float64 `json:"preemption_threshold"`
|
||||
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
|
||||
@@ -66,7 +65,6 @@ type ChannelCreateReq struct {
|
||||
Remark string `json:"remark" dc:"备注"`
|
||||
IsVIP bool `json:"is_vip" d:"false" dc:"是否VIP专属渠道"`
|
||||
UseProxy bool `json:"use_proxy" d:"false" dc:"启用代理"`
|
||||
UpstreamResponses bool `json:"upstream_responses" d:"false" dc:"上游使用 OpenAI Responses 协议(/v1/responses),responses 入站直连转发"`
|
||||
SharingThreshold float64 `json:"sharing_threshold" d:"0.6" dc:"普通租户借用阈值"`
|
||||
PreemptionThreshold float64 `json:"preemption_threshold" d:"0.8" dc:"VIP抢占阈值"`
|
||||
BorrowingCooldownSeconds int `json:"borrowing_cooldown_seconds" d:"30" dc:"被抢占后冷却时间(秒)"`
|
||||
@@ -95,7 +93,6 @@ type ChannelUpdateReq struct {
|
||||
StrictCapacity *bool `json:"strict_capacity" dc:"严格容量(fail-closed)"`
|
||||
IsVIP *bool `json:"is_vip" dc:"是否VIP专属渠道"`
|
||||
UseProxy *bool `json:"use_proxy" dc:"启用代理"`
|
||||
UpstreamResponses *bool `json:"upstream_responses" dc:"上游使用 OpenAI Responses 协议(/v1/responses)"`
|
||||
SharingThreshold *float64 `json:"sharing_threshold" dc:"普通租户借用阈值"`
|
||||
PreemptionThreshold *float64 `json:"preemption_threshold" dc:"VIP抢占阈值"`
|
||||
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds" dc:"被抢占后冷却时间(秒)"`
|
||||
@@ -130,7 +127,6 @@ type ChannelDetailRes struct {
|
||||
Remark string `json:"remark"`
|
||||
IsVIP bool `json:"is_vip"`
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
UpstreamResponses bool `json:"upstream_responses" dc:"上游使用 OpenAI Responses 协议(/v1/responses)"`
|
||||
SharingThreshold *float64 `json:"sharing_threshold"`
|
||||
PreemptionThreshold *float64 `json:"preemption_threshold"`
|
||||
BorrowingCooldownSeconds *int `json:"borrowing_cooldown_seconds"`
|
||||
@@ -177,11 +173,13 @@ type ChannelAbilityBatchReq struct {
|
||||
|
||||
// AbilityItem 模型能力项
|
||||
type AbilityItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ModelName string `json:"model_name" v:"required" dc:"平台标准模型名"`
|
||||
UpstreamModel string `json:"upstream_model" dc:"上游实际模型名"`
|
||||
Enabled bool `json:"enabled" d:"true" dc:"是否启用"`
|
||||
CostRatio float64 `json:"cost_ratio" d:"1" v:"between:0,100" dc:"成本比例:上游实际价/平台基准价,1.0=等价(参与调度 costFactor)"`
|
||||
ID int64 `json:"id"`
|
||||
ModelName string `json:"model_name" v:"required" dc:"平台标准模型名"`
|
||||
UpstreamModel string `json:"upstream_model" dc:"上游实际模型名"`
|
||||
Enabled bool `json:"enabled" d:"true" dc:"是否启用"`
|
||||
CostRatio float64 `json:"cost_ratio" d:"1" v:"between:0,100" dc:"成本比例:上游实际价/平台基准价,1.0=等价(参与调度 costFactor)"`
|
||||
SupportsResponses bool `json:"supports_responses" d:"false" dc:"支持 OpenAI Responses 协议(/v1/responses 原生直连,responses 入站软偏好)"`
|
||||
ChatViaResponses bool `json:"chat_via_responses" d:"false" dc:"上游仅有 Responses 协议(responses-only),chat 入站经桥接发送 /v1/responses"`
|
||||
}
|
||||
|
||||
// ProviderDefaultURLReq 获取供应商默认 URL
|
||||
|
||||
@@ -21,26 +21,30 @@ type ChnAbilitiesDao struct {
|
||||
|
||||
// ChnAbilitiesColumns defines and stores column names for the table chn_abilities.
|
||||
type ChnAbilitiesColumns struct {
|
||||
Id string // 主键ID
|
||||
ChannelId string // 关联渠道ID
|
||||
ModelName string // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel string // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled string // 是否启用该模型能力
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
CostRatio string // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
Id string // 主键ID
|
||||
ChannelId string // 关联渠道ID
|
||||
ModelName string // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel string // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled string // 是否启用该模型能力
|
||||
CreatedAt string // 创建时间
|
||||
UpdatedAt string // 更新时间
|
||||
CostRatio string // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
SupportsResponses string // 模型在该渠道支持 OpenAI Responses 协议(/v1/responses),responses 入站直连转发并获调度软偏好
|
||||
ChatViaResponses string // 上游仅有 Responses 协议(responses-only 上游),chat 入站经桥接转换后发送 /v1/responses
|
||||
}
|
||||
|
||||
// chnAbilitiesColumns holds the columns for the table chn_abilities.
|
||||
var chnAbilitiesColumns = ChnAbilitiesColumns{
|
||||
Id: "id",
|
||||
ChannelId: "channel_id",
|
||||
ModelName: "model_name",
|
||||
UpstreamModel: "upstream_model",
|
||||
Enabled: "enabled",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
CostRatio: "cost_ratio",
|
||||
Id: "id",
|
||||
ChannelId: "channel_id",
|
||||
ModelName: "model_name",
|
||||
UpstreamModel: "upstream_model",
|
||||
Enabled: "enabled",
|
||||
CreatedAt: "created_at",
|
||||
UpdatedAt: "updated_at",
|
||||
CostRatio: "cost_ratio",
|
||||
SupportsResponses: "supports_responses",
|
||||
ChatViaResponses: "chat_via_responses",
|
||||
}
|
||||
|
||||
// NewChnAbilitiesDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -21,33 +21,37 @@ const (
|
||||
|
||||
// ChannelMeta 转发所需的渠道元数据(调度核心不关心,handler 构造 ChannelSelection 用)。
|
||||
type ChannelMeta struct {
|
||||
ChannelID int64
|
||||
ChannelName string
|
||||
ChannelType int
|
||||
BaseURL string
|
||||
UpstreamModel string
|
||||
IsModelMapped bool
|
||||
Settings string
|
||||
MaxConcurrency int
|
||||
StrictCapacity bool
|
||||
Tier string
|
||||
ChannelID int64
|
||||
ChannelName string
|
||||
ChannelType int
|
||||
BaseURL string
|
||||
UpstreamModel string
|
||||
IsModelMapped bool
|
||||
Settings string
|
||||
MaxConcurrency int
|
||||
StrictCapacity bool
|
||||
Tier string
|
||||
SupportsResponses bool // 模型在该渠道支持 /v1/responses(responses 入站直连 + 调度软偏好)
|
||||
ChatViaResponses bool // responses-only 上游:chat 入站经桥接发送 /v1/responses
|
||||
}
|
||||
|
||||
// catalogRow 目录加载的原始行(渠道×模型)。
|
||||
type catalogRow struct {
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelType int `json:"channel_type"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Weight int `json:"weight"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
Settings string `json:"settings"`
|
||||
Tier string `json:"tier"`
|
||||
StrictCapacity bool `json:"strict_capacity"`
|
||||
CostRatio float64 `json:"cost_ratio"`
|
||||
CreatedAtMs int64 `json:"created_at_ms"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
ChannelName string `json:"channel_name"`
|
||||
ChannelType int `json:"channel_type"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Weight int `json:"weight"`
|
||||
MaxConcurrency int `json:"max_concurrency"`
|
||||
Settings string `json:"settings"`
|
||||
Tier string `json:"tier"`
|
||||
StrictCapacity bool `json:"strict_capacity"`
|
||||
CostRatio float64 `json:"cost_ratio"`
|
||||
CreatedAtMs int64 `json:"created_at_ms"`
|
||||
SupportsResponses bool `json:"supports_responses"`
|
||||
ChatViaResponses bool `json:"chat_via_responses"`
|
||||
}
|
||||
|
||||
// catalogData 一次完整加载的目录数据。
|
||||
@@ -294,20 +298,22 @@ func (c *Catalog) Rebuild(ctx context.Context) {
|
||||
}
|
||||
|
||||
ch := dispatch.Channel{
|
||||
ID: row.ChannelID,
|
||||
Name: row.ChannelName,
|
||||
Tier: dispatch.Tier(row.Tier),
|
||||
BaseWeight: float64(row.Weight),
|
||||
CostRatio: row.CostRatio,
|
||||
SuccEwma: rt.SuccEwma,
|
||||
LatEwmaMs: rt.LatEwmaMs,
|
||||
Inflight: rt.Inflight,
|
||||
SoftLimit: effectiveSoftLimit(row.MaxConcurrency, rt.Onset429Ewma),
|
||||
Breaker: rt.Breaker,
|
||||
ModelBreaker: rt.ModelBreaker,
|
||||
RampElapsedMs: rampElapsed(now, rt.RecoveredMs, row.CreatedAtMs, rampWindowMs),
|
||||
KeyIDs: data.keysByChannel[row.ChannelID],
|
||||
StrictCapacity: row.StrictCapacity,
|
||||
ID: row.ChannelID,
|
||||
Name: row.ChannelName,
|
||||
Tier: dispatch.Tier(row.Tier),
|
||||
BaseWeight: float64(row.Weight),
|
||||
CostRatio: row.CostRatio,
|
||||
SuccEwma: rt.SuccEwma,
|
||||
LatEwmaMs: rt.LatEwmaMs,
|
||||
Inflight: rt.Inflight,
|
||||
SoftLimit: effectiveSoftLimit(row.MaxConcurrency, rt.Onset429Ewma),
|
||||
Breaker: rt.Breaker,
|
||||
ModelBreaker: rt.ModelBreaker,
|
||||
RampElapsedMs: rampElapsed(now, rt.RecoveredMs, row.CreatedAtMs, rampWindowMs),
|
||||
KeyIDs: data.keysByChannel[row.ChannelID],
|
||||
StrictCapacity: row.StrictCapacity,
|
||||
SupportsResponses: row.SupportsResponses,
|
||||
ChatViaResponses: row.ChatViaResponses,
|
||||
}
|
||||
idx.byModel[row.ModelName] = append(idx.byModel[row.ModelName], ch)
|
||||
idx.channelModels[row.ChannelID] = append(idx.channelModels[row.ChannelID], row.ModelName)
|
||||
@@ -320,16 +326,18 @@ func (c *Catalog) Rebuild(ctx context.Context) {
|
||||
upstream = row.ModelName
|
||||
}
|
||||
idx.meta[row.ChannelID][row.ModelName] = ChannelMeta{
|
||||
ChannelID: row.ChannelID,
|
||||
ChannelName: row.ChannelName,
|
||||
ChannelType: row.ChannelType,
|
||||
BaseURL: row.BaseURL,
|
||||
UpstreamModel: upstream,
|
||||
IsModelMapped: row.UpstreamModel != "" && row.UpstreamModel != row.ModelName,
|
||||
Settings: row.Settings,
|
||||
MaxConcurrency: row.MaxConcurrency,
|
||||
StrictCapacity: row.StrictCapacity,
|
||||
Tier: row.Tier,
|
||||
ChannelID: row.ChannelID,
|
||||
ChannelName: row.ChannelName,
|
||||
ChannelType: row.ChannelType,
|
||||
BaseURL: row.BaseURL,
|
||||
UpstreamModel: upstream,
|
||||
IsModelMapped: row.UpstreamModel != "" && row.UpstreamModel != row.ModelName,
|
||||
Settings: row.Settings,
|
||||
MaxConcurrency: row.MaxConcurrency,
|
||||
StrictCapacity: row.StrictCapacity,
|
||||
Tier: row.Tier,
|
||||
SupportsResponses: row.SupportsResponses,
|
||||
ChatViaResponses: row.ChatViaResponses,
|
||||
}
|
||||
if row.StrictCapacity {
|
||||
idx.strict[row.ChannelID] = row.MaxConcurrency
|
||||
@@ -379,6 +387,7 @@ func loadCatalogFromDB(ctx context.Context) (*catalogData, error) {
|
||||
Fields("c.id as channel_id, c.name as channel_name, c.type as channel_type, c.base_url, " +
|
||||
"a.model_name, a.upstream_model, c.weight, c.max_concurrency, c.settings, " +
|
||||
"c.tier, c.strict_capacity, a.cost_ratio, " +
|
||||
"a.supports_responses, a.chat_via_responses, " +
|
||||
"(EXTRACT(EPOCH FROM c.created_at) * 1000)::bigint as created_at_ms").
|
||||
Scan(&rows)
|
||||
if err != nil {
|
||||
|
||||
@@ -139,7 +139,6 @@ func (s *sAdmin) ListChannels(ctx context.Context, req *v1.ChannelListReq) (*v1.
|
||||
Remark: ch.Remark,
|
||||
IsVIP: ch.IsVIP,
|
||||
UseProxy: settings.UseProxy,
|
||||
UpstreamResponses: settings.UpstreamResponses,
|
||||
SharingThreshold: ch.SharingThreshold,
|
||||
PreemptionThreshold: ch.PreemptionThreshold,
|
||||
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
|
||||
@@ -241,12 +240,15 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1
|
||||
|
||||
// 克隆 abilities
|
||||
var abilities []struct {
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CostRatio float64 `json:"cost_ratio"`
|
||||
SupportsResponses bool `json:"supports_responses"`
|
||||
ChatViaResponses bool `json:"chat_via_responses"`
|
||||
}
|
||||
err = dao.ChnAbilities.Ctx(ctx).
|
||||
Fields("model_name, upstream_model, enabled").
|
||||
Fields("model_name, upstream_model, enabled, cost_ratio, supports_responses, chat_via_responses").
|
||||
Where("channel_id", req.ID).
|
||||
Scan(&abilities)
|
||||
if err != nil {
|
||||
@@ -254,10 +256,13 @@ func (s *sAdmin) CloneChannel(ctx context.Context, req *v1.ChannelCloneReq) (*v1
|
||||
}
|
||||
for _, ab := range abilities {
|
||||
_, err := dao.ChnAbilities.Ctx(ctx).Insert(do.ChnAbilities{
|
||||
ChannelId: newID,
|
||||
ModelName: ab.ModelName,
|
||||
UpstreamModel: ab.UpstreamModel,
|
||||
Enabled: ab.Enabled,
|
||||
ChannelId: newID,
|
||||
ModelName: ab.ModelName,
|
||||
UpstreamModel: ab.UpstreamModel,
|
||||
Enabled: ab.Enabled,
|
||||
CostRatio: ab.CostRatio,
|
||||
SupportsResponses: ab.SupportsResponses,
|
||||
ChatViaResponses: ab.ChatViaResponses,
|
||||
})
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "clone ability %s for channel %d failed: %v", ab.ModelName, newID, err)
|
||||
@@ -280,15 +285,10 @@ func (s *sAdmin) CreateChannel(ctx context.Context, req *v1.ChannelCreateReq) (*
|
||||
baseURL = defaultProviderURL(req.Type)
|
||||
}
|
||||
|
||||
// Build settings JSON(use_proxy / upstream_responses 协议类开关)
|
||||
// Build settings JSON
|
||||
settingsJSON := "{}"
|
||||
if req.UseProxy || req.UpstreamResponses {
|
||||
settings := relay.DefaultChannelSettings()
|
||||
settings.UseProxy = req.UseProxy
|
||||
settings.UpstreamResponses = req.UpstreamResponses
|
||||
if b, err := json.Marshal(settings); err == nil {
|
||||
settingsJSON = string(b)
|
||||
}
|
||||
if req.UseProxy {
|
||||
settingsJSON = `{"use_proxy":true}`
|
||||
}
|
||||
|
||||
tier := req.Tier
|
||||
@@ -386,17 +386,12 @@ func (s *sAdmin) UpdateChannel(ctx context.Context, req *v1.ChannelUpdateReq) (*
|
||||
data.BorrowingCooldownSeconds = *req.BorrowingCooldownSeconds
|
||||
}
|
||||
|
||||
// Update protocol-related settings in settings JSONB
|
||||
if req.UseProxy != nil || req.UpstreamResponses != nil {
|
||||
// Update use_proxy in settings JSONB
|
||||
if req.UseProxy != nil {
|
||||
var currentSettings string
|
||||
_ = dao.ChnChannels.Ctx(ctx).Where("id", req.ID).Fields("settings").Scan(¤tSettings)
|
||||
settings := relay.ParseChannelSettings(currentSettings)
|
||||
if req.UseProxy != nil {
|
||||
settings.UseProxy = *req.UseProxy
|
||||
}
|
||||
if req.UpstreamResponses != nil {
|
||||
settings.UpstreamResponses = *req.UpstreamResponses
|
||||
}
|
||||
settings.UseProxy = *req.UseProxy
|
||||
if settingsJSON, err := json.Marshal(settings); err == nil {
|
||||
data.Settings = string(settingsJSON)
|
||||
}
|
||||
@@ -540,7 +535,6 @@ func (s *sAdmin) GetChannelDetail(ctx context.Context, req *v1.ChannelDetailReq)
|
||||
Remark: ch.Remark,
|
||||
IsVIP: ch.IsVIP,
|
||||
UseProxy: settings.UseProxy,
|
||||
UpstreamResponses: settings.UpstreamResponses,
|
||||
SharingThreshold: ch.SharingThreshold,
|
||||
PreemptionThreshold: ch.PreemptionThreshold,
|
||||
BorrowingCooldownSeconds: ch.BorrowingCooldownSeconds,
|
||||
@@ -595,11 +589,13 @@ func (s *sAdmin) SetChannelAbilities(ctx context.Context, req *v1.ChannelAbility
|
||||
costRatio = 1.0
|
||||
}
|
||||
if _, err := dao.ChnAbilities.Ctx(ctx).Insert(do.ChnAbilities{
|
||||
ChannelId: req.ChannelID,
|
||||
ModelName: ab.ModelName,
|
||||
UpstreamModel: ab.UpstreamModel,
|
||||
Enabled: ab.Enabled,
|
||||
CostRatio: costRatio,
|
||||
ChannelId: req.ChannelID,
|
||||
ModelName: ab.ModelName,
|
||||
UpstreamModel: ab.UpstreamModel,
|
||||
Enabled: ab.Enabled,
|
||||
CostRatio: costRatio,
|
||||
SupportsResponses: ab.SupportsResponses,
|
||||
ChatViaResponses: ab.ChatViaResponses,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -660,11 +656,13 @@ func (s *sAdmin) GetChannelKeys(ctx context.Context, req *v1.ChannelKeyListReq)
|
||||
// GetChannelAbilities 获取渠道模型能力列表
|
||||
func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbilitiesGetReq) (*v1.ChannelAbilitiesGetRes, error) {
|
||||
var abilities []struct {
|
||||
ID int64 `json:"id"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CostRatio float64 `json:"cost_ratio"`
|
||||
ID int64 `json:"id"`
|
||||
ModelName string `json:"model_name"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CostRatio float64 `json:"cost_ratio"`
|
||||
SupportsResponses bool `json:"supports_responses"`
|
||||
ChatViaResponses bool `json:"chat_via_responses"`
|
||||
}
|
||||
|
||||
err := dao.ChnAbilities.Ctx(ctx).
|
||||
@@ -678,11 +676,13 @@ func (s *sAdmin) GetChannelAbilities(ctx context.Context, req *v1.ChannelAbiliti
|
||||
list := make([]v1.AbilityItem, len(abilities))
|
||||
for i, a := range abilities {
|
||||
list[i] = v1.AbilityItem{
|
||||
ID: a.ID,
|
||||
ModelName: a.ModelName,
|
||||
UpstreamModel: a.UpstreamModel,
|
||||
Enabled: a.Enabled,
|
||||
CostRatio: a.CostRatio,
|
||||
ID: a.ID,
|
||||
ModelName: a.ModelName,
|
||||
UpstreamModel: a.UpstreamModel,
|
||||
Enabled: a.Enabled,
|
||||
CostRatio: a.CostRatio,
|
||||
SupportsResponses: a.SupportsResponses,
|
||||
ChatViaResponses: a.ChatViaResponses,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,13 +77,11 @@ func TestParseChannelSettings_FullOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChannelSettings_UpstreamResponses(t *testing.T) {
|
||||
// 未配置时默认 false
|
||||
if s := ParseChannelSettings(`{}`); s.UpstreamResponses {
|
||||
t.Error("UpstreamResponses should default to false")
|
||||
}
|
||||
// 开启后正确解析
|
||||
if s := ParseChannelSettings(`{"upstream_responses":true}`); !s.UpstreamResponses {
|
||||
t.Error("UpstreamResponses should parse true")
|
||||
func TestParseChannelSettings_IgnoresUnknownProtoKeys(t *testing.T) {
|
||||
// 协议能力已迁移到 chn_abilities(supports_responses / chat_via_responses),
|
||||
// 渠道 settings 不再有协议类字段:历史遗留的 upstream_responses 键应被静默忽略
|
||||
s := ParseChannelSettings(`{"upstream_responses":true,"chat_completions_via_responses":true}`)
|
||||
if s.PassThroughBodyEnabled {
|
||||
t.Error("unknown protocol keys must not map to any channel setting")
|
||||
}
|
||||
}
|
||||
@@ -993,6 +993,8 @@ func (p *DataProviderImpl) MaterializeSelection(ctx context.Context, channelID,
|
||||
IsModelMapped: meta.IsModelMapped,
|
||||
MaxConcurrency: meta.MaxConcurrency,
|
||||
Settings: ParseChannelSettings(meta.Settings),
|
||||
SupportsResponses: meta.SupportsResponses,
|
||||
ChatViaResponses: meta.ChatViaResponses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ import (
|
||||
|
||||
// ChnAbilities is the golang structure of table chn_abilities for DAO operations like Where/Data.
|
||||
type ChnAbilities struct {
|
||||
g.Meta `orm:"table:chn_abilities, do:true"`
|
||||
Id any // 主键ID
|
||||
ChannelId any // 关联渠道ID
|
||||
ModelName any // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel any // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled any // 是否启用该模型能力
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CostRatio any // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
g.Meta `orm:"table:chn_abilities, do:true"`
|
||||
Id any // 主键ID
|
||||
ChannelId any // 关联渠道ID
|
||||
ModelName any // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel any // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled any // 是否启用该模型能力
|
||||
CreatedAt *gtime.Time // 创建时间
|
||||
UpdatedAt *gtime.Time // 更新时间
|
||||
CostRatio any // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
SupportsResponses any // 模型在该渠道支持 OpenAI Responses 协议(/v1/responses),responses 入站直连转发并获调度软偏好
|
||||
ChatViaResponses any // 上游仅有 Responses 协议(responses-only 上游),chat 入站经桥接转换后发送 /v1/responses
|
||||
}
|
||||
@@ -11,12 +11,14 @@ import (
|
||||
|
||||
// ChnAbilities is the golang structure for table chn_abilities.
|
||||
type ChnAbilities struct {
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"关联渠道ID"` // 关联渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"平台标准模型名(用户请求使用的模型名)"` // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)"` // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用该模型能力"` // 是否启用该模型能力
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CostRatio decimal.Decimal `json:"cost_ratio" orm:"cost_ratio" description:"成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)"` // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID
|
||||
ChannelId int64 `json:"channel_id" orm:"channel_id" description:"关联渠道ID"` // 关联渠道ID
|
||||
ModelName string `json:"model_name" orm:"model_name" description:"平台标准模型名(用户请求使用的模型名)"` // 平台标准模型名(用户请求使用的模型名)
|
||||
UpstreamModel string `json:"upstream_model" orm:"upstream_model" description:"上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)"` // 上游实际模型名(与平台标准名不同时需要映射,如平台名 gpt-4 → 上游名 gpt-4-0314)
|
||||
Enabled bool `json:"enabled" orm:"enabled" description:"是否启用该模型能力"` // 是否启用该模型能力
|
||||
CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间
|
||||
UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间
|
||||
CostRatio decimal.Decimal `json:"cost_ratio" orm:"cost_ratio" description:"成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)"` // 成本比例:该渠道该模型上游实际价/平台基准价,1.0=等价,0.8=八折,参与调度 costFactor 计算(无量纲比例,非金额)
|
||||
SupportsResponses bool `json:"supports_responses" orm:"supports_responses" description:"模型在该渠道支持 OpenAI Responses 协议(/v1/responses),responses 入站直连转发并获调度软偏好"` // 模型在该渠道支持 OpenAI Responses 协议(/v1/responses),responses 入站直连转发并获调度软偏好
|
||||
ChatViaResponses bool `json:"chat_via_responses" orm:"chat_via_responses" description:"上游仅有 Responses 协议(responses-only 上游),chat 入站经桥接转换后发送 /v1/responses"` // 上游仅有 Responses 协议(responses-only 上游),chat 入站经桥接转换后发送 /v1/responses
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
-- 渠道×模型级协议能力标记(配合 Responses 端点支持):
|
||||
-- supports_responses:模型在该渠道支持 OpenAI Responses 协议(/v1/responses)。
|
||||
-- responses 入站请求命中时原样直连上游 /v1/responses;调度时对 responses 入站软偏好此类渠道。
|
||||
-- chat_via_responses:上游仅有 Responses 协议(responses-only,如 Codex 类中转),
|
||||
-- chat 入站请求经 chat→Responses 桥接发送到 /v1/responses。
|
||||
-- 两项均为渠道×模型粒度(非渠道级):同一渠道的不同模型协议支持可能不同。
|
||||
|
||||
ALTER TABLE chn_abilities ADD COLUMN supports_responses BOOLEAN DEFAULT false NOT NULL;
|
||||
COMMENT ON COLUMN chn_abilities.supports_responses IS '模型在该渠道支持 OpenAI Responses 协议(/v1/responses),responses 入站直连转发并获调度软偏好';
|
||||
|
||||
ALTER TABLE chn_abilities ADD COLUMN chat_via_responses BOOLEAN DEFAULT false NOT NULL;
|
||||
COMMENT ON COLUMN chn_abilities.chat_via_responses IS '上游仅有 Responses 协议(responses-only 上游),chat 入站经桥接转换后发送 /v1/responses';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE chn_abilities DROP COLUMN IF EXISTS supports_responses;
|
||||
ALTER TABLE chn_abilities DROP COLUMN IF EXISTS chat_via_responses;
|
||||
@@ -58,8 +58,8 @@ func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) {
|
||||
case constant.RelayModeRerank:
|
||||
return baseURL + "/v1/rerank", nil
|
||||
case constant.RelayModeResponses, constant.RelayModeResponsesCompact:
|
||||
// 渠道上游为 Responses 协议:直接打 /v1/responses 端点(响应按 Responses 格式原样透传)
|
||||
if info.ChannelMeta.Settings.UpstreamResponses {
|
||||
// 上游原生支持 Responses 协议:直接打 /v1/responses 端点(响应按 Responses 格式原样透传)
|
||||
if info.ChannelMeta.UpstreamSpeaksResponses() {
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeResponsesCompact {
|
||||
return baseURL + "/v1/responses/compact", nil
|
||||
}
|
||||
@@ -134,8 +134,8 @@ func (a *Adaptor) SetupRequestHeader(header http.Header, info *common.RelayInfo)
|
||||
// ConvertRequest 根据入站格式转换请求体为 OpenAI 格式,然后做 OpenAI 特有后处理。
|
||||
func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, requestBody []byte) (io.Reader, error) {
|
||||
mode := constant.RelayMode(info.RelayMode)
|
||||
// responses 入站 + 渠道声明上游为 Responses 协议:保持 Responses 格式直连,不做 chat 转换
|
||||
responsesUpstream := info.ChannelMeta.Settings.UpstreamResponses && info.InboundFormat == constant.RelayFormatResponses
|
||||
// responses 入站 + 上游原生支持 Responses 协议:保持 Responses 格式直连,不做 chat 转换
|
||||
responsesUpstream := info.ChannelMeta.UpstreamSpeaksResponses() && info.InboundFormat == constant.RelayFormatResponses
|
||||
|
||||
// Audio/Rerank: OpenAI 原生格式直接透传(不做 replaceModelIfNeeded 和 injectStreamOptions)
|
||||
switch mode {
|
||||
@@ -172,6 +172,25 @@ func (a *Adaptor) ConvertRequest(ctx context.Context, info *common.RelayInfo, re
|
||||
default:
|
||||
converted = bytes.NewReader(requestBody)
|
||||
}
|
||||
|
||||
// responses-only 上游桥接:chat 请求体转换为 Responses 格式发送 /v1/responses。
|
||||
// 转换器自行处理模型映射,并读取 chat 体的 reasoning_effort 映射为 reasoning.effort
|
||||
// (thinking 后缀先注入 chat 体再进转换器),故此分支直接返回、不走后续 chat 后处理。
|
||||
if info.UseResponsesAPI && mode == constant.RelayModeChatCompletions && !responsesUpstream {
|
||||
if info.ReasoningEffort != "" {
|
||||
converted = injectReasoningEffort(converted, info.ReasoningEffort)
|
||||
}
|
||||
body, err := io.ReadAll(converted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read chat body for responses bridge failed: %w", err)
|
||||
}
|
||||
r, err := ConvertOpenAIToResponses(body, info)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.NewReader(r), nil
|
||||
}
|
||||
|
||||
result := replaceModelIfNeeded(converted, info)
|
||||
// stream_options 是 Chat Completions 专属字段,GPT Image 使用 stream/partial_images 原生参数;
|
||||
// Responses 上游走原生 stream 字段,不注入 chat 专属 stream_options
|
||||
@@ -262,7 +281,7 @@ func (a *Adaptor) DoResponse(ctx context.Context, resp *http.Response, info *com
|
||||
}
|
||||
return handleGeminiInboundNonStream(ctx, resp, info, writer)
|
||||
case constant.RelayFormatResponses:
|
||||
if info.ChannelMeta.Settings.UpstreamResponses {
|
||||
if info.ChannelMeta.UpstreamSpeaksResponses() {
|
||||
// 上游为 Responses 协议:原样透传上游 Responses 响应
|
||||
if info.IsStream {
|
||||
return a.handleResponsesUpstreamStream(ctx, resp, info, writer)
|
||||
|
||||
@@ -3,6 +3,7 @@ package openai
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -27,9 +28,8 @@ func responsesUpstreamInfo(relayMode constant.RelayMode, isStream bool) *common.
|
||||
BaseURL: "https://upstream.example.com",
|
||||
UpstreamModelName: "gpt-4o-upstream",
|
||||
IsModelMapped: false,
|
||||
Settings: common.ChannelSettings{
|
||||
UpstreamResponses: true,
|
||||
},
|
||||
|
||||
SupportsResponses: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,8 @@ func TestAdaptor_GetRequestURL_ResponsesUpstream(t *testing.T) {
|
||||
// responses 入站仍转换到 /v1/chat/completions(chat-only 上游兜底)。
|
||||
func TestAdaptor_GetRequestURL_ResponsesChatFallback(t *testing.T) {
|
||||
info := responsesUpstreamInfo(constant.RelayModeResponses, false)
|
||||
info.ChannelMeta.Settings.UpstreamResponses = false
|
||||
info.ChannelMeta.SupportsResponses = false
|
||||
info.ChannelMeta.ChatViaResponses = false
|
||||
a := &Adaptor{}
|
||||
got, err := a.GetRequestURL(info)
|
||||
if err != nil {
|
||||
@@ -73,6 +74,40 @@ func TestAdaptor_GetRequestURL_ResponsesChatFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_GetRequestURL_ChatViaResponses responses-only 渠道(chat_via_responses):
|
||||
// responses 入站同样直连 /v1/responses(上游本来就说 Responses 协议),
|
||||
// chat 入站经 UseResponsesAPI 桥接也打 /v1/responses。
|
||||
func TestAdaptor_GetRequestURL_ChatViaResponses(t *testing.T) {
|
||||
a := &Adaptor{}
|
||||
|
||||
// responses 入站:仅勾 chat_via_responses(未勾 supports_responses)也应直连
|
||||
info := responsesUpstreamInfo(constant.RelayModeResponses, false)
|
||||
info.ChannelMeta.SupportsResponses = false
|
||||
info.ChannelMeta.ChatViaResponses = true
|
||||
got, err := a.GetRequestURL(info)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestURL error: %v", err)
|
||||
}
|
||||
if want := "https://upstream.example.com/v1/responses"; got != want {
|
||||
t.Errorf("responses inbound on chat_via_responses channel: URL = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// chat 入站 + 桥接标志:URL 走 /v1/responses
|
||||
chatInfo := responsesUpstreamInfo(constant.RelayModeChatCompletions, false)
|
||||
chatInfo.InboundFormat = constant.RelayFormatOpenAI
|
||||
chatInfo.ClientFormat = constant.RelayFormatOpenAI
|
||||
chatInfo.ChannelMeta.SupportsResponses = false
|
||||
chatInfo.ChannelMeta.ChatViaResponses = true
|
||||
chatInfo.UseResponsesAPI = true
|
||||
got, err = a.GetRequestURL(chatInfo)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestURL error: %v", err)
|
||||
}
|
||||
if want := "https://upstream.example.com/v1/responses"; got != want {
|
||||
t.Errorf("chat inbound via bridge: URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_ConvertRequest_ResponsesUpstream 上游声明 Responses 协议时,
|
||||
// responses 请求保持 Responses 格式:模型名替换、不注入 chat 专属 stream_options/messages。
|
||||
func TestAdaptor_ConvertRequest_ResponsesUpstream(t *testing.T) {
|
||||
@@ -309,3 +344,202 @@ func TestAdaptor_DoResponse_ResponsesUpstreamStream_ModelMapped(t *testing.T) {
|
||||
t.Errorf("usage = %+v, want prompt=10 completion=20", usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_ConvertRequest_ChatViaResponsesBridge responses-only 上游桥接:
|
||||
// chat 请求体转换为 Responses 格式(messages→input),thinking 后缀映射 reasoning.effort,
|
||||
// 不注入 chat 专属 stream_options。
|
||||
func TestAdaptor_ConvertRequest_ChatViaResponsesBridge(t *testing.T) {
|
||||
info := responsesUpstreamInfo(constant.RelayModeChatCompletions, true)
|
||||
info.InboundFormat = constant.RelayFormatOpenAI
|
||||
info.ClientFormat = constant.RelayFormatOpenAI
|
||||
info.OriginModelName = "gpt-4o"
|
||||
info.ChannelMeta.SupportsResponses = false
|
||||
info.ChannelMeta.ChatViaResponses = true
|
||||
info.UseResponsesAPI = true
|
||||
|
||||
body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"say hi"}],"stream":true}`)
|
||||
a := &Adaptor{}
|
||||
out, err := a.ConvertRequest(context.Background(), info, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest error: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(out)
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("bad bridged json: %v\n%s", err, raw)
|
||||
}
|
||||
if _, ok := m["messages"]; ok {
|
||||
t.Error("bridge should convert messages to responses input")
|
||||
}
|
||||
if _, ok := m["input"]; !ok {
|
||||
t.Error("bridge output missing input field")
|
||||
}
|
||||
if _, ok := m["stream_options"]; ok {
|
||||
t.Error("bridge should NOT inject chat stream_options")
|
||||
}
|
||||
if stream := string(m["stream"]); stream != "true" {
|
||||
t.Errorf("stream = %s, want true", stream)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_ConvertRequest_ChatViaResponsesBridge_ModelMapped 桥接 + 模型映射:
|
||||
// 转换器应将模型名替换为上游模型名。
|
||||
func TestAdaptor_ConvertRequest_ChatViaResponsesBridge_ModelMapped(t *testing.T) {
|
||||
info := responsesUpstreamInfo(constant.RelayModeChatCompletions, false)
|
||||
info.InboundFormat = constant.RelayFormatOpenAI
|
||||
info.ClientFormat = constant.RelayFormatOpenAI
|
||||
info.ChannelMeta.IsModelMapped = true
|
||||
info.ChannelMeta.SupportsResponses = false
|
||||
info.ChannelMeta.ChatViaResponses = true
|
||||
info.UseResponsesAPI = true
|
||||
|
||||
body := []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`)
|
||||
a := &Adaptor{}
|
||||
out, err := a.ConvertRequest(context.Background(), info, body)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertRequest error: %v", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(out)
|
||||
var m map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("bad bridged json: %v", err)
|
||||
}
|
||||
if got := string(m["model"]); got != `"gpt-4o-upstream"` {
|
||||
t.Errorf("model = %s, want gpt-4o-upstream", got)
|
||||
}
|
||||
}
|
||||
|
||||
// responsesInboundInfo responses 入站 + 上游 chat 渠道(无 responses 协议能力),
|
||||
// 响应侧走 chat→responses 桥接(handleResponsesInboundStream)。
|
||||
func responsesInboundInfo(isStream bool) *common.RelayInfo {
|
||||
info := responsesUpstreamInfo(constant.RelayModeResponses, isStream)
|
||||
info.ChannelMeta.SupportsResponses = false
|
||||
info.ChannelMeta.ChatViaResponses = false
|
||||
return info
|
||||
}
|
||||
|
||||
// TestExtractStreamEmbeddedError 内嵌错误对象检测:
|
||||
// "error":null 与无 error 键不算错误,对象/字符串错误都要识别。
|
||||
func TestExtractStreamEmbeddedError(t *testing.T) {
|
||||
if _, ok := extractStreamEmbeddedError([]byte(`{"id":"1","choices":[]}`)); ok {
|
||||
t.Error("chunk without error key should not be detected")
|
||||
}
|
||||
if _, ok := extractStreamEmbeddedError([]byte(`{"error":null,"choices":[]}`)); ok {
|
||||
t.Error(`"error":null should not be detected`)
|
||||
}
|
||||
if body, ok := extractStreamEmbeddedError([]byte(`{"error":{"type":"rate_limit_error","message":"limited"}}`)); !ok {
|
||||
t.Error("error object should be detected")
|
||||
} else if !strings.Contains(string(body), "rate_limit_error") {
|
||||
t.Errorf("error body = %s", string(body))
|
||||
}
|
||||
if _, ok := extractStreamEmbeddedError([]byte(`{"error":"overloaded"}`)); !ok {
|
||||
t.Error("string error should be detected")
|
||||
}
|
||||
if _, ok := extractStreamEmbeddedError([]byte(`not json`)); ok {
|
||||
t.Error("invalid json should not be detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorBeforeEvents
|
||||
// 上游 200 + SSE 但首个 data 行即内嵌错误对象(部分聚合商的出错形态):
|
||||
// 必须返回 upstream error 并向客户端透传错误体,而非静默合成空 response.completed。
|
||||
func TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorBeforeEvents(t *testing.T) {
|
||||
errLine := `{"error":{"type":"rate_limit_error","message":"Rate limit reached"}}`
|
||||
ss := strings.Join([]string{
|
||||
"data: " + errLine,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(ss)),
|
||||
}
|
||||
|
||||
info := responsesInboundInfo(true)
|
||||
rec := httptest.NewRecorder()
|
||||
a := &Adaptor{}
|
||||
_, err := a.DoResponse(context.Background(), resp, info, rec)
|
||||
if err == nil {
|
||||
t.Fatal("embedded upstream error should return an upstream error, got nil")
|
||||
}
|
||||
var relayErr *constant.RelayError
|
||||
if !errors.As(err, &relayErr) {
|
||||
t.Fatalf("error should be *constant.RelayError, got %T", err)
|
||||
}
|
||||
if !relayErr.ResponseWritten {
|
||||
t.Error("ResponseWritten should be true after writing error body to client")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "rate_limit_error") {
|
||||
t.Errorf("error body not passed through: %s", rec.Body.String())
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "response.completed") {
|
||||
t.Errorf("should not synthesize empty response.completed on upstream error: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorMidStream
|
||||
// 流中途出现内嵌错误:已发送的事件保留,处理终止并返回 upstream error。
|
||||
func TestAdaptor_DoResponse_ResponsesInboundStream_EmbeddedErrorMidStream(t *testing.T) {
|
||||
ss := strings.Join([]string{
|
||||
`data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"},"finish_reason":null}]}`,
|
||||
"",
|
||||
`data: {"error":{"type":"server_error","message":"upstream crashed"}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(ss)),
|
||||
}
|
||||
|
||||
info := responsesInboundInfo(true)
|
||||
rec := httptest.NewRecorder()
|
||||
a := &Adaptor{}
|
||||
usage, err := a.DoResponse(context.Background(), resp, info, rec)
|
||||
if err == nil {
|
||||
t.Fatal("mid-stream embedded error should return an upstream error, got nil")
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatal("usage should be non-nil for error path")
|
||||
}
|
||||
out := rec.Body.String()
|
||||
if !strings.Contains(out, "response.created") || !strings.Contains(out, `"delta":"Hi"`) {
|
||||
t.Errorf("events emitted before the error should remain:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "response.completed") {
|
||||
t.Errorf("should not synthesize response.completed after mid-stream error:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptor_DoResponse_ResponsesInboundStream_UnparseableChunks
|
||||
// 上游 data 行全部无法解析为 chat chunk(如返回了非 chat 格式):
|
||||
// 保持既有兜底行为(合成空 response.completed、不报错),但不再静默无痕。
|
||||
func TestAdaptor_DoResponse_ResponsesInboundStream_UnparseableChunks(t *testing.T) {
|
||||
ss := strings.Join([]string{
|
||||
"data: {invalid json",
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(ss)),
|
||||
}
|
||||
|
||||
info := responsesInboundInfo(true)
|
||||
rec := httptest.NewRecorder()
|
||||
a := &Adaptor{}
|
||||
usage, err := a.DoResponse(context.Background(), resp, info, rec)
|
||||
if err != nil {
|
||||
t.Fatalf("unparseable chunks keep fallback behavior, unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "response.completed") {
|
||||
t.Errorf("fallback response.completed missing:\n%s", rec.Body.String())
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatal("usage should be non-nil")
|
||||
}
|
||||
}
|
||||
@@ -1149,6 +1149,22 @@ func extractOutputFromResponses(resp *dto.OpenAIResponsesResponse) (string, []dt
|
||||
func HandleResponsesStreamToChat(ctx context.Context, resp *http.Response, info *common.RelayInfo, writer http.ResponseWriter) (*common.Usage, error) {
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 非 200:上游在 SSE 开始前返回错误(非 SSE 体)。必须在 StreamScannerHandler 写出
|
||||
// 200 SSE 响应头之前拦截,否则错误体会被当作 SSE 流转发、且无法再向客户端传递状态码。
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, constant.NewUpstreamError(resp.StatusCode, "read response body failed", err).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
}
|
||||
if isUpstreamOpenAIError(body) {
|
||||
writeUpstreamErrorResponse(writer, resp.StatusCode, body)
|
||||
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
upstreamErr.ResponseWritten = true
|
||||
return &common.Usage{}, upstreamErr
|
||||
}
|
||||
return nil, constant.NewUpstreamErrorFromResponse(resp, body)
|
||||
}
|
||||
|
||||
responseID := fmt.Sprintf("chatcmpl-%s", info.RequestID)
|
||||
createAt := time.Now().Unix()
|
||||
model := info.ChannelMeta.UpstreamModelName
|
||||
@@ -1412,7 +1428,10 @@ func HandleResponsesNonStreamToChat(ctx context.Context, resp *http.Response, in
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(resp.StatusCode)
|
||||
_, _ = writer.Write(body)
|
||||
return nil, constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
// 错误体已写给客户端,标记 ResponseWritten 防止调度 FSM 误判为可重试导致二次写入
|
||||
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(body), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
upstreamErr.ResponseWritten = true
|
||||
return nil, upstreamErr
|
||||
}
|
||||
|
||||
var responsesResp dto.OpenAIResponsesResponse
|
||||
|
||||
@@ -2,6 +2,7 @@ package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
"github.com/qianfree/team-api/relay/common"
|
||||
"github.com/qianfree/team-api/relay/constant"
|
||||
"github.com/qianfree/team-api/relay/dto"
|
||||
@@ -194,6 +197,7 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R
|
||||
var contentBuilder strings.Builder
|
||||
sentCreated := false
|
||||
sentTextDone := false
|
||||
parsedChunks := 0
|
||||
outputIndex := 0
|
||||
contentIndex := 0
|
||||
toolCallIndexByID := make(map[string]int)
|
||||
@@ -232,10 +236,32 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R
|
||||
break
|
||||
}
|
||||
|
||||
// 检测 SSE 流中内嵌的上游错误对象:部分聚合商出错时返回 HTTP 200 + SSE,
|
||||
// 错误信息夹在 data 行里({"error":{...}})。不识别会被当作解析失败静默丢弃,
|
||||
// 最终合成空的 response.completed(客户端表现为"成功但无内容")。
|
||||
if errBody, ok := extractStreamEmbeddedError([]byte(data)); ok {
|
||||
err := fmt.Errorf("upstream embedded error in SSE stream: %.500s", string(errBody))
|
||||
g.Log().Warningf(ctx, "[OpenAI.handleResponsesInboundStream] %v", err)
|
||||
info.StreamStatus.SetEndReason(common.StreamEndReasonError, err)
|
||||
if !sentCreated {
|
||||
// 尚未向客户端发送任何事件:透传上游错误体(与 SSE 开始前的非 200 分支行为一致)
|
||||
writeUpstreamErrorResponse(writer, resp.StatusCode, []byte(data))
|
||||
upstreamErr := constant.NewUpstreamError(resp.StatusCode, string(errBody), nil).WithRetryAfter(constant.RetryAfterFromHeader(resp.Header))
|
||||
upstreamErr.ResponseWritten = true
|
||||
return &usage, upstreamErr
|
||||
}
|
||||
// 已发送部分事件:流已污染无法回写错误体,直接返回错误终止处理
|
||||
return &usage, constant.NewUpstreamError(resp.StatusCode, string(errBody), nil)
|
||||
}
|
||||
|
||||
var chunk dto.ChatCompletionStreamResponse
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
// 解析失败不静默丢弃:记录日志便于定位上游返回非 chat 格式的问题
|
||||
// (如 responses 格式 SSE、纯 JSON 非流式体、空 body 等)
|
||||
g.Log().Warningf(ctx, "[OpenAI.handleResponsesInboundStream] unmarshal chat stream chunk failed: %v, data: %.200s", err, data)
|
||||
continue
|
||||
}
|
||||
parsedChunks++
|
||||
|
||||
// 第一个 chunk:发送 response.created + output_item.added + content_part.added
|
||||
if !sentCreated {
|
||||
@@ -488,6 +514,14 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R
|
||||
}
|
||||
}
|
||||
|
||||
// 上游零有效 chunk 时不再静默合成空响应:记录告警便于定位
|
||||
// (上游返回非 SSE 体 / 非 chat 格式 / 空 body / 纯 [DONE] 等异常都会走到这里)
|
||||
if !sentCreated {
|
||||
g.Log().Warningf(ctx,
|
||||
"[OpenAI.handleResponsesInboundStream] upstream stream yielded no parseable chat chunks (parsed=%d), synthesizing empty response.completed: channel=%d(%s) model=%s request_id=%s",
|
||||
parsedChunks, info.ChannelMeta.ChannelID, info.ChannelMeta.ChannelName, info.OriginModelName, info.RequestID)
|
||||
}
|
||||
|
||||
// 估算 usage(正常结束 4 字符/token;scanner 异常等部分传输场景 2 字符/token)
|
||||
if usage.CompletionTokens == 0 {
|
||||
text := contentBuilder.String()
|
||||
@@ -544,6 +578,25 @@ func (a *Adaptor) handleResponsesInboundStream(ctx context.Context, resp *http.R
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
// extractStreamEmbeddedError 检测 SSE data 行中内嵌的上游错误对象(存在 "error" 键且值非 null)。
|
||||
// 与 isUpstreamOpenAIError 的区别:后者用于完整响应体且不区分 null;这里对流式 chunk 逐行
|
||||
// 检测,并排除 "error":null(部分供应商的正常 chunk 会携带空 error 字段)。
|
||||
func extractStreamEmbeddedError(data []byte) (json.RawMessage, bool) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
errBody, ok := raw["error"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
trimmed := string(bytes.TrimSpace(errBody))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, false
|
||||
}
|
||||
return errBody, true
|
||||
}
|
||||
|
||||
// emitResponsesSSE 发送一个 Responses API 格式的 SSE 事件
|
||||
func emitResponsesSSE(w http.ResponseWriter, eventType string, data any) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
|
||||
@@ -123,6 +123,11 @@ type ChannelSelection struct {
|
||||
IsModelMapped bool
|
||||
MaxConcurrency int // 该渠道最大并发(0/负值表示不限),供各转发入口做容量控制
|
||||
Settings ChannelSettings
|
||||
// 协议能力(chn_abilities 渠道×模型级):
|
||||
// SupportsResponses:responses 入站原样直连 /v1/responses;
|
||||
// ChatViaResponses:responses-only 上游,chat 入站经桥接发送 /v1/responses。
|
||||
SupportsResponses bool
|
||||
ChatViaResponses bool
|
||||
SelectionReason string // bind / hrw / overflow / probe / cred_rotate
|
||||
Priority int
|
||||
Weight int
|
||||
|
||||
@@ -20,6 +20,20 @@ type ChannelMeta struct {
|
||||
UpstreamModelName string // 上游实际模型名(可能与用户请求不同)
|
||||
IsModelMapped bool // 是否经过模型名映射
|
||||
Settings ChannelSettings
|
||||
|
||||
// 协议能力(chn_abilities 渠道×模型级):
|
||||
// SupportsResponses:模型支持 /v1/responses,responses 入站原样直连(不做 chat 转换);
|
||||
// ChatViaResponses:responses-only 上游,chat 入站经桥接转换后发送 /v1/responses。
|
||||
// ChatViaResponses 隐含上游原生说 Responses 协议(UpstreamSpeaksResponses)。
|
||||
SupportsResponses bool
|
||||
ChatViaResponses bool
|
||||
}
|
||||
|
||||
// UpstreamSpeaksResponses 上游(针对当前模型)是否原生支持 Responses 协议:
|
||||
// supports_responses 或 chat_via_responses 任一为真即成立——后者定义上就是
|
||||
// "上游只有 /v1/responses",responses 入站在其上同样应直连而非转 chat。
|
||||
func (m *ChannelMeta) UpstreamSpeaksResponses() bool {
|
||||
return m != nil && (m.SupportsResponses || m.ChatViaResponses)
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -47,13 +61,11 @@ func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int {
|
||||
|
||||
// ChannelSettings 渠道配置(来自 chn_channels.settings JSONB)
|
||||
type ChannelSettings struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // 请求超时秒数,默认 60
|
||||
RetryCount int `json:"retry_count"` // 重试次数,默认 1
|
||||
ParamOverride map[string]any `json:"param_override,omitempty"` // 请求体改写规则
|
||||
HeaderOverride map[string]any `json:"header_override,omitempty"` // Header 改写规则
|
||||
ChatCompletionsViaResponses bool `json:"chat_completions_via_responses,omitempty"` // Chat Completions → Responses API 桥接
|
||||
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` // 直连转发:跳过协议转换,原始请求体直接转发上游
|
||||
UpstreamResponses bool `json:"upstream_responses,omitempty"` // 上游使用 OpenAI Responses 协议(/v1/responses),responses 入站直连转发
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // 请求超时秒数,默认 60
|
||||
RetryCount int `json:"retry_count"` // 重试次数,默认 1
|
||||
ParamOverride map[string]any `json:"param_override,omitempty"` // 请求体改写规则
|
||||
HeaderOverride map[string]any `json:"header_override,omitempty"` // Header 改写规则
|
||||
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` // 直连转发:跳过协议转换,原始请求体直接转发上游
|
||||
|
||||
// System Prompt 注入
|
||||
SystemPrompt string `json:"system_prompt,omitempty"` // 渠道级系统提示词
|
||||
|
||||
@@ -18,6 +18,13 @@ func canPassThrough(info *common.RelayInfo) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// responses-only 桥接渠道的 chat 入站:chat 体与上游 /v1/responses 端点不兼容,
|
||||
// 必须经 ConvertOpenAIToResponses 桥接转换,即使显式开启直连也不得原样透传。
|
||||
if info.ChannelMeta.ChatViaResponses &&
|
||||
constant.RelayMode(info.RelayMode) == constant.RelayModeChatCompletions {
|
||||
return false
|
||||
}
|
||||
|
||||
// 显式开启:运营者明确配置直连,不做额外检查
|
||||
if settings.PassThroughBodyEnabled {
|
||||
return true
|
||||
@@ -48,11 +55,14 @@ func canPassThrough(info *common.RelayInfo) bool {
|
||||
}
|
||||
|
||||
// inboundMatchesChannelNative 判断入站格式是否与渠道原生格式匹配(匹配则可原样直连转发)。
|
||||
// - 渠道显式声明上游为 Responses 协议(upstream_responses):Responses 入站视为原生匹配。
|
||||
// - 上游原生支持 Responses 协议(supports_responses / chat_via_responses):Responses 入站视为原生匹配。
|
||||
// - 普通渠道:入站格式须等于该渠道的唯一原生格式(helper.ProviderNativeFormat)。
|
||||
// - 多协议原生透传渠道(New API / Sub2API):OpenAI/Claude/Gemini 三种格式均视为匹配。
|
||||
//
|
||||
// 注意:chat_via_responses(responses-only 桥接渠道)的 chat 入站由 canPassThrough
|
||||
// 前置硬排除——chat 体必须经 ConvertOpenAIToResponses 转换后才能发 /v1/responses。
|
||||
func inboundMatchesChannelNative(info *common.RelayInfo) bool {
|
||||
if info.ChannelMeta.Settings.UpstreamResponses {
|
||||
if info.ChannelMeta.UpstreamSpeaksResponses() {
|
||||
return info.InboundFormat == constant.RelayFormatResponses
|
||||
}
|
||||
if constant.IsMultiNativeProvider(info.ChannelMeta.ChannelType) {
|
||||
|
||||
@@ -193,57 +193,87 @@ func TestCanPassThrough_MultiNative_ResponsesNotNative(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanPassThrough_UpstreamResponses_ResponsesInbound 渠道声明上游为 Responses 协议后,
|
||||
// TestCanPassThrough_SupportsResponses_ResponsesInbound 模型能力声明支持 Responses 协议后,
|
||||
// Responses 入站在无改写时应原样直连转发。
|
||||
func TestCanPassThrough_UpstreamResponses_ResponsesInbound(t *testing.T) {
|
||||
func TestCanPassThrough_SupportsResponses_ResponsesInbound(t *testing.T) {
|
||||
info := &common.RelayInfo{
|
||||
InboundFormat: constant.RelayFormatResponses,
|
||||
ChannelMeta: &common.ChannelMeta{
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
Settings: common.ChannelSettings{
|
||||
UpstreamResponses: true,
|
||||
},
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
SupportsResponses: true,
|
||||
},
|
||||
}
|
||||
if !canPassThrough(info) {
|
||||
t.Error("channel declaring UpstreamResponses should pass through Responses inbound natively")
|
||||
t.Error("channel declaring SupportsResponses should pass through Responses inbound natively")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanPassThrough_UpstreamResponses_NonResponsesInbound 渠道声明上游为 Responses 协议时,
|
||||
// TestCanPassThrough_SupportsResponses_NonResponsesInbound 渠道声明支持 Responses 协议时,
|
||||
// 其它入站格式(如 chat)不属于原生匹配,不得原样直连。
|
||||
func TestCanPassThrough_UpstreamResponses_NonResponsesInbound(t *testing.T) {
|
||||
func TestCanPassThrough_SupportsResponses_NonResponsesInbound(t *testing.T) {
|
||||
info := &common.RelayInfo{
|
||||
InboundFormat: constant.RelayFormatOpenAI,
|
||||
ChannelMeta: &common.ChannelMeta{
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
Settings: common.ChannelSettings{
|
||||
UpstreamResponses: true,
|
||||
},
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
SupportsResponses: true,
|
||||
},
|
||||
}
|
||||
if canPassThrough(info) {
|
||||
t.Error("UpstreamResponses channel should NOT pass through non-Responses inbound natively")
|
||||
t.Error("SupportsResponses channel should NOT pass through non-Responses inbound natively")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanPassThrough_UpstreamResponses_ModelMappedForcesConversion 上游为 Responses 协议
|
||||
// TestCanPassThrough_SupportsResponses_ModelMappedForcesConversion 上游为 Responses 协议
|
||||
// 但配置了模型映射时,仍需经 ConvertRequest 替换模型名,不得原样透传。
|
||||
func TestCanPassThrough_UpstreamResponses_ModelMappedForcesConversion(t *testing.T) {
|
||||
func TestCanPassThrough_SupportsResponses_ModelMappedForcesConversion(t *testing.T) {
|
||||
info := &common.RelayInfo{
|
||||
InboundFormat: constant.RelayFormatResponses,
|
||||
ChannelMeta: &common.ChannelMeta{
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: true,
|
||||
Settings: common.ChannelSettings{
|
||||
UpstreamResponses: true,
|
||||
},
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: true,
|
||||
SupportsResponses: true,
|
||||
},
|
||||
}
|
||||
if canPassThrough(info) {
|
||||
t.Error("UpstreamResponses channel with model mapping should NOT pass through")
|
||||
t.Error("SupportsResponses channel with model mapping should NOT pass through")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanPassThrough_ChatViaResponses_ChatInboundBlocked responses-only 桥接渠道的
|
||||
// chat 入站:即使显式开启直连(pass_through_body_enabled)也不得原样透传,
|
||||
// chat 体必须经桥接转换后才能发 /v1/responses。
|
||||
func TestCanPassThrough_ChatViaResponses_ChatInboundBlocked(t *testing.T) {
|
||||
info := &common.RelayInfo{
|
||||
InboundFormat: constant.RelayFormatOpenAI,
|
||||
RelayMode: int(constant.RelayModeChatCompletions),
|
||||
ChannelMeta: &common.ChannelMeta{
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
ChatViaResponses: true,
|
||||
SupportsResponses: true,
|
||||
},
|
||||
}
|
||||
info.ChannelMeta.Settings.PassThroughBodyEnabled = true
|
||||
if canPassThrough(info) {
|
||||
t.Error("chat inbound on chat_via_responses channel must NOT pass through, even with explicit passthrough")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanPassThrough_ChatViaResponses_ResponsesInbound responses-only 桥接渠道的
|
||||
// responses 入站视为原生匹配(上游本来就说 Responses 协议),可原样直连。
|
||||
func TestCanPassThrough_ChatViaResponses_ResponsesInbound(t *testing.T) {
|
||||
info := &common.RelayInfo{
|
||||
InboundFormat: constant.RelayFormatResponses,
|
||||
ChannelMeta: &common.ChannelMeta{
|
||||
ChannelType: int(constant.ProviderOpenAI),
|
||||
IsModelMapped: false,
|
||||
ChatViaResponses: true,
|
||||
},
|
||||
}
|
||||
if !canPassThrough(info) {
|
||||
t.Error("responses inbound on chat_via_responses channel should pass through natively")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -499,6 +499,7 @@ func RelayHandler(ctx context.Context, body []byte, path string, headers http.He
|
||||
Scope: channelScope,
|
||||
Replay: co.Policy().Replay.ReplayabilityForMode(v.relayModeStr),
|
||||
Signals: sig,
|
||||
Proto: protoPreference(constant.RelayMode(v.relayMode)),
|
||||
Policy: dispatchadapter.TenantRoutingPolicy(ctx, rc.TenantID),
|
||||
})
|
||||
// 兜底:任何未显式 Finish 的退出路径释放租约(Finish 幂等,成功路径的显式调用优先生效)
|
||||
@@ -763,8 +764,17 @@ func buildRelayInfo(ctx context.Context, rc *RelayContext, v *relayValidation, s
|
||||
UpstreamModelName: selection.UpstreamModelName,
|
||||
IsModelMapped: selection.IsModelMapped,
|
||||
Settings: selection.Settings,
|
||||
|
||||
SupportsResponses: selection.SupportsResponses,
|
||||
ChatViaResponses: selection.ChatViaResponses,
|
||||
},
|
||||
}
|
||||
// responses-only 上游桥接:chat 入站经 /v1/responses 发送(chat→Responses 请求转换 +
|
||||
// Responses→chat 响应转换)。仅对 OpenAI chat 入站生效;claude/gemini 入站需
|
||||
// Responses→原生格式链式转换,暂不支持(此类渠道上会 404,运营应避免混用)。
|
||||
if selection.ChatViaResponses && v.relayMode == constant.RelayModeChatCompletions {
|
||||
info.UseResponsesAPI = true
|
||||
}
|
||||
// 流中断结算时上游 usage 常缺失,记录请求侧输入估算值(与预扣同源)供输入计费兜底
|
||||
info.SetEstimatePromptTokens(v.estimatedTokens)
|
||||
return info
|
||||
@@ -1042,6 +1052,19 @@ func setPreResponseHeaders(w http.ResponseWriter, br *BillingResult) {
|
||||
}
|
||||
}
|
||||
|
||||
// protoPreference 入站端点协议 → 调度软偏好(只降权不排除):
|
||||
// responses 端点偏好声明支持 Responses 协议的渠道(避免经 chat 转换丢失有状态特性),
|
||||
// chat 端点偏好原生 chat 渠道(responses-only 桥接渠道降权但仍可服务),其余端点无偏好。
|
||||
func protoPreference(mode constant.RelayMode) dispatch.ProtoPreference {
|
||||
switch mode {
|
||||
case constant.RelayModeResponses, constant.RelayModeResponsesCompact:
|
||||
return dispatch.ProtoResponses
|
||||
case constant.RelayModeChatCompletions:
|
||||
return dispatch.ProtoChat
|
||||
}
|
||||
return dispatch.ProtoAny
|
||||
}
|
||||
|
||||
// requestType 根据 isStream 返回请求类型
|
||||
func requestType(isStream bool) int {
|
||||
if isStream {
|
||||
|
||||
@@ -361,7 +361,7 @@ func (s *RouteSession) buildCandidates(snapshot []Channel, roundExcluded map[int
|
||||
zeroTier = append(zeroTier, ch)
|
||||
continue
|
||||
}
|
||||
w, bd := EffectiveWeight(ch, pol)
|
||||
w, bd := EffectiveWeight(ch, pol, s.profile.Proto)
|
||||
scored = append(scored, ScoredChannel{Channel: ch, Weight: w, Breakdown: bd})
|
||||
}
|
||||
|
||||
@@ -374,10 +374,10 @@ func (s *RouteSession) buildCandidates(snapshot []Channel, roundExcluded map[int
|
||||
if ch.Tier != t {
|
||||
continue
|
||||
}
|
||||
w, bd := EffectiveWeight(ch, pol)
|
||||
w, bd := EffectiveWeight(ch, pol, s.profile.Proto)
|
||||
// tierFactor 为 0,用 epsilon 重算
|
||||
bd.Tier = epsilonTier
|
||||
bd.Effective = bd.Base * bd.Tier * bd.Health * bd.Headroom * bd.Cost * bd.Ramp
|
||||
bd.Effective = bd.Base * bd.Tier * bd.Health * bd.Headroom * bd.Cost * bd.Ramp * bd.Proto
|
||||
w = bd.Effective
|
||||
if w > 0 {
|
||||
added = true
|
||||
|
||||
@@ -50,6 +50,15 @@ type RoutingPolicy struct {
|
||||
Degrade struct {
|
||||
MaxReplicas int `json:"maxReplicas"` // 实例级保守限额分母,默认 1
|
||||
} `json:"degrade"`
|
||||
|
||||
Proto struct {
|
||||
// ResponsesMismatch responses 入站打在不支持 Responses 协议渠道上的降权因子,
|
||||
// 默认 0.25(经 chat 转换丢失有状态 Responses 特性,降权较重)。
|
||||
ResponsesMismatch float64 `json:"responsesMismatch"`
|
||||
// ChatBridgeMismatch chat 入站打在 responses-only 桥接渠道上的降权因子,
|
||||
// 默认 0.75(桥接基本无损,降权较轻)。
|
||||
ChatBridgeMismatch float64 `json:"chatBridgeMismatch"`
|
||||
} `json:"proto"`
|
||||
}
|
||||
|
||||
// ReplayPolicy 可重放性策略。
|
||||
@@ -157,6 +166,8 @@ func DefaultRoutingPolicy() *RoutingPolicy {
|
||||
SafeModes: []string{"embeddings", "rerank"},
|
||||
}
|
||||
p.Degrade.MaxReplicas = 1
|
||||
p.Proto.ResponsesMismatch = 0.25
|
||||
p.Proto.ChatBridgeMismatch = 0.75
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -214,6 +225,12 @@ func (p *RoutingPolicy) Validate() error {
|
||||
if p.Degrade.MaxReplicas <= 0 {
|
||||
return fmt.Errorf("degrade.maxReplicas 必须 > 0")
|
||||
}
|
||||
// proto 偏好因子必须为 (0,1]:>1 会反向惩罚匹配渠道,=0 会把不匹配渠道硬排除
|
||||
if p.Proto.ResponsesMismatch <= 0 || p.Proto.ResponsesMismatch > 1 ||
|
||||
p.Proto.ChatBridgeMismatch <= 0 || p.Proto.ChatBridgeMismatch > 1 {
|
||||
return fmt.Errorf("proto 因子必须在 (0,1]: responsesMismatch=%v chatBridgeMismatch=%v",
|
||||
p.Proto.ResponsesMismatch, p.Proto.ChatBridgeMismatch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestProtoFactor_ResponsesPreference protoFactor 在 responses 入站下的软偏好:
|
||||
// 匹配渠道因子为 1,不匹配渠道按策略 ResponsesMismatch 降权;ProtoAny 不降权。
|
||||
func TestProtoFactor_ResponsesPreference(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
match := healthyChannel(1, TierPrimary, 10)
|
||||
match.SupportsResponses = true
|
||||
mismatch := healthyChannel(2, TierPrimary, 10)
|
||||
|
||||
assert.Equal(t, 1.0, protoFactor(match, ProtoResponses, pol))
|
||||
assert.Equal(t, pol.Proto.ResponsesMismatch, protoFactor(mismatch, ProtoResponses, pol))
|
||||
assert.Equal(t, 1.0, protoFactor(mismatch, ProtoAny, pol))
|
||||
// chat 入站对 responses 支持与否不敏感
|
||||
assert.Equal(t, 1.0, protoFactor(match, ProtoChat, pol))
|
||||
}
|
||||
|
||||
// TestProtoFactor_ChatBridgePreference protoFactor 在 chat 入站下的软偏好:
|
||||
// 原生 chat 渠道因子为 1,chat_via_responses 桥接渠道按 ChatBridgeMismatch 降权。
|
||||
func TestProtoFactor_ChatBridgePreference(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
native := healthyChannel(1, TierPrimary, 10)
|
||||
bridge := healthyChannel(2, TierPrimary, 10)
|
||||
bridge.ChatViaResponses = true
|
||||
|
||||
assert.Equal(t, 1.0, protoFactor(native, ProtoChat, pol))
|
||||
assert.Equal(t, pol.Proto.ChatBridgeMismatch, protoFactor(bridge, ProtoChat, pol))
|
||||
assert.Equal(t, 1.0, protoFactor(bridge, ProtoResponses, pol)) // 未声明 supports_responses
|
||||
}
|
||||
|
||||
// TestEffectiveWeight_ProtoInProduct proto 因子参与权重乘积且记录在分解明细中。
|
||||
func TestEffectiveWeight_ProtoInProduct(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
c := healthyChannel(1, TierPrimary, 10)
|
||||
wNoPref, _ := EffectiveWeight(c, pol)
|
||||
wPref, bd := EffectiveWeight(c, pol, ProtoResponses)
|
||||
require.Equal(t, pol.Proto.ResponsesMismatch, bd.Proto)
|
||||
assert.InDelta(t, wNoPref*pol.Proto.ResponsesMismatch, wPref, 1e-9)
|
||||
}
|
||||
|
||||
// TestCoordinator_ProtoResponsesPreference 统计意义验证:两个同权重同健康渠道,
|
||||
// responses 入站时绝大多数会话键选中声明 SupportsResponses 的渠道(软偏好非硬过滤,
|
||||
// HRW 哈希仍可能命中另一渠道——降权只是让匹配渠道的选中概率占优)。
|
||||
func TestCoordinator_ProtoResponsesPreference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
match := healthyChannel(1, TierPrimary, 10)
|
||||
match.SupportsResponses = true
|
||||
other := healthyChannel(2, TierPrimary, 10)
|
||||
|
||||
matchWins, otherWins := 0, 0
|
||||
for i := range 200 {
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, match, other)
|
||||
p := testProfile()
|
||||
p.RequestID = fmt.Sprintf("req-%d", i)
|
||||
p.Signals = SessionSignals{HeaderSessionID: fmt.Sprintf("sess-%d", i)}
|
||||
p.Proto = ProtoResponses
|
||||
d := co.Route(ctx, p).Next(ctx)
|
||||
require.NotNil(t, d)
|
||||
if d.Channel.ID == 1 {
|
||||
matchWins++
|
||||
} else {
|
||||
otherWins++
|
||||
}
|
||||
}
|
||||
assert.Greater(t, matchWins, otherWins*3, "responses-capable channel should dominate: match=%d other=%d", matchWins, otherWins)
|
||||
}
|
||||
|
||||
// TestCoordinator_ProtoAnyNoPreference 对照组:无偏好时两同权重渠道选中次数同量级。
|
||||
func TestCoordinator_ProtoAnyNoPreference(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
a := healthyChannel(1, TierPrimary, 10)
|
||||
a.SupportsResponses = true
|
||||
b := healthyChannel(2, TierPrimary, 10)
|
||||
|
||||
aWins, bWins := 0, 0
|
||||
for i := range 200 {
|
||||
state := newFakeState()
|
||||
co, _ := newTestCoordinator(state, a, b)
|
||||
p := testProfile()
|
||||
p.RequestID = fmt.Sprintf("req-%d", i)
|
||||
p.Signals = SessionSignals{HeaderSessionID: fmt.Sprintf("sess-%d", i)}
|
||||
d := co.Route(ctx, p).Next(ctx)
|
||||
require.NotNil(t, d)
|
||||
if d.Channel.ID == 1 {
|
||||
aWins++
|
||||
} else {
|
||||
bWins++
|
||||
}
|
||||
}
|
||||
// HRW 均匀性:无偏好时两渠道选中次数应为同量级(放宽到 4 倍避免哈希偶然性导致的 flake)
|
||||
assert.Less(t, aWins, bWins*4)
|
||||
assert.Less(t, bWins, aWins*4)
|
||||
}
|
||||
|
||||
// TestPolicyValidate_ProtoFactors proto 因子越界(>1 或 <=0)必须被拒绝。
|
||||
func TestPolicyValidate_ProtoFactors(t *testing.T) {
|
||||
pol := DefaultRoutingPolicy()
|
||||
require.NoError(t, pol.Validate())
|
||||
|
||||
pol.Proto.ResponsesMismatch = 0
|
||||
assert.Error(t, pol.Validate())
|
||||
pol = DefaultRoutingPolicy()
|
||||
pol.Proto.ResponsesMismatch = 1.5
|
||||
assert.Error(t, pol.Validate())
|
||||
pol = DefaultRoutingPolicy()
|
||||
pol.Proto.ChatBridgeMismatch = 0
|
||||
assert.Error(t, pol.Validate())
|
||||
}
|
||||
@@ -6,10 +6,15 @@ import (
|
||||
|
||||
// EffectiveWeight 权重合成函数 W(c)(纯函数)。
|
||||
//
|
||||
// W(c) = baseWeight × tierFactor × healthFactor × headroom^γ × costFactor × rampFactor
|
||||
// W(c) = baseWeight × tierFactor × healthFactor × headroom^γ × costFactor × rampFactor × protoFactor
|
||||
//
|
||||
// 返回合成权重与分解明细(供决策日志 / ForwardingTrace)。
|
||||
func EffectiveWeight(c Channel, pol *RoutingPolicy) (float64, WeightBreakdown) {
|
||||
// pref 为可选的入站协议偏好(省略 = ProtoAny,protoFactor 恒为 1)。
|
||||
func EffectiveWeight(c Channel, pol *RoutingPolicy, pref ...ProtoPreference) (float64, WeightBreakdown) {
|
||||
var proto ProtoPreference
|
||||
if len(pref) > 0 {
|
||||
proto = pref[0]
|
||||
}
|
||||
bd := WeightBreakdown{
|
||||
Base: c.BaseWeight,
|
||||
Tier: tierFactor(c.Tier, pol),
|
||||
@@ -17,11 +22,12 @@ func EffectiveWeight(c Channel, pol *RoutingPolicy) (float64, WeightBreakdown) {
|
||||
Headroom: headroomFactor(c, pol),
|
||||
Cost: costFactor(c, pol),
|
||||
Ramp: rampFactor(c, pol),
|
||||
Proto: protoFactor(c, proto, pol),
|
||||
}
|
||||
if bd.Base <= 0 {
|
||||
bd.Base = 0
|
||||
}
|
||||
bd.Effective = bd.Base * bd.Tier * bd.Health * bd.Headroom * bd.Cost * bd.Ramp
|
||||
bd.Effective = bd.Base * bd.Tier * bd.Health * bd.Headroom * bd.Cost * bd.Ramp * bd.Proto
|
||||
return bd.Effective, bd
|
||||
}
|
||||
|
||||
@@ -89,6 +95,29 @@ func rampFactor(c Channel, pol *RoutingPolicy) float64 {
|
||||
return clamp(float64(c.RampElapsedMs)/float64(window), pol.Ramp.Floor, 1.0)
|
||||
}
|
||||
|
||||
// protoFactor 协议偏好因子(软偏好,只降权不排除,恒 > 0):
|
||||
// - ProtoResponses:上游原生支持 Responses 协议(supports_responses 或
|
||||
// chat_via_responses,后者定义上即 responses-only 上游)的渠道为 1,其余降权
|
||||
// (经 chat 转换会丢失 previous_response_id 有状态多轮等 Responses 专属特性,
|
||||
// 损失大故降权较重);
|
||||
// - ProtoChat:原生 chat 渠道为 1,ChatViaResponses 桥接渠道降权(桥接基本无损,
|
||||
// 仅响应细节略有出入,降权较轻)。
|
||||
func protoFactor(c Channel, pref ProtoPreference, pol *RoutingPolicy) float64 {
|
||||
switch pref {
|
||||
case ProtoResponses:
|
||||
if c.SupportsResponses || c.ChatViaResponses {
|
||||
return 1
|
||||
}
|
||||
return pol.Proto.ResponsesMismatch
|
||||
case ProtoChat:
|
||||
if !c.ChatViaResponses {
|
||||
return 1
|
||||
}
|
||||
return pol.Proto.ChatBridgeMismatch
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi float64) float64 {
|
||||
if v < lo {
|
||||
return lo
|
||||
|
||||
@@ -67,8 +67,23 @@ type Channel struct {
|
||||
|
||||
KeyIDs []int64 // active 状态的渠道 Key ID 列表(按 id 升序);空表示由适配层自行取 Key
|
||||
StrictCapacity bool // Redis 故障时 fail-closed
|
||||
|
||||
// 协议能力(chn_abilities 渠道×模型级),参与 protoFactor 软偏好:
|
||||
// responses 入站偏好 SupportsResponses 渠道(避免经 chat 转换丢失有状态 Responses 特性),
|
||||
// chat 入站偏好原生 chat 渠道(ChatViaResponses 桥接渠道降权但仍可用)。
|
||||
SupportsResponses bool // 模型在该渠道支持 /v1/responses 原生协议
|
||||
ChatViaResponses bool // 上游仅有 Responses 协议,chat 需经桥接发送
|
||||
}
|
||||
|
||||
// ProtoPreference 入站端点协议偏好(软偏好,只降权不排除)。
|
||||
type ProtoPreference uint8
|
||||
|
||||
const (
|
||||
ProtoAny ProtoPreference = iota // 非文本生成端点 / 无偏好
|
||||
ProtoResponses // responses 入站:偏好 SupportsResponses 渠道
|
||||
ProtoChat // chat 入站:偏好原生 chat 渠道
|
||||
)
|
||||
|
||||
// ScoredChannel 打分后的候选。
|
||||
type ScoredChannel struct {
|
||||
Channel
|
||||
@@ -84,6 +99,7 @@ type WeightBreakdown struct {
|
||||
Headroom float64 `json:"headroom"`
|
||||
Cost float64 `json:"cost"`
|
||||
Ramp float64 `json:"ramp"`
|
||||
Proto float64 `json:"proto"`
|
||||
Effective float64 `json:"effective"`
|
||||
}
|
||||
|
||||
@@ -97,6 +113,9 @@ type RequestProfile struct {
|
||||
Scope []int64 // 租户渠道范围(空 = 不限制),由适配层填充
|
||||
Replay Replayability
|
||||
Signals SessionSignals
|
||||
// Proto 入站端点协议偏好(ProtoAny=无偏好)。responses 入站软偏好
|
||||
// SupportsResponses 渠道、chat 入站软偏好原生 chat 渠道,只降权不排除。
|
||||
Proto ProtoPreference
|
||||
// Policy 请求级策略覆盖(租户级差异化)。
|
||||
// nil = 使用协调器全局策略。由适配层解析(全局 + 租户浅合并)后传入。
|
||||
Policy *RoutingPolicy
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, h, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Tag, Button, Popconfirm, Message, RadioGroup, Radio, InputNumber, Input } from '@arco-design/web-vue'
|
||||
import { Tag, Button, Popconfirm, Message, RadioGroup, Radio, InputNumber, Input, Switch } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -68,7 +68,6 @@ const editForm = reactive({
|
||||
status: 'active',
|
||||
is_vip: false,
|
||||
use_proxy: false,
|
||||
upstream_responses: false,
|
||||
sharing_threshold: null as number | null,
|
||||
preemption_threshold: null as number | null,
|
||||
borrowing_cooldown_seconds: null as number | null,
|
||||
@@ -90,7 +89,6 @@ function openEditModal() {
|
||||
status: detail.value.status || 'active',
|
||||
is_vip: detail.value.is_vip || false,
|
||||
use_proxy: detail.value.use_proxy || false,
|
||||
upstream_responses: detail.value.upstream_responses || false,
|
||||
sharing_threshold: detail.value.sharing_threshold ?? null,
|
||||
preemption_threshold: detail.value.preemption_threshold ?? null,
|
||||
borrowing_cooldown_seconds: detail.value.borrowing_cooldown_seconds ?? null,
|
||||
@@ -160,6 +158,26 @@ const abilityColumns: TableColumnData[] = [
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Responses 协议', dataIndex: 'supports_responses', width: 110,
|
||||
render({ record }) {
|
||||
return h(Switch, {
|
||||
modelValue: !!record.supports_responses,
|
||||
size: 'mini',
|
||||
onChange: (v: boolean) => handleToggleProtoFlag(record, 'supports_responses', v),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Chat 经 Responses', dataIndex: 'chat_via_responses', width: 120,
|
||||
render({ record }) {
|
||||
return h(Switch, {
|
||||
modelValue: !!record.chat_via_responses,
|
||||
size: 'mini',
|
||||
onChange: (v: boolean) => handleToggleProtoFlag(record, 'chat_via_responses', v),
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'enabled', width: 80,
|
||||
render({ record }) {
|
||||
@@ -189,7 +207,7 @@ async function fetchAbilities() {
|
||||
} catch { abilitiesData.value = [] } finally { abilitiesLoading.value = false }
|
||||
}
|
||||
|
||||
// abilityPayload 组装能力批量提交体(cost_ratio 必须随行提交,否则会被重置为默认 1.0)
|
||||
// abilityPayload 组装能力批量提交体(cost_ratio / 协议开关必须随行提交,否则会被重置为默认值)
|
||||
function abilityPayload(list: any[]) {
|
||||
return {
|
||||
channel_id: Number(channelId),
|
||||
@@ -198,6 +216,8 @@ function abilityPayload(list: any[]) {
|
||||
upstream_model: a.upstream_model || '',
|
||||
enabled: a.enabled,
|
||||
cost_ratio: a.cost_ratio ?? 1,
|
||||
supports_responses: !!a.supports_responses,
|
||||
chat_via_responses: !!a.chat_via_responses,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -234,6 +254,16 @@ function handleUpstreamModelChange(record: any, value: string) {
|
||||
record.upstream_model = value
|
||||
}
|
||||
|
||||
// 协议能力开关(supports_responses / chat_via_responses):即时整表提交
|
||||
async function handleToggleProtoFlag(ab: any, flag: 'supports_responses' | 'chat_via_responses', value: boolean) {
|
||||
const newList = abilitiesData.value.map(a => a.id === ab.id ? { ...a, [flag]: value } : a)
|
||||
try {
|
||||
await request.put(`/admin/channels/${channelId}/abilities`, abilityPayload(newList))
|
||||
abilitiesData.value = newList
|
||||
Message.success(flag === 'supports_responses' ? 'Responses 协议支持已更新' : 'Chat 经 Responses 已更新')
|
||||
} catch { /* error handled by interceptor */ }
|
||||
}
|
||||
|
||||
async function handleDeleteAbility(id: number) {
|
||||
const newList = abilitiesData.value.filter(a => a.id !== id)
|
||||
try {
|
||||
@@ -613,6 +643,12 @@ function formatHeaders(headers: Record<string, string>): string {
|
||||
<div>
|
||||
<span style="font-weight: 500;">成本比例:</span>该渠道相对基准价的成本系数,1.0 为标准。小于 1(如 0.8)表示更便宜,多渠道择优时更优先调度;大于 1(如 1.5)表示更贵,优先级降低。仅用于渠道调度,不影响对用户的计费。
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight: 500;">Responses 协议:</span>该模型在上游支持 OpenAI Responses API(/v1/responses)。开启后 /v1/responses 端点请求直连上游原生协议转发(不经 chat 转换),且调度时优先选择此类渠道。
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight: 500;">Chat 经 Responses:</span>上游仅有 Responses 协议(responses-only,如 Codex 类中转)。开启后 /v1/chat/completions 请求自动经桥接转换发送到 /v1/responses。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ACard>
|
||||
@@ -799,12 +835,6 @@ function formatHeaders(headers: Record<string, string>): string {
|
||||
<ASwitch v-model="editForm.use_proxy" />
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="12">
|
||||
<AFormItem label="上游使用 Responses 协议">
|
||||
<ASwitch v-model="editForm.upstream_responses" />
|
||||
<template #extra><span class="field-help">上游为 OpenAI Responses API(/v1/responses),responses 入站直连转发</span></template>
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
|
||||
<AFormItem label="备注"><AInput v-model="editForm.remark" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" /></AFormItem>
|
||||
|
||||
@@ -257,10 +257,10 @@ const providerDefaultURLs: Record<number, string> = {
|
||||
35: 'https://api.openai.com',
|
||||
}
|
||||
|
||||
const form = reactive({ name: '', type: 1, base_url: '', api_key: '', priority: 0, weight: 100, max_concurrency: 100, tier: 'primary', strict_capacity: false, test_model: '', remark: '', status: 'active', is_vip: false, use_proxy: false, upstream_responses: false, sharing_threshold: null as number | null, preemption_threshold: null as number | null, borrowing_cooldown_seconds: null as number | null })
|
||||
const form = reactive({ name: '', type: 1, base_url: '', api_key: '', priority: 0, weight: 100, max_concurrency: 100, tier: 'primary', strict_capacity: false, test_model: '', remark: '', status: 'active', is_vip: false, use_proxy: false, sharing_threshold: null as number | null, preemption_threshold: null as number | null, borrowing_cooldown_seconds: null as number | null })
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { name: '', type: 1, base_url: '', api_key: '', priority: 0, weight: 100, max_concurrency: 100, tier: 'primary', strict_capacity: false, test_model: '', remark: '', status: 'active', is_vip: false, use_proxy: false, upstream_responses: false, sharing_threshold: null, preemption_threshold: null, borrowing_cooldown_seconds: null })
|
||||
Object.assign(form, { name: '', type: 1, base_url: '', api_key: '', priority: 0, weight: 100, max_concurrency: 100, tier: 'primary', strict_capacity: false, test_model: '', remark: '', status: 'active', is_vip: false, use_proxy: false, sharing_threshold: null, preemption_threshold: null, borrowing_cooldown_seconds: null })
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -603,12 +603,6 @@ const { exporting, exportFile } = useExport({
|
||||
<template #extra><span class="field-help">需先在系统设置中配置代理</span></template>
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="12">
|
||||
<AFormItem label="上游使用 Responses 协议">
|
||||
<ASwitch v-model="form.upstream_responses" />
|
||||
<template #extra><span class="field-help">上游为 OpenAI Responses API(/v1/responses),responses 入站直连转发</span></template>
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<AFormItem label="备注"><AInput v-model="form.remark" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="备注(选填)" /></AFormItem>
|
||||
<!-- VIP 设置 -->
|
||||
|
||||
Reference in new issue
Block a user