渠道: 分协议 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export interface Channel {
|
||||
provider: 'openai' | 'anthropic' | 'compatible'
|
||||
formats: string[] // chat | responses | messages
|
||||
base_url: string
|
||||
base_urls?: Record<string, string> | null // 分协议 base_url 覆盖
|
||||
api_key_masked: string
|
||||
weight: number
|
||||
priority: number
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhArrowsClockwise, PhPlus, PhX } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import type { Channel, ChannelModelMapping } from '@/types'
|
||||
|
||||
const props = defineProps<{ channel: Channel }>()
|
||||
const toast = useToastStore()
|
||||
|
||||
const mappings = ref<ChannelModelMapping[]>([])
|
||||
const remote = ref<string[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const addForm = reactive({ custom_name: '', upstream_model: '' })
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const { data } = await http.get(`/admin/channels/${props.channel.id}/models`)
|
||||
mappings.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRemote() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await http.get(`/admin/channels/${props.channel.id}/models/remote`)
|
||||
remote.value = data.data.items
|
||||
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
||||
selected.value = remote.value.filter((r) => bound.has(r))
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addSelected() {
|
||||
const bound = new Set(mappings.value.map((m) => m.upstream_model))
|
||||
let added = 0
|
||||
for (const name of selected.value) {
|
||||
if (bound.has(name)) continue
|
||||
try {
|
||||
await http.post(`/admin/channels/${props.channel.id}/models`, { upstream_model: name })
|
||||
added++
|
||||
} catch {
|
||||
/* 单个失败不中断 */
|
||||
}
|
||||
}
|
||||
toast.ok(added ? `已添加 ${added} 个模型` : '所选均已添加')
|
||||
await load()
|
||||
}
|
||||
|
||||
async function addManual() {
|
||||
if (!addForm.upstream_model.trim()) return
|
||||
try {
|
||||
await http.post(`/admin/channels/${props.channel.id}/models`, {
|
||||
upstream_model: addForm.upstream_model.trim(),
|
||||
custom_name: addForm.custom_name.trim(),
|
||||
})
|
||||
toast.ok('已添加')
|
||||
addForm.custom_name = ''
|
||||
addForm.upstream_model = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUpstream(b: ChannelModelMapping) {
|
||||
try {
|
||||
await http.patch(`/admin/channels/${props.channel.id}/models/${b.id}`, {
|
||||
upstream_model: b.upstream_model,
|
||||
})
|
||||
toast.ok('已更新')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(b: ChannelModelMapping) {
|
||||
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
|
||||
try {
|
||||
await http.delete(`/admin/channels/${props.channel.id}/models/${b.id}`)
|
||||
toast.ok('已解除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- 当前支持的模型 -->
|
||||
<div>
|
||||
<p class="mb-1.5 text-xs font-medium text-muted">当前支持的模型({{ mappings.length }})</p>
|
||||
<div v-if="mappings.length" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="b in mappings"
|
||||
:key="b.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-edge bg-surface px-2 py-1 font-mono text-[11px] text-muted"
|
||||
>
|
||||
<span class="text-ink">{{ b.model_name }}</span>
|
||||
<span class="opacity-60">→</span>
|
||||
<input
|
||||
v-model="b.upstream_model"
|
||||
class="w-28 rounded border border-transparent bg-transparent px-1 text-[11px] text-accent outline-none transition focus:border-accent/50 focus:bg-surface2"
|
||||
@change="saveUpstream(b)"
|
||||
/>
|
||||
<button class="text-muted hover:text-err" aria-label="解除" @click="remove(b)">
|
||||
<PhX :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-muted">尚未添加模型</p>
|
||||
</div>
|
||||
|
||||
<!-- 从接口拉取 + 勾选 -->
|
||||
<div class="border-t border-edge pt-3">
|
||||
<div class="mb-1.5 flex items-center justify-between">
|
||||
<p class="text-xs font-medium text-muted">从接口拉取模型</p>
|
||||
<Button size="sm" variant="ghost" :loading="loading" @click="fetchRemote">
|
||||
<PhArrowsClockwise :size="13" />
|
||||
拉取
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="remote.length" class="flex max-h-36 flex-wrap gap-2 overflow-y-auto">
|
||||
<label
|
||||
v-for="m in remote"
|
||||
:key="m"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-md border border-edge2 px-2 py-1 font-mono text-[11px] text-muted transition select-none"
|
||||
:class="selected.includes(m) ? 'border-accent bg-accent-soft text-ink' : 'hover:border-edge'"
|
||||
>
|
||||
<input v-model="selected" type="checkbox" :value="m" class="size-3.5 accent-[var(--color-accent)]" />
|
||||
{{ m }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="remote.length" class="mt-2">
|
||||
<Button size="sm" @click="addSelected">
|
||||
<PhPlus :size="13" />
|
||||
添加所选({{ selected.length }})
|
||||
</Button>
|
||||
</div>
|
||||
<p v-else-if="!loading" class="text-xs text-muted">点「拉取」获取渠道接口返回的模型,勾选需要的加入</p>
|
||||
</div>
|
||||
|
||||
<!-- 手动添加 -->
|
||||
<div class="flex items-center gap-2 border-t border-edge pt-3">
|
||||
<input
|
||||
v-model="addForm.custom_name"
|
||||
placeholder="自定义名称(可选)"
|
||||
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@keyup.enter="addManual"
|
||||
/>
|
||||
<input
|
||||
v-model="addForm.upstream_model"
|
||||
placeholder="上游模型名"
|
||||
class="h-8 min-w-0 flex-1 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@keyup.enter="addManual"
|
||||
/>
|
||||
<Button size="sm" class="shrink-0" @click="addManual">
|
||||
<PhPlus :size="13" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { PhCaretDown, PhPulse, PhNotePencil, PhTrash, PhStack } from '@phosphor-icons/vue'
|
||||
import { http, errMsg } from '@/api/client'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||||
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Input from '@/components/ui/Input.vue'
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import type { Channel, ChannelModelMapping } from '@/types'
|
||||
import type { Channel } from '@/types'
|
||||
|
||||
const toast = useToastStore()
|
||||
const channels = ref<Channel[]>([])
|
||||
@@ -15,11 +17,17 @@ const editOpen = ref(false)
|
||||
const editing = ref<Channel | null>(null)
|
||||
const saving = ref(false)
|
||||
const busyId = ref<number | null>(null)
|
||||
const expandedId = ref<number | null>(null)
|
||||
|
||||
function toggleDrawer(ch: Channel) {
|
||||
expandedId.value = expandedId.value === ch.id ? null : ch.id
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
formats: ['chat'] as string[],
|
||||
base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
|
||||
api_key: '',
|
||||
weight: 1,
|
||||
priority: 0,
|
||||
@@ -40,7 +48,9 @@ async function load() {
|
||||
function openCreate() {
|
||||
editing.value = null
|
||||
Object.assign(form, {
|
||||
name: '', formats: ['chat'], base_url: '', api_key: '',
|
||||
name: '', formats: ['chat'], base_url: '',
|
||||
base_urls: { chat: '', responses: '', messages: '' },
|
||||
api_key: '',
|
||||
weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
|
||||
})
|
||||
editOpen.value = true
|
||||
@@ -50,7 +60,13 @@ function openEdit(ch: Channel) {
|
||||
editing.value = ch
|
||||
Object.assign(form, {
|
||||
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])],
|
||||
base_url: ch.base_url, api_key: '',
|
||||
base_url: ch.base_url,
|
||||
base_urls: {
|
||||
chat: ch.base_urls?.chat ?? '',
|
||||
responses: ch.base_urls?.responses ?? '',
|
||||
messages: ch.base_urls?.messages ?? '',
|
||||
},
|
||||
api_key: '',
|
||||
weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
|
||||
max_concurrency: ch.max_concurrency, enabled: ch.enabled,
|
||||
})
|
||||
@@ -111,123 +127,72 @@ async function testChannel(ch: Channel) {
|
||||
}
|
||||
}
|
||||
|
||||
async function importModels(ch: Channel) {
|
||||
busyId.value = ch.id
|
||||
try {
|
||||
const { data } = await http.post(`/admin/channels/${ch.id}/models/import`)
|
||||
toast.ok(`已导入 ${data.data.imported} 个模型`)
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// --- 渠道模型映射 ---
|
||||
const modelsOpen = ref(false)
|
||||
const mappingChan = ref<Channel | null>(null)
|
||||
const mappings = ref<ChannelModelMapping[]>([])
|
||||
const availableModels = ref<string[]>([]) // 已存在模型名,供上游模型输入联想
|
||||
const mappingForm = reactive({ upstream_model: '', custom_name: '', weight: 1 })
|
||||
const showModelSuggestions = ref(false)
|
||||
const filteredModels = computed(() => {
|
||||
const q = mappingForm.upstream_model.trim().toLowerCase()
|
||||
if (!q) return availableModels.value
|
||||
return availableModels.value.filter((m) => m.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function pickSuggestion(m: string) {
|
||||
mappingForm.upstream_model = m
|
||||
showModelSuggestions.value = false
|
||||
}
|
||||
|
||||
async function openModels(ch: Channel) {
|
||||
mappingChan.value = ch
|
||||
mappingForm.upstream_model = ''
|
||||
mappingForm.custom_name = ''
|
||||
mappingForm.weight = 1
|
||||
modelsOpen.value = true
|
||||
await Promise.all([loadMappings(), loadAvailableModels()])
|
||||
}
|
||||
|
||||
async function loadMappings() {
|
||||
if (!mappingChan.value) return
|
||||
try {
|
||||
const { data } = await http.get(`/admin/channels/${mappingChan.value.id}/models`)
|
||||
mappings.value = data.data.items
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableModels() {
|
||||
try {
|
||||
const { data } = await http.get('/admin/models')
|
||||
availableModels.value = (data.data.items as { name: string }[]).map((m) => m.name)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
async function addMapping() {
|
||||
if (!mappingChan.value || !mappingForm.upstream_model) return
|
||||
try {
|
||||
await http.post(`/admin/channels/${mappingChan.value.id}/models`, {
|
||||
upstream_model: mappingForm.upstream_model,
|
||||
custom_name: mappingForm.custom_name,
|
||||
weight: Number(mappingForm.weight) || 1,
|
||||
})
|
||||
toast.ok('已添加')
|
||||
mappingForm.upstream_model = ''
|
||||
mappingForm.custom_name = ''
|
||||
showModelSuggestions.value = false
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMapping(b: ChannelModelMapping) {
|
||||
if (!mappingChan.value) return
|
||||
try {
|
||||
await http.patch(`/admin/channels/${mappingChan.value.id}/models/${b.id}`, {
|
||||
upstream_model: b.upstream_model,
|
||||
})
|
||||
toast.ok('已更新')
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapping(b: ChannelModelMapping) {
|
||||
if (!mappingChan.value) return
|
||||
if (!confirm(`解除模型 ${b.model_name} 的绑定?`)) return
|
||||
try {
|
||||
await http.delete(`/admin/channels/${mappingChan.value.id}/models/${b.id}`)
|
||||
toast.ok('已解除')
|
||||
await loadMappings()
|
||||
} catch (e) {
|
||||
toast.err(errMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-lg font-semibold">渠道</h1>
|
||||
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
|
||||
</div>
|
||||
<Button @click="openCreate">添加渠道</Button>
|
||||
<Button class="shrink-0" @click="openCreate">添加渠道</Button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<!-- 移动端:卡片列表 -->
|
||||
<div class="space-y-3 md:hidden">
|
||||
<div v-for="ch in channels" :key="ch.id" class="card p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-ink">{{ ch.name }}</p>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1">
|
||||
<code
|
||||
v-for="f in ch.formats || []"
|
||||
:key="f"
|
||||
class="rounded bg-surface2 px-1.5 py-0.5 font-mono text-[10px] text-muted"
|
||||
>{{ protocolShort(f) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1.5">
|
||||
<Badge :variant="ch.health_status === 'healthy' ? 'ok' : ch.health_status === 'cooldown' ? 'err' : 'warn'">
|
||||
{{ ch.health_status }}
|
||||
</Badge>
|
||||
<Badge :variant="ch.enabled ? 'ok' : 'neutral'">{{ ch.enabled ? '启用' : '停用' }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 truncate font-mono text-[11px] text-muted">{{ ch.base_url }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-x-3 gap-y-1.5 border-t border-edge pt-3">
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<PhPulse :size="13" />
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-accent hover:text-accent-strong" @click="toggleDrawer(ch)">
|
||||
<PhStack :size="13" />
|
||||
支持的模型 {{ expandedId === ch.id ? '▴' : '▾' }}
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||
<PhNotePencil :size="13" />
|
||||
编辑
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||
<PhTrash :size="13" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expandedId === ch.id" class="mt-3 border-t border-edge pt-3">
|
||||
<ChannelModelsDrawer :channel="ch" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="channels.length === 0" class="card px-4 py-10 text-center text-sm text-muted">
|
||||
还没有渠道,点击「添加渠道」
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端:表格 -->
|
||||
<div class="card hidden md:block">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<table class="w-full text-sm min-w-[820px]">
|
||||
<thead>
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-4 py-2.5 font-medium">名称</th>
|
||||
@@ -240,8 +205,14 @@ onMounted(load)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ch in channels" :key="ch.id" class="table-row">
|
||||
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td>
|
||||
<template v-for="ch in channels" :key="ch.id">
|
||||
<tr class="table-row">
|
||||
<td class="px-4 py-2.5">
|
||||
<button class="inline-flex items-center gap-1.5 text-ink transition hover:text-accent" @click="toggleDrawer(ch)">
|
||||
<span class="truncate">{{ ch.name }}</span>
|
||||
<PhCaretDown :size="12" class="shrink-0 text-muted transition-transform" :class="expandedId === ch.id ? 'rotate-180' : ''" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<code
|
||||
@@ -261,16 +232,27 @@ onMounted(load)
|
||||
<td class="px-4 py-2.5 text-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
|
||||
<td class="px-4 py-2.5 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-accent" :disabled="busyId === ch.id" @click="testChannel(ch)">
|
||||
<PhPulse :size="13" />
|
||||
{{ busyId === ch.id ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button>
|
||||
<button class="text-xs text-muted hover:text-accent" @click="openModels(ch)">模型映射</button>
|
||||
<button class="text-xs text-muted hover:text-ink" @click="openEdit(ch)">编辑</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="remove(ch)">删除</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
|
||||
<PhNotePencil :size="13" />
|
||||
编辑
|
||||
</button>
|
||||
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
|
||||
<PhTrash :size="13" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandedId === ch.id" class="bg-surface/40">
|
||||
<td colspan="7" class="px-4 py-3">
|
||||
<ChannelModelsDrawer :channel="ch" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="channels.length === 0">
|
||||
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
|
||||
</tr>
|
||||
@@ -308,12 +290,19 @@ onMounted(load)
|
||||
placeholder="https://api.openai.com/v1"
|
||||
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认"
|
||||
/>
|
||||
<div class="space-y-3 rounded-md border border-edge p-3">
|
||||
<p class="text-xs font-medium text-muted">分协议 Base URL(可选,如智谱三种格式不同)</p>
|
||||
<Input v-model="form.base_urls.chat" label="OpenAI Chat Completions" placeholder="留空用主 Base URL" />
|
||||
<Input v-model="form.base_urls.responses" label="OpenAI Responses" placeholder="留空用主 Base URL" />
|
||||
<Input v-model="form.base_urls.messages" label="Anthropic Messages" placeholder="留空用主 Base URL" />
|
||||
<p class="text-xs text-muted">网关按协议选对应 base_url 直通,无需为每种格式建多个渠道</p>
|
||||
</div>
|
||||
<Input
|
||||
v-model="form.api_key"
|
||||
label="上游 API Key"
|
||||
:placeholder="editing ? '留空则不修改' : 'sk-...'"
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input v-model="form.weight" label="权重" type="number" />
|
||||
<Input v-model="form.priority" label="优先级" type="number" />
|
||||
<Input v-model="form.timeout_ms" label="超时 (ms)" type="number" />
|
||||
@@ -325,76 +314,5 @@ onMounted(load)
|
||||
<Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 模型名称映射 -->
|
||||
<Modal :open="modelsOpen" :title="`支持的模型 · ${mappingChan?.name}`" @close="modelsOpen = false">
|
||||
<div class="mb-3 text-xs text-muted">
|
||||
客户端调用「客户端名称」,网关转发为「上游模型」。无 /models 接口的渠道可手工添加。
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||
<th scope="col" class="px-3 py-2 font-medium">客户端名称</th>
|
||||
<th scope="col" class="px-3 py-2 font-medium">上游模型</th>
|
||||
<th scope="col" class="px-3 py-2 font-medium">权重</th>
|
||||
<th scope="col" class="px-3 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in mappings" :key="b.id" class="border-b border-edge last:border-0">
|
||||
<td class="px-3 py-2 font-mono text-xs text-ink">{{ b.model_name }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<input
|
||||
v-model="b.upstream_model"
|
||||
class="h-8 w-full min-w-36 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs text-ink outline-none focus:border-accent"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-3 py-2 mono-num text-xs text-muted">{{ b.weight }}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<button class="text-xs text-muted hover:text-ink" @click="saveMapping(b)">保存</button>
|
||||
<button class="text-xs text-muted hover:text-err" @click="deleteMapping(b)">解除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="mappings.length === 0">
|
||||
<td colspan="4" class="px-3 py-6 text-center text-xs text-muted">尚未添加模型,可在下方手工填写</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-3 flex items-center gap-2 border-t border-edge pt-3">
|
||||
<input
|
||||
v-model="mappingForm.custom_name"
|
||||
placeholder="自定义名称(可选)"
|
||||
class="h-9 w-36 shrink-0 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@keyup.enter="addMapping"
|
||||
/>
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<input
|
||||
v-model="mappingForm.upstream_model"
|
||||
placeholder="上游模型名"
|
||||
class="h-9 w-full rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||
@focus="showModelSuggestions = true"
|
||||
@input="showModelSuggestions = true"
|
||||
@keydown.enter="addMapping"
|
||||
@keydown.esc="showModelSuggestions = false"
|
||||
/>
|
||||
<div
|
||||
v-if="showModelSuggestions && filteredModels.length"
|
||||
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-40 overflow-y-auto rounded-md border border-edge bg-surface py-1 shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="m in filteredModels"
|
||||
:key="m"
|
||||
class="block w-full truncate px-2.5 py-1.5 text-left font-mono text-xs text-ink hover:bg-surface2"
|
||||
@mousedown.prevent="pickSuggestion(m)"
|
||||
>
|
||||
{{ m }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" class="shrink-0" @click="addMapping">添加</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user