mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
475 lines
18 KiB
Go
475 lines
18 KiB
Go
package gateway
|
|
|
|
// Agent-facing management API.
|
|
//
|
|
// The Web UI has always driven the gateway through /api/*, so the management
|
|
// surface already exists. What it lacked was anything an agent could *rely* on:
|
|
// no way to discover the contract, no single call that answers "what is the
|
|
// current state", and per-endpoint response shapes that make scripting brittle.
|
|
//
|
|
// This file adds a versioned, self-describing facade under /api/v1 without
|
|
// touching the existing endpoints, so the Web UI keeps working unchanged while
|
|
// agents get a stable contract:
|
|
//
|
|
// GET /api/v1 — machine-readable index of every endpoint
|
|
// GET /api/v1/overview — one call: sources + auto chain + keys + health
|
|
// GET /api/v1/sources — sources, credentials masked
|
|
// GET /api/v1/sources/{name} — one source
|
|
// GET /api/v1/auto — scheduling chain + live slot states
|
|
// GET /api/v1/keys — key metadata (never the secret)
|
|
// GET /api/v1/models — every model id the gateway can route
|
|
// GET /api/v1/health — per-source health, no auth needed beyond the key
|
|
//
|
|
// Auth is the same gateway key as everywhere else (Authorization: Bearer, or
|
|
// ?api_key=, or the gw_key cookie). Read endpoints accept any role; writes
|
|
// still require admin, enforced by the same middleware the UI goes through.
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
|
|
"modelrouter/internal/config"
|
|
)
|
|
|
|
// apiV1Routes dispatches /api/v1/*. Kept separate from routes() so the original
|
|
// switch stays readable and the versioned surface can grow on its own.
|
|
func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
|
path = strings.Trim(path, "/")
|
|
|
|
switch {
|
|
case path == "":
|
|
g.apiV1Index(w, r)
|
|
case path == "overview":
|
|
g.apiV1Overview(w, r)
|
|
case path == "health":
|
|
g.apiV1Health(w, r)
|
|
case path == "models":
|
|
g.apiV1Models(w, r)
|
|
case path == "sources":
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": maskSources(g.core.Sources())})
|
|
case strings.HasPrefix(path, "sources/"):
|
|
name, err := decodePathSegment(strings.TrimPrefix(path, "sources/"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
|
"use PUT/DELETE on /api/sources/{name} to modify a source")
|
|
return
|
|
}
|
|
for _, s := range g.core.Sources() {
|
|
if s.Name == name {
|
|
// ?reveal=credentials is admin-only and is what the Web UI's
|
|
// edit dialog uses: a form that round-trips a source must be
|
|
// able to show the current key, otherwise saving an unrelated
|
|
// field would blank it. Everything else stays masked.
|
|
if r.URL.Query().Get("reveal") == "credentials" {
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden",
|
|
"admin role required to reveal credentials")
|
|
return
|
|
}
|
|
// The edit form round-trips this response, so it needs the credential in the
|
|
// clear AND the durations — see maskSourceFields.
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSourceFields(s, true, true)})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)})
|
|
return
|
|
}
|
|
}
|
|
writeError(w, http.StatusNotFound, "not_found", "no such source: "+name)
|
|
case path == "auto":
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"rules": g.core.AutoRules(),
|
|
"image_rules": g.core.AutoImageRules(),
|
|
"states": g.core.AutoSlotStates(),
|
|
})
|
|
case path == "keys":
|
|
if reqRole(r.Context()) != "admin" {
|
|
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
|
|
return
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
|
"use POST/PUT/DELETE on /api/keys to manage keys")
|
|
return
|
|
}
|
|
keys := g.core.ListKeys()
|
|
out := make([]map[string]interface{}, 0, len(keys))
|
|
for _, k := range keys {
|
|
out = append(out, map[string]interface{}{
|
|
"name": k.Name,
|
|
"role": k.Role,
|
|
"models": k.Models,
|
|
"note": k.Note,
|
|
"created_at": k.CreatedAt,
|
|
"seed": k.Seed,
|
|
// Quotas live on the scope entries (k.Models), echoed above;
|
|
// there is deliberately no key-wide total.
|
|
// The secret itself is never echoed. An operator that needs it
|
|
// already has it from creation time or from config.yaml.
|
|
"key_prefix": maskKey(k.Key),
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"keys": out})
|
|
default:
|
|
g.apiV1Index(w, r)
|
|
}
|
|
}
|
|
|
|
// apiV1Index is the discovery document: it names every endpoint with its
|
|
// method, auth requirement and purpose, so an agent does not have to scrape the
|
|
// HTML to find out what it can call.
|
|
func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
|
|
type ep struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Auth string `json:"auth"`
|
|
Summary string `json:"summary"`
|
|
WriteEffect string `json:"writes,omitempty"`
|
|
}
|
|
index := map[string]interface{}{
|
|
"api_version": "v1",
|
|
"description": "modelrouter management API. Authenticate with a gateway key: " +
|
|
"'Authorization: Bearer <key>', '?api_key=<key>', or the gw_key cookie. " +
|
|
"Read endpoints accept any role; mutations require an admin key.",
|
|
"discovery": []ep{
|
|
{Method: "GET", Path: "/api/v1", Auth: "any", Summary: "this document"},
|
|
{Method: "GET", Path: "/api/v1/overview", Auth: "any",
|
|
Summary: "current state in one call: sources, auto chain, key count, source health"},
|
|
{Method: "GET", Path: "/api/v1/health", Auth: "any",
|
|
Summary: "per-source health snapshot only"},
|
|
{Method: "GET", Path: "/api/v1/models", Auth: "any",
|
|
Summary: "every model id the gateway can route, grouped by owning source"},
|
|
{Method: "GET", Path: "/api/v1/sources", Auth: "any",
|
|
Summary: "all sources with credentials masked"},
|
|
{Method: "GET", Path: "/api/v1/sources/{name}", Auth: "any", Summary: "one source"},
|
|
{Method: "GET", Path: "/api/v1/auto", Auth: "any",
|
|
Summary: "AUTO scheduling chain and live per-slot state"},
|
|
{Method: "GET", Path: "/api/v1/keys", Auth: "admin",
|
|
Summary: "gateway key metadata; secrets are never returned"},
|
|
|
|
{Method: "GET", Path: "/api/sources", Auth: "admin", Summary: "raw source list (includes api_key)"},
|
|
{Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source",
|
|
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
|
|
{Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source",
|
|
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
|
|
{Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source",
|
|
WriteEffect: "writes config.yaml"},
|
|
|
|
{Method: "GET", Path: "/api/auto", Auth: "any", Summary: "scheduling chain (readable by any role)"},
|
|
{Method: "PUT", Path: "/api/auto", Auth: "admin", Summary: "replace the scheduling chain",
|
|
WriteEffect: "writes config.yaml (the AUTO chain lives here)"},
|
|
|
|
{Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"},
|
|
{Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key",
|
|
WriteEffect: "writes config.yaml"},
|
|
{Method: "PUT", Path: "/api/keys/{key}", Auth: "admin",
|
|
Summary: "update a gateway key (name, role, model scopes and their per-model quotas)",
|
|
WriteEffect: "writes config.yaml"},
|
|
{Method: "DELETE", Path: "/api/keys/{key}", Auth: "admin",
|
|
Summary: "delete a gateway key — the path segment is the KEY itself, not its name",
|
|
WriteEffect: "writes config.yaml"},
|
|
|
|
{Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"},
|
|
{Method: "GET", Path: "/api/stats", Auth: "any", Summary: "usage aggregates"},
|
|
{Method: "GET", Path: "/api/stats/records", Auth: "any", Summary: "paged request records"},
|
|
{Method: "GET", Path: "/api/adapters", Auth: "admin", Summary: "installed Lua adapters"},
|
|
{Method: "POST", Path: "/api/sources/discover", Auth: "admin",
|
|
Summary: "probe a base_url+api_key for its model catalog",
|
|
// Probing is read-only with respect to configuration: it writes
|
|
// nothing, which is why it is safe to call repeatedly while the
|
|
// operator is still typing a key.
|
|
WriteEffect: "none",
|
|
},
|
|
{Method: "POST", Path: "/api/sources/discover/apply", Auth: "admin",
|
|
Summary: "probe and write the source in one step",
|
|
// Requires replace_models=true when the source already exists,
|
|
// so re-running discovery cannot silently discard a hand-tuned
|
|
// model list. An empty api_key keeps the stored credential.
|
|
WriteEffect: "creates or replaces a source and its model list",
|
|
},
|
|
{Method: "GET", Path: "/api/source_templates", Auth: "admin", Summary: "source templates"},
|
|
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
|
|
},
|
|
"conventions": map[string]interface{}{
|
|
"errors": "{ \"error\": { \"type\": <code>, \"message\": <text> } }",
|
|
"path_escape": "URL-encode source and key names; {name} is a single path segment",
|
|
"idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name; " +
|
|
"PUT takes the name from the path and rejects a body name that disagrees",
|
|
"partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " +
|
|
"current credential; model_ids adds models to the existing list instead of replacing it, " +
|
|
"so a one-field edit never needs to read the source first",
|
|
"optional_fields": "proxy_url, api_key_env, timeout and queue_timeout use presence semantics: " +
|
|
"OMITTED from the request keeps the stored value, present (even as \"\") overwrites it. " +
|
|
"The upsert rewrites the whole source, so without this a one-field edit would silently reset " +
|
|
"them — and api_key_env in particular is only visible as a credential failure on the NEXT " +
|
|
"upstream call. api_key deliberately keeps the older \"empty means inherit\" rule instead, " +
|
|
"because losing a credential breaks the source while losing a proxy only changes its route.",
|
|
"durations": "timeout and queue_timeout are Go duration strings (\"300s\", \"5m\", \"1h\"); " +
|
|
"the empty string clears them back to the config defaults",
|
|
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
|
|
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
|
|
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +
|
|
"that round-trips a source must show the current key or saving another field would blank it.",
|
|
},
|
|
}
|
|
writeJSON(w, http.StatusOK, index)
|
|
}
|
|
|
|
// apiV1Overview is the "what is the current state" call an agent makes first.
|
|
// One round trip instead of five.
|
|
func (g *Gateway) apiV1Overview(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
sources := g.core.Sources()
|
|
names := make([]string, 0, len(sources))
|
|
modelCount := 0
|
|
for _, s := range sources {
|
|
names = append(names, s.Name)
|
|
modelCount += len(s.Models)
|
|
}
|
|
keys := g.core.ListKeys()
|
|
adminCount, userCount := 0, 0
|
|
for _, k := range keys {
|
|
if k.Role == "admin" {
|
|
adminCount++
|
|
} else {
|
|
userCount++
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"api_version": "v1",
|
|
"summary": map[string]interface{}{
|
|
"source_count": len(sources),
|
|
"model_count": modelCount,
|
|
"gateway_key_count": len(keys),
|
|
"admin_key_count": adminCount,
|
|
"user_key_count": userCount,
|
|
"auto_slots": len(g.core.AutoRules()),
|
|
},
|
|
"sources": maskSources(sources),
|
|
"auto": g.core.AutoRules(),
|
|
"auto_image": g.core.AutoImageRules(),
|
|
"health": g.sourceHealthBrief(),
|
|
"caller": map[string]interface{}{
|
|
"role": reqRole(r.Context()),
|
|
"key": maskKey(reqKey(r.Context())),
|
|
},
|
|
})
|
|
}
|
|
|
|
func (g *Gateway) apiV1Health(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"sources": g.sourceHealthBrief()})
|
|
}
|
|
|
|
// apiV1Models lists routable model ids per source, which is what an agent needs
|
|
// to build a valid request — /v1/models flattens them and hides the owner.
|
|
func (g *Gateway) apiV1Models(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
|
|
return
|
|
}
|
|
bySource := map[string][]string{}
|
|
var all []string
|
|
for _, s := range g.core.Sources() {
|
|
for _, m := range s.Models {
|
|
bySource[s.Name] = append(bySource[s.Name], m.ID)
|
|
all = append(all, m.ID)
|
|
}
|
|
}
|
|
sort.Strings(all)
|
|
all = dedupeStrings(all)
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"count": len(all),
|
|
"models": all,
|
|
"by_source": bySource,
|
|
"note": "request a model as \"<source>:<model>\" to pin one source, or the bare id to let the gateway choose",
|
|
})
|
|
}
|
|
|
|
// ---- helpers ----
|
|
|
|
// maskSources returns sources with credentials replaced by a presence marker.
|
|
// The UI still uses /api/sources (which returns real values, because its edit
|
|
// form round-trips them); this is the safe view for programmatic callers.
|
|
func maskSources(srcs []config.Source) []map[string]interface{} {
|
|
out := make([]map[string]interface{}, 0, len(srcs))
|
|
for _, s := range srcs {
|
|
out = append(out, maskSource(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func maskSource(s config.Source) map[string]interface{} {
|
|
return maskSourceFields(s, false, false)
|
|
}
|
|
|
|
// maskSourceFields builds the API view of a source.
|
|
//
|
|
// revealCredentials swaps the masked api_key for the real one — admin-only, and
|
|
// used by the WebUI edit dialog, whose form has to round-trip the whole source
|
|
// or saving an unrelated field would blank the key.
|
|
//
|
|
// exposeDurations adds the two durations as strings, matching what the write
|
|
// path accepts. It rides along with the credential reveal because both exist for
|
|
// the same reason: config.Source tags Timeout/QueueTimeout `json:"-"`, so a
|
|
// plain marshal of the struct omits them. A form that cannot SEE the stored
|
|
// timeout would clear it on every save, since the timeout box is always sent.
|
|
func maskSourceFields(s config.Source, revealCredentials, exposeDurations bool) map[string]interface{} {
|
|
key := maskKey(s.APIKey)
|
|
if revealCredentials {
|
|
key = s.APIKey
|
|
}
|
|
m := map[string]interface{}{
|
|
"name": s.Name,
|
|
"base_url": s.BaseURL,
|
|
"adapter": s.Adapter,
|
|
"endpoint": s.Endpoint,
|
|
"image_endpoint": s.ImageEndpoint,
|
|
"api_key": key,
|
|
"api_key_set": s.APIKey != "",
|
|
"models": s.Models,
|
|
"headers": maskHeaders(s.Headers),
|
|
"proxy_url": s.ProxyURL,
|
|
"api_key_env": s.APIKeyEnv,
|
|
"meta": s.Meta,
|
|
"temperature": s.Temperature,
|
|
"max_tokens": s.MaxTokens,
|
|
"max_concurrent": s.MaxConcurrent,
|
|
"rpm": s.RPM,
|
|
}
|
|
if exposeDurations {
|
|
m["timeout"] = s.Timeout.String()
|
|
m["queue_timeout"] = s.QueueTimeout.String()
|
|
}
|
|
return m
|
|
}
|
|
|
|
func maskKey(k string) string {
|
|
if k == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(k, "enc:v1:") {
|
|
return "(sealed)"
|
|
}
|
|
if len(k) <= 10 {
|
|
return k[:2] + "…"
|
|
}
|
|
return k[:6] + "…" + k[len(k)-4:]
|
|
}
|
|
|
|
func maskHeaders(h map[string]string) map[string]string {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(h))
|
|
for k, v := range h {
|
|
out[k] = maskKey(v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sourceHealthBrief reuses the same registry status the UI's /api/status shows,
|
|
// so the agent view and the UI view cannot drift apart. Recent-traffic counters
|
|
// come from the same stats window, because a source that is actually serving
|
|
// traffic must never look down just because a probe was rate-limited.
|
|
func (g *Gateway) sourceHealthBrief() []map[string]interface{} {
|
|
sts := g.core.Registry().Status()
|
|
recent := g.stats.SourceRecent(300)
|
|
avgs := g.stats.SourceAverages(300)
|
|
for i := range sts {
|
|
if v, ok := recent[sts[i].Name]; ok {
|
|
sts[i].RecentOK = v[0]
|
|
sts[i].RecentErr = v[1]
|
|
}
|
|
if a, ok := avgs[sts[i].Name]; ok {
|
|
sts[i].AvgFirstByteMs = a.AvgFirstByteMs
|
|
sts[i].AvgTokPerS = a.AvgTokPerS
|
|
}
|
|
}
|
|
out := make([]map[string]interface{}, 0, len(sts))
|
|
for _, s := range sts {
|
|
row := map[string]interface{}{
|
|
"name": s.Name,
|
|
"adapter": s.Adapter,
|
|
"healthy": s.Healthy,
|
|
"available": s.Available,
|
|
"live_available": s.LiveAvailable,
|
|
"model_count": len(s.Models),
|
|
}
|
|
if s.LastError != "" {
|
|
row["last_error"] = s.LastError
|
|
}
|
|
if s.LastChecked > 0 {
|
|
row["last_checked"] = s.LastChecked
|
|
}
|
|
if s.RecentOK > 0 || s.RecentErr > 0 {
|
|
row["recent_ok"] = s.RecentOK
|
|
row["recent_err"] = s.RecentErr
|
|
}
|
|
if s.AvgFirstByteMs > 0 {
|
|
row["avg_first_byte_ms"] = s.AvgFirstByteMs
|
|
}
|
|
if s.AvgTokPerS > 0 {
|
|
row["avg_tok_per_s"] = s.AvgTokPerS
|
|
}
|
|
if s.FailCount > 0 {
|
|
row["fail_count"] = s.FailCount
|
|
}
|
|
if s.Permanent {
|
|
row["permanent"] = true
|
|
}
|
|
out = append(out, row)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// decodePathSegment URL-decodes one path segment and rejects an empty result,
|
|
// so a name containing a slash cannot be silently mis-resolved.
|
|
func decodePathSegment(seg string) (string, error) {
|
|
s, err := url.PathUnescape(seg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if s == "" {
|
|
return "", errEmptySegment
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func dedupeStrings(in []string) []string {
|
|
out := in[:0]
|
|
var last string
|
|
for i, s := range in {
|
|
if i == 0 || s != last {
|
|
out = append(out, s)
|
|
}
|
|
last = s
|
|
}
|
|
return out
|
|
}
|
|
|
|
// errEmptySegment marks a path like /api/v1/sources/ with no name after it.
|
|
var errEmptySegment = errors.New("empty path segment")
|