渠道: 分协议 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:
Sakurasan
2026-08-16 04:31:28 +08:00
co-authored by Claude
parent 7c4e80afac
commit db83972b6f
11 changed files with 484 additions and 246 deletions
@@ -1,14 +1,67 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store" "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 — 渠道的模型绑定列表(含上游映射名)。 // AdminChannelModels GET /api/v1/admin/channels/:id/models — 渠道的模型绑定列表(含上游映射名)。
func (h *Handler) AdminChannelModels(c *gin.Context) { func (h *Handler) AdminChannelModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+52 -32
View File
@@ -34,7 +34,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
} }
out = append(out, gin.H{ out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "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, "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority,
"timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency,
"health_status": ch.HealthStatus, "enabled": ch.Enabled, "health_status": ch.HealthStatus, "enabled": ch.Enabled,
@@ -45,16 +45,34 @@ func (h *Handler) AdminChannels(c *gin.Context) {
} }
type channelBody struct { type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"` Name string `json:"name" binding:"required,min=1,max=64"`
Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据) Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据)
Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置) Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置)
BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点 BaseURL string `json:"base_url"` // 可选:留空按供应商默认;支持前缀或完整端点
APIKey string `json:"api_key"` BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages)
Weight *int `json:"weight"` APIKey string `json:"api_key"`
Priority *int `json:"priority"` Weight *int `json:"weight"`
TimeoutMS *int `json:"timeout_ms"` Priority *int `json:"priority"`
MaxConcurrency *int `json:"max_concurrency"` TimeoutMS *int `json:"timeout_ms"`
Enabled *bool `json:"enabled"` 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:留空按供应商默认;网关按内容智能识别前缀/完整端点。 // resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。
@@ -166,6 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
} }
ch := store.Channel{ ch := store.Channel{
Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL, 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), APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0),
TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16),
HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true),
@@ -185,17 +204,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
return return
} }
var body struct { var body struct {
Name *string `json:"name"` Name *string `json:"name"`
Provider *string `json:"provider"` Provider *string `json:"provider"`
Formats *[]string `json:"formats"` Formats *[]string `json:"formats"`
BaseURL *string `json:"base_url"` BaseURL *string `json:"base_url"`
APIKey *string `json:"api_key"` BaseURLs *map[string]string `json:"base_urls"`
Weight *int `json:"weight"` APIKey *string `json:"api_key"`
Priority *int `json:"priority"` Weight *int `json:"weight"`
TimeoutMS *int `json:"timeout_ms"` Priority *int `json:"priority"`
MaxConcurrency *int `json:"max_concurrency"` TimeoutMS *int `json:"timeout_ms"`
HealthStatus *string `json:"health_status"` MaxConcurrency *int `json:"max_concurrency"`
Enabled *bool `json:"enabled"` HealthStatus *string `json:"health_status"`
Enabled *bool `json:"enabled"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input") resp.Fail(c, http.StatusBadRequest, "invalid input")
@@ -229,6 +249,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
} }
updates["base_url"] = b 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 != "" { if body.APIKey != nil && *body.APIKey != "" {
enc, err := h.a.Enc.Encrypt(*body.APIKey) enc, err := h.a.Enc.Encrypt(*body.APIKey)
if err != nil { if err != nil {
@@ -265,20 +290,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, ferr.Error()) resp.Fail(c, http.StatusBadRequest, ferr.Error())
return return
} }
updates["formats"] = formats // formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化)
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
} }
if len(updates) > 0 { if len(updates) > 0 {
if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update channel") resp.Fail(c, http.StatusInternalServerError, "failed to update channel")
return 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}) 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") resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return return
} }
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key) 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") resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return return
} }
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 15 * time.Second} client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Authorization", "Bearer "+key)
+1
View File
@@ -104,6 +104,7 @@ func NewRouter(a *app.App, gw *proxy.Gateway) *gin.Engine {
admin.DELETE("/channels/:id", h.AdminDeleteChannel) admin.DELETE("/channels/:id", h.AdminDeleteChannel)
admin.POST("/channels/:id/test", h.AdminTestChannel) admin.POST("/channels/:id/test", h.AdminTestChannel)
admin.POST("/channels/:id/models/import", h.AdminImportChannelModels) admin.POST("/channels/:id/models/import", h.AdminImportChannelModels)
admin.GET("/channels/:id/models/remote", h.AdminChannelRemoteModels)
admin.GET("/channels/:id/models", h.AdminChannelModels) admin.GET("/channels/:id/models", h.AdminChannelModels)
admin.POST("/channels/:id/models", h.AdminChannelAddModel) admin.POST("/channels/:id/models", h.AdminChannelAddModel)
admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel) admin.PATCH("/channels/:id/models/:bid", h.AdminChannelUpdateModel)
+1 -2
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"log" "log"
"net/http" "net/http"
"strings"
"sync" "sync"
"time" "time"
@@ -83,7 +82,7 @@ func (h *HealthMonitor) probe(ch *store.Channel) {
h.record(ch, false) h.record(ch, false)
return return
} }
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" url := ch.UpstreamURL("", "/models")
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
+5 -4
View File
@@ -251,16 +251,17 @@ func (g *Gateway) checkBalance(c *gin.Context, u *store.User) bool {
func upstreamPath(proto string) string { func upstreamPath(proto string) string {
switch proto { switch proto {
case convert.ProtoMessages: case convert.ProtoMessages:
return "/v1/messages" return "/messages"
case convert.ProtoResponses: case convert.ProtoResponses:
return "/v1/responses" return "/responses"
default: default:
return "/v1/chat/completions" return "/chat/completions"
} }
} }
// upstreamPlan 描述一次代理请求的上游访问方式。 // upstreamPlan 描述一次代理请求的上游访问方式。
type upstreamPlan struct { type upstreamPlan struct {
proto string // 上游协议(用于分协议 base_url)
path string // 上游路径 path string // 上游路径
body []byte // 已转换的请求体 body []byte // 已转换的请求体
lineConv func([]byte) []byte // 流式逐行转换(nil=直通) lineConv func([]byte) []byte // 流式逐行转换(nil=直通)
@@ -292,7 +293,7 @@ func prepareUpstream(ch *store.Channel, clientProto string, body []byte, upstrea
if target == "" { if target == "" {
return nil, fmt.Errorf("channel %q declares no supported protocol format", ch.Name) 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 { if target != clientProto {
converted, err := convert.ConvertRequest(body, clientProto, target) converted, err := convert.ConvertRequest(body, clientProto, target)
if err != nil { if err != nil {
+6 -16
View File
@@ -44,19 +44,9 @@ func parseBody(c *gin.Context) (*bodyReq, []byte, error) {
return br, body, nil return br, body, nil
} }
// upstreamURL 组装上游地址,智能识别用户填写的 Base URL 形式: // upstreamURL 组装上游地址:按协议选 base_url 再拼资源路径(见 store.Channel.UpstreamURL)。
// - 完整端点(以目标资源路径结尾) → 直接使用 func upstreamURL(ch *store.Channel, proto, path string) string {
// - 已含版本前缀(如 /v1) → 只拼资源路径(/chat/completions 等) return ch.UpstreamURL(proto, path)
// - 纯域名/地址前缀 → 拼完整路径(/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
} }
// doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。 // doProxy 通用代理(M5):遍历候选渠道,按需转换;可安全重试的失败自动故障转移。
@@ -103,7 +93,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
upBody := plan.body upBody := plan.body
// 直通 chat 流式:注入 stream_options.include_usage,保证末块带 usage(OpenAI 行为) // 直通 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 var m map[string]any
if json.Unmarshal(upBody, &m) == nil { if json.Unmarshal(upBody, &m) == nil {
m["stream_options"] = map[string]any{"include_usage": true} 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) ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(ch.TimeoutMS)*time.Millisecond)
defer cancel() 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 { if err != nil {
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request") 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 != "" { if ua := c.GetHeader("User-Agent"); ua != "" {
req.Header.Set("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") req.Header.Set("anthropic-version", "2023-06-01")
} }
for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} { for _, h := range []string{"OpenAI-Organization", "OpenAI-Project", "OpenAI-Beta"} {
+35 -4
View File
@@ -2,7 +2,11 @@
// 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。 // 字段设计对应 PLANNING.md §6:金额/价格 numeric(20,8),token bigint,时间 UTC。
package store package store
import "time" import (
"regexp"
"strings"
"time"
)
// 角色 / 状态枚举(字符串存库,便于阅读与迁移) // 角色 / 状态枚举(字符串存库,便于阅读与迁移)
const ( const (
@@ -82,9 +86,10 @@ type Channel struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Name string `gorm:"uniqueIndex;size:64;not null" json:"name"` Name string `gorm:"uniqueIndex;size:64;not null" json:"name"`
Provider string `gorm:"size:16;not null" json:"provider"` // openai|anthropic|compatible(供应商/默认格式) 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 Formats []string `gorm:"type:jsonb;serializer:json" json:"formats,omitempty"` // 原生支持的协议格式 chat|responses|messages
BaseURL string `gorm:"size:255;not null" json:"base_url"` BaseURL string `gorm:"size:255;not null" json:"base_url"`
APIKeyEnc string `gorm:"size:1024;not null" json:"-"` // AES-GCM 密文 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"` Weight int `gorm:"not null;default:1" json:"weight"`
Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先 Priority int `gorm:"not null;default:0" json:"priority"` // 数值小优先
TimeoutMS int `gorm:"not null;default:120000" json:"timeout_ms"` 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) // Model 全局模型 + 定价(PLANNING §6.4,价格按每百万 token,USD)
type Model struct { type Model struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
+50
View File
@@ -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)
}
}
}
+1
View File
@@ -29,6 +29,7 @@ export interface Channel {
provider: 'openai' | 'anthropic' | 'compatible' provider: 'openai' | 'anthropic' | 'compatible'
formats: string[] // chat | responses | messages formats: string[] // chat | responses | messages
base_url: string base_url: string
base_urls?: Record<string, string> | null // 分协议 base_url 覆盖
api_key_masked: string api_key_masked: string
weight: number weight: number
priority: number priority: number
+174
View File
@@ -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>
+106 -188
View File
@@ -1,13 +1,15 @@
<script setup lang="ts"> <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 { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol' import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
import ChannelModelsDrawer from '@/views/admin/ChannelModelsDrawer.vue'
import Button from '@/components/ui/Button.vue' import Button from '@/components/ui/Button.vue'
import Input from '@/components/ui/Input.vue' import Input from '@/components/ui/Input.vue'
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
import Badge from '@/components/ui/Badge.vue' import Badge from '@/components/ui/Badge.vue'
import type { Channel, ChannelModelMapping } from '@/types' import type { Channel } from '@/types'
const toast = useToastStore() const toast = useToastStore()
const channels = ref<Channel[]>([]) const channels = ref<Channel[]>([])
@@ -15,11 +17,17 @@ const editOpen = ref(false)
const editing = ref<Channel | null>(null) const editing = ref<Channel | null>(null)
const saving = ref(false) const saving = ref(false)
const busyId = ref<number | null>(null) 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({ const form = reactive({
name: '', name: '',
formats: ['chat'] as string[], formats: ['chat'] as string[],
base_url: '', base_url: '',
base_urls: { chat: '', responses: '', messages: '' } as Record<string, string>,
api_key: '', api_key: '',
weight: 1, weight: 1,
priority: 0, priority: 0,
@@ -40,7 +48,9 @@ async function load() {
function openCreate() { function openCreate() {
editing.value = null editing.value = null
Object.assign(form, { 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, weight: 1, priority: 0, timeout_ms: 120000, max_concurrency: 16, enabled: true,
}) })
editOpen.value = true editOpen.value = true
@@ -50,7 +60,13 @@ function openEdit(ch: Channel) {
editing.value = ch editing.value = ch
Object.assign(form, { Object.assign(form, {
name: ch.name, formats: [...(ch.formats?.length ? ch.formats : ['chat'])], 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, weight: ch.weight, priority: ch.priority, timeout_ms: ch.timeout_ms,
max_concurrency: ch.max_concurrency, enabled: ch.enabled, 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) onMounted(load)
</script> </script>
<template> <template>
<div class="mx-auto max-w-6xl"> <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> <div>
<h1 class="text-lg font-semibold">渠道</h1> <h1 class="text-lg font-semibold">渠道</h1>
<p class="text-sm text-muted">接入上游服务,API Key 加密存储</p> <p class="text-sm text-muted">接入上游服务,API Key 加密存储</p>
</div> </div>
<Button @click="openCreate">添加渠道</Button> <Button class="shrink-0" @click="openCreate">添加渠道</Button>
</div> </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"> <div class="overflow-x-auto">
<table class="w-full text-sm"> <table class="w-full text-sm min-w-[820px]">
<thead> <thead>
<tr class="border-b border-edge text-left text-xs text-muted"> <tr class="border-b border-edge text-left text-xs text-muted">
<th scope="col" class="px-4 py-2.5 font-medium">名称</th> <th scope="col" class="px-4 py-2.5 font-medium">名称</th>
@@ -240,8 +205,14 @@ onMounted(load)
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="ch in channels" :key="ch.id" class="table-row"> <template v-for="ch in channels" :key="ch.id">
<td class="px-4 py-2.5 text-ink">{{ ch.name }}</td> <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"> <td class="px-4 py-2.5">
<div class="flex flex-col gap-0.5"> <div class="flex flex-col gap-0.5">
<code <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-xs text-muted">{{ ch.enabled ? '是' : '否' }}</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex justify-end gap-2"> <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 ? '测试中…' : '测试' }} {{ busyId === ch.id ? '测试中…' : '测试' }}
</button> </button>
<button class="text-xs text-muted hover:text-accent" @click="importModels(ch)">导入模型</button> <button class="inline-flex items-center gap-1 text-xs text-muted hover:text-ink" @click="openEdit(ch)">
<button class="text-xs text-muted hover:text-accent" @click="openModels(ch)">模型映射</button> <PhNotePencil :size="13" />
<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>
<button class="inline-flex items-center gap-1 text-xs text-muted hover:text-err" @click="remove(ch)">
<PhTrash :size="13" />
删除
</button>
</div> </div>
</td> </td>
</tr> </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"> <tr v-if="channels.length === 0">
<td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td> <td colspan="7" class="px-4 py-10 text-center text-sm text-muted">还没有渠道,点击「添加渠道」</td>
</tr> </tr>
@@ -308,12 +290,19 @@ onMounted(load)
placeholder="https://api.openai.com/v1" placeholder="https://api.openai.com/v1"
hint="支持前缀或完整端点,如 https://api.openai.com/v1 或 https://api.openai.com/v1/chat/completions;留空按供应商默认" 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 <Input
v-model="form.api_key" v-model="form.api_key"
label="上游 API Key" label="上游 API Key"
:placeholder="editing ? '留空则不修改' : 'sk-...'" :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.weight" label="权重" type="number" />
<Input v-model="form.priority" label="优先级" type="number" /> <Input v-model="form.priority" label="优先级" type="number" />
<Input v-model="form.timeout_ms" label="超时 (ms)" 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> <Button :loading="saving" @click="save">{{ editing ? '保存' : '创建' }}</Button>
</template> </template>
</Modal> </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> </div>
</template> </template>