渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标
- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通 (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接 - 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加 - 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标 - 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/openteam/server/internal/pkg/resp"
|
||||
"github.com/openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// AdminChannelRemoteModels GET /api/v1/admin/channels/:id/models/remote
|
||||
// 拉取渠道接口的模型列表(仅预览,不绑定)。
|
||||
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid channel id")
|
||||
return
|
||||
}
|
||||
var ch store.Channel
|
||||
if err := h.a.DB.First(&ch, id).Error; err != nil {
|
||||
resp.Fail(c, http.StatusNotFound, "channel not found")
|
||||
return
|
||||
}
|
||||
key, err := h.a.Enc.Decrypt(ch.APIKeyEnc)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, ch.UpstreamURL("", "/models"), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode))
|
||||
return
|
||||
}
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil {
|
||||
resp.Fail(c, http.StatusBadGateway, "failed to parse model list")
|
||||
return
|
||||
}
|
||||
items := make([]string, 0, len(list.Data))
|
||||
for _, m := range list.Data {
|
||||
if strings.TrimSpace(m.ID) != "" {
|
||||
items = append(items, strings.TrimSpace(m.ID))
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// AdminChannelModels GET /api/v1/admin/channels/:id/models — 渠道的模型绑定列表(含上游映射名)。
|
||||
func (h *Handler) AdminChannelModels(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
|
||||
@@ -34,7 +34,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
}
|
||||
out = append(out, gin.H{
|
||||
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
|
||||
"base_url": ch.BaseURL,
|
||||
"base_url": ch.BaseURL, "base_urls": ch.BaseURLs,
|
||||
"api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
|
||||
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
|
||||
"health_status": ch.HealthStatus, "enabled": ch.Enabled,
|
||||
@@ -45,16 +45,34 @@ func (h *Handler) AdminChannels(c *gin.Context) {
|
||||
}
|
||||
|
||||
type channelBody struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
||||
BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
|
||||
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
|
||||
BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点
|
||||
BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages)
|
||||
APIKey string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// normalizeBaseURLs 校验并清理分协议 base_url。
|
||||
func normalizeBaseURLs(m map[string]string) map[string]string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for k, v := range m {
|
||||
if validFormats[k] && strings.TrimSpace(v) != "" {
|
||||
out[k] = strings.TrimRight(strings.TrimSpace(v), "/")
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
|
||||
@@ -166,6 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
|
||||
}
|
||||
ch := store.Channel{
|
||||
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL,
|
||||
BaseURLs: normalizeBaseURLs(req.BaseURLs),
|
||||
APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
|
||||
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
|
||||
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
|
||||
@@ -185,17 +204,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Name *string `json:"name"`
|
||||
Provider *string `json:"provider"`
|
||||
Formats *[]string `json:"formats"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
BaseURLs *map[string]string `json:"base_urls"`
|
||||
APIKey *string `json:"api_key"`
|
||||
Weight *int `json:"weight"`
|
||||
Priority *int `json:"priority"`
|
||||
TimeoutMS *int `json:"timeout_ms"`
|
||||
MaxConcurrency *int `json:"max_concurrency"`
|
||||
HealthStatus *string `json:"health_status"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||
@@ -229,6 +249,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
}
|
||||
updates["base_url"] = b
|
||||
}
|
||||
if body.BaseURLs != nil {
|
||||
// base_urls 是 jsonb:手动序列化
|
||||
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
|
||||
updates["base_urls"] = string(raw)
|
||||
}
|
||||
if body.APIKey != nil && *body.APIKey != "" {
|
||||
enc, err := h.a.Enc.Encrypt(*body.APIKey)
|
||||
if err != nil {
|
||||
@@ -265,20 +290,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusBadRequest, ferr.Error())
|
||||
return
|
||||
}
|
||||
updates["formats"] = formats
|
||||
// formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化)
|
||||
raw, _ := json.Marshal(formats)
|
||||
updates["formats"] = string(raw)
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
|
||||
return
|
||||
}
|
||||
// jsonb 序列化走模型字段更新
|
||||
if f, ok := updates["formats"]; ok {
|
||||
if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to update formats")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
resp.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -321,7 +341,7 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
url := ch.UpstreamURL("", "/models")
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
@@ -369,7 +389,7 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) {
|
||||
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
url := ch.UpstreamURL("", "/models")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
|
||||
@@ -104,6 +104,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
|
||||
admin.DELETE("/channels/:id", h.AdminDeleteChannel)
|
||||
admin.POST("/channels/:id/test", h.AdminTestChannel)
|
||||
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
|
||||
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
|
||||
admin.GET("/channels/:id/models", h.AdminChannelModels)
|
||||
admin.POST("/channels/:id/models", h.AdminChannelAddModel)
|
||||
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) {
|
||||
h.record(ch, false)
|
||||
return
|
||||
}
|
||||
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
|
||||
url := ch.UpstreamURL("", "/models")
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
@@ -251,16 +251,17 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
|
||||
func upstreamPath(proto string) string {
|
||||
switch proto {
|
||||
case convert.ProtoMessages:
|
||||
return "/v1/messages"
|
||||
return "/messages"
|
||||
case convert.ProtoResponses:
|
||||
return "/v1/responses"
|
||||
return "/responses"
|
||||
default:
|
||||
return "/v1/chat/completions"
|
||||
return "/chat/completions"
|
||||
}
|
||||
}
|
||||
|
||||
// upstreamPlan 描述一次代理请求的上游访问方式。
|
||||
type upstreamPlan struct {
|
||||
proto string // 上游协议(用于分协议 base_url)
|
||||
path string // 上游路径
|
||||
body []byte // 已转换的请求体
|
||||
lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
|
||||
@@ -292,7 +293,7 @@ func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstrea
|
||||
if target == "" {
|
||||
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name)
|
||||
}
|
||||
plan := &upstreamPlan{path: upstreamPath(target), body: body}
|
||||
plan := &upstreamPlan{proto: target, path: upstreamPath(target), body: body}
|
||||
if target != clientProto {
|
||||
converted, err := convert.ConvertRequest(body, clientProto, target)
|
||||
if err != nil {
|
||||
|
||||
@@ -44,19 +44,9 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
|
||||
return br, body, nil
|
||||
}
|
||||
|
||||
// upstreamURL 组装上游地址,智能识别用户填写的 Base URL 形式:
|
||||
// - 完整端点(以目标资源路径结尾) → 直接使用
|
||||
// - 已含版本前缀(如 /v1) → 只拼资源路径(/chat/completions 等)
|
||||
// - 纯域名/地址前缀 → 拼完整路径(/v1/chat/completions 等)
|
||||
func upstreamURL(ch *store.Channel, path string) string {
|
||||
base := strings.TrimRight(ch.BaseURL, "/")
|
||||
if strings.HasSuffix(base, path) {
|
||||
return base
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
return base + strings.TrimPrefix(path, "/v1")
|
||||
}
|
||||
return base + path
|
||||
// upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。
|
||||
func upstreamURL(ch *store.Channel, proto, path string) string {
|
||||
return ch.UpstreamURL(proto, path)
|
||||
}
|
||||
|
||||
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
|
||||
@@ -103,7 +93,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
|
||||
upBody := plan.body
|
||||
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为)
|
||||
if stream && plan.path == "/v1/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
||||
if stream && plan.path == "/chat/completions" && plan.lineConv == nil && !bytes.Contains(upBody, []byte(`"include_usage"`)) {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(upBody, &m) == nil {
|
||||
m["stream_options"] = map[string]any{"include_usage": true}
|
||||
@@ -115,7 +105,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.path), bytes.NewReader(upBody))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
|
||||
if err != nil {
|
||||
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
|
||||
}
|
||||
@@ -125,7 +115,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
|
||||
if ua := c.GetHeader("User-Agent"); ua != "" {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
}
|
||||
if plan.path == "/v1/messages" {
|
||||
if plan.path == "/messages" {
|
||||
req.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
|
||||
package store
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 角色 / 状态枚举(字符串存库,便于阅读与迁移)
|
||||
const (
|
||||
@@ -82,9 +86,10 @@ type Channel struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
|
||||
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式)
|
||||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||
Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
|
||||
BaseURL string `gorm:"size:255;not null" json:"base_url"`
|
||||
BaseURLs map[string]string `gorm:"type:jsonb;serializer:json" json:"base_urls,omitempty"` // 分协议 base_url 覆盖(chat/responses/messages)
|
||||
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文
|
||||
Weight int `gorm:"not null;default:1" json:"weight"`
|
||||
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
|
||||
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"`
|
||||
@@ -110,6 +115,32 @@ func (c *Channel) FormatsEffective() []string {
|
||||
}
|
||||
}
|
||||
|
||||
// versionSegRe 匹配末尾版本前缀,如 /v1、/v2、/v4。
|
||||
var versionSegRe = regexp.MustCompile(`/v[0-9]+/?$`)
|
||||
|
||||
// UpstreamURL 按协议选 base_url(分协议覆盖优先),再按版本前缀拼资源路径(path 不含 /v1)。
|
||||
// - proto 有 BaseURLs 覆盖时用覆盖值,否则用主 BaseURL
|
||||
// - base 已以资源路径结尾 → 原样
|
||||
// - base 含版本前缀(如 /v1、/v4) → base + path
|
||||
// - 否则 → base + /v1 + path(默认补 OpenAI/Anthropic 的 /v1)
|
||||
func (c *Channel) UpstreamURL(proto, path string) string {
|
||||
base := c.BaseURL
|
||||
if len(c.BaseURLs) > 0 && c.BaseURLs[proto] != "" {
|
||||
base = c.BaseURLs[proto]
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
if strings.HasSuffix(base, path) {
|
||||
return base
|
||||
}
|
||||
if versionSegRe.MatchString(base) {
|
||||
return base + path
|
||||
}
|
||||
return base + "/v1" + path
|
||||
}
|
||||
|
||||
// Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
|
||||
type Model struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChannelUpstreamURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
base, path, want string
|
||||
}{
|
||||
// 无版本前缀 → 默认补 /v1(OpenAI 纯域名)
|
||||
{"https://api.openai.com", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||
// 已含 /v1 → 直接拼资源路径
|
||||
{"https://api.openai.com/v1", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||
{"https://api.anthropic.com/v1", "/messages", "https://api.anthropic.com/v1/messages"},
|
||||
// 自定义版本前缀(如 BigModel 的 /v4) → 直接拼资源路径
|
||||
{"https://open.bigmodel.cn/api/paas/v4", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"},
|
||||
{"https://open.bigmodel.cn/api/paas/v4", "/models", "https://open.bigmodel.cn/api/paas/v4/models"},
|
||||
// 本地 mock:无版本前缀补 /v1
|
||||
{"http://localhost:9000", "/chat/completions", "http://localhost:9000/v1/chat/completions"},
|
||||
{"http://localhost:9000/v1", "/models", "http://localhost:9000/v1/models"},
|
||||
// 完整端点原样
|
||||
{"https://api.openai.com/v1/chat/completions", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||
// 尾斜杠
|
||||
{"https://api.openai.com/v1/", "/chat/completions", "https://api.openai.com/v1/chat/completions"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ch := &Channel{BaseURL: c.base}
|
||||
got := ch.UpstreamURL("", c.path)
|
||||
if got != c.want {
|
||||
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.base, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// 分协议 base_url 覆盖
|
||||
ch := &Channel{
|
||||
BaseURL: "https://open.bigmodel.cn/api/paas/v4",
|
||||
BaseURLs: map[string]string{"responses": "https://resp.example.com/v4", "messages": "https://msg.example.com/v1"},
|
||||
}
|
||||
cases2 := []struct{ proto, path, want string }{
|
||||
{"chat", "/chat/completions", "https://open.bigmodel.cn/api/paas/v4/chat/completions"}, // 用主 base
|
||||
{"responses", "/responses", "https://resp.example.com/v4/responses"}, // 用覆盖 base
|
||||
{"messages", "/messages", "https://msg.example.com/v1/messages"}, // 用覆盖 base
|
||||
{"", "/models", "https://open.bigmodel.cn/api/paas/v4/models"}, // 空协议用主 base
|
||||
}
|
||||
for _, c := range cases2 {
|
||||
got := ch.UpstreamURL(c.proto, c.path)
|
||||
if got != c.want {
|
||||
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", c.proto, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user