Add available-models admin tab with live model list and latency probing
build / build (push) Successful in 2m29s
build / build (push) Successful in 2m29s
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
- 上游 SSE 直接透传为 OpenAI SSE;非流式请求在本地聚合为 OpenAI Chat Completion JSON。
|
||||||
- OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
|
- OpenAI 函数/工具调用:支持 `tools`、`tool_choice`、流式 `delta.tool_calls`、非流式 `message.tool_calls` 以及 `role: tool` 结果续传。
|
||||||
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
|
- Token 消耗统计:流式与非流式请求均解析上游 `usage`,按模型/按日/最近明细写入本地 SQLite(`zhanlu.db`),凭据也一并持久化在同一个库中。管理页提供 `GET /admin/stats` 可视化与 `GET /api/stats` JSON 接口。
|
||||||
|
- 可用模型:管理后台新增「可用模型」tab,实时拉取上游 `/gateway/v1/model/info` 返回的模型列表,并提供单模型可用性及首字延时(TTFT)探测(`GET /api/models`、`POST /api/models/test`)。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
@@ -234,6 +235,22 @@ curl http://127.0.0.1:8080/v1/models `
|
|||||||
|
|
||||||
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
|
设置 `ZHANLU_STATS_DISABLED=true` 可停止写入统计。
|
||||||
|
|
||||||
|
## 可用模型
|
||||||
|
|
||||||
|
管理后台「可用模型」tab(位于「Token 统计」之后)实时展示当前上游接口返回的模型列表,并提供单模型可用性与延时探测:
|
||||||
|
|
||||||
|
- 进入 tab 时自动拉取一次,也可随时点击「刷新」重新获取。
|
||||||
|
- 每个模型行可单独「测试」,或点击「全部测试」依次探测所有模型。
|
||||||
|
- 探测向上游 `/chat/completions` 发送一条极简流式请求(`hi`),测量首字延时(TTFT,首个 SSE 数据块到达时间)与总耗时,并在收到首个内容块后立即关闭连接,避免消耗额外 token。
|
||||||
|
- 探测请求不计入 Token 统计。
|
||||||
|
|
||||||
|
对应 JSON 接口(需先登录管理页面):
|
||||||
|
|
||||||
|
- `GET /api/models`:实时返回上游模型列表 `{"ok":true,"models":["GLM-4.7",...]}`。
|
||||||
|
- `POST /api/models/test`:请求体 `{"model":"<model_id>"}`,返回 `{"ok":true,"model":"...","available":true,"ttft_ms":123,"total_ms":456}` 或 `{"ok":true,"model":"...","available":false,"error":"...","total_ms":789}`。
|
||||||
|
|
||||||
|
探测超时上限为 30 秒(`modelTestTimeout`);凭据未配置或 API Key 缺失时 `GET /api/models` 与 `POST /api/models/test` 会按需自动换取 API Key,与聊天接口一致。
|
||||||
|
|
||||||
## 安全说明
|
## 安全说明
|
||||||
|
|
||||||
- `zhanlu.db` 数据库包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。
|
- `zhanlu.db` 数据库包含明文 `AccessKey`、`SecretKey`、`Token` 和 `apiKey`,请不要提交到仓库。
|
||||||
|
|||||||
+269
-1
@@ -61,6 +61,8 @@ func (s *Server) routes() {
|
|||||||
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
s.mux.HandleFunc("POST /api/sso/exchange", s.withLoginSession(s.exchangeSSOCode))
|
||||||
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
s.mux.HandleFunc("GET /api/stats", s.withLoginSession(s.getStats))
|
||||||
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
s.mux.HandleFunc("POST /api/stats/reset", s.withLoginSession(s.resetStats))
|
||||||
|
s.mux.HandleFunc("GET /api/models", s.withLoginSession(s.getModels))
|
||||||
|
s.mux.HandleFunc("POST /api/models/test", s.withLoginSession(s.testModel))
|
||||||
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
s.mux.HandleFunc("GET /v1/models", s.withAPIKey(s.models))
|
||||||
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
s.mux.HandleFunc("POST /v1/chat/completions", s.withAPIKey(s.chatCompletions))
|
||||||
}
|
}
|
||||||
@@ -593,6 +595,159 @@ func (s *Server) resetStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// modelTestTimeout caps how long a single model probe may wait for the first
|
||||||
|
// token. The full upstream timeout (default 300s) is far too long for a probe.
|
||||||
|
const modelTestTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// getModels returns the live model list advertised by the upstream gateway
|
||||||
|
// model-info endpoint, fetched on every request so the admin console reflects
|
||||||
|
// newly published models without a restart. It provisions an API key on demand
|
||||||
|
// if the stored credentials lack one, mirroring the chat path.
|
||||||
|
func (s *Server) getModels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
creds, err := s.currentCredentials()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": "凭据未配置,请先在凭据登录页登录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
models, err := client.Models(r.Context(), creds.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "models": models})
|
||||||
|
}
|
||||||
|
|
||||||
|
// testModel sends a minimal streaming chat completion to the upstream gateway
|
||||||
|
// for the requested model and measures the time to first token (TTFT) and the
|
||||||
|
// total probe duration, so the admin console can report availability and
|
||||||
|
// latency. The stream is closed as soon as the first content chunk arrives to
|
||||||
|
// avoid consuming tokens beyond what the probe needs. Probe requests are not
|
||||||
|
// recorded in token statistics.
|
||||||
|
func (s *Server) testModel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model := strings.TrimSpace(in.Model)
|
||||||
|
if model == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "model 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
creds, err := s.currentCredentials()
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": "凭据未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !creds.HasAPIKey() {
|
||||||
|
creds, err = s.provisionCredentials(r.Context(), creds)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.cfg.Credentials = creds
|
||||||
|
_ = s.st.SaveCredentials(creds)
|
||||||
|
}
|
||||||
|
modelBaseURL := firstNonEmpty(creds.ModelBaseURL, s.cfg.MobileModelBaseURL)
|
||||||
|
client, err := s.zhanluClientWithBase(modelBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
||||||
|
"stream": true,
|
||||||
|
"stream_options": map[string]any{"include_usage": true},
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), modelTestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.ChatCompletions(ctx, creds.APIKey, body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": err.Error(), "total_ms": time.Since(start).Milliseconds()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "model": model, "available": false, "error": fmt.Sprintf("上游返回 %d: %s", resp.StatusCode, string(b)), "total_ms": time.Since(start).Milliseconds()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Read the SSE stream until the first content chunk arrives (or an error),
|
||||||
|
// then close the connection so the probe consumes at most one token.
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
var ttft int64
|
||||||
|
gotStream := false
|
||||||
|
available := false
|
||||||
|
errMsg := ""
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if line != "" {
|
||||||
|
payload := sseDataPayload(line)
|
||||||
|
if payload != "" && payload != "[DONE]" {
|
||||||
|
var evt struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
ErrorMessage string `json:"errorMessage"`
|
||||||
|
Choices []struct {
|
||||||
|
Delta struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"delta"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal([]byte(payload), &evt) == nil {
|
||||||
|
if evt.State == "ERROR" {
|
||||||
|
errMsg = firstNonEmpty(evt.ErrorMessage, "上游返回错误")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !gotStream {
|
||||||
|
gotStream = true
|
||||||
|
ttft = time.Since(start).Milliseconds()
|
||||||
|
}
|
||||||
|
if len(evt.Choices) > 0 && evt.Choices[0].Delta.Content != "" {
|
||||||
|
available = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !gotStream && errMsg == "" {
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gotStream && errMsg == "" {
|
||||||
|
available = true
|
||||||
|
}
|
||||||
|
result := map[string]any{"ok": true, "model": model, "available": available, "total_ms": time.Since(start).Milliseconds()}
|
||||||
|
if gotStream {
|
||||||
|
result["ttft_ms"] = ttft
|
||||||
|
}
|
||||||
|
if !available {
|
||||||
|
result["error"] = firstNonEmpty(errMsg, "未收到响应内容")
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func parseLimit(s string) int {
|
func parseLimit(s string) int {
|
||||||
n := 0
|
n := 0
|
||||||
for _, c := range s {
|
for _, c := range s {
|
||||||
@@ -1262,6 +1417,7 @@ var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|||||||
|
|
||||||
<div class="tabs" role="tablist">
|
<div class="tabs" role="tablist">
|
||||||
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
<button class="tab" role="tab" data-tab="stats" aria-selected="true">Token 统计</button>
|
||||||
|
<button class="tab" role="tab" data-tab="models" aria-selected="false">可用模型</button>
|
||||||
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
<button class="tab" role="tab" data-tab="login" aria-selected="false">凭据登录</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1314,6 +1470,23 @@ var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="tabpanel" data-tab="models" role="tabpanel">
|
||||||
|
<div class="sub-actions">
|
||||||
|
<button class="btn btn-ghost" id="models-refresh" type="button">刷新</button>
|
||||||
|
<button class="btn btn-primary" id="models-test-all" type="button">全部测试</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h2>可用模型</h2>
|
||||||
|
<p class="muted" id="models-status" style="margin:0 0 16px">点击刷新获取当前上游接口返回的模型列表。</p>
|
||||||
|
<div class="scroll">
|
||||||
|
<table id="models-table">
|
||||||
|
<thead><tr><th>模型</th><th>状态</th><th class="num">首字延时</th><th class="num">总耗时</th><th>操作</th></tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="tabpanel" data-tab="login" role="tabpanel">
|
<section class="tabpanel" data-tab="login" role="tabpanel">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
<p class="lede">输入手机号获取验证码,按插件默认的移动云登录接口换取凭据和模型 API Key。凭据保存到本地数据库,后续 OpenAI 兼容接口自动使用。</p>
|
||||||
@@ -1347,7 +1520,8 @@ var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
var hash = location.hash.replace('#', '');
|
var hash = location.hash.replace('#', '');
|
||||||
if (hash === 'login') activate('login');
|
if (hash === 'models') activate('models');
|
||||||
|
else if (hash === 'login') activate('login');
|
||||||
|
|
||||||
var rows = document.querySelectorAll('#daily .bar');
|
var rows = document.querySelectorAll('#daily .bar');
|
||||||
var max = 1;
|
var max = 1;
|
||||||
@@ -1475,6 +1649,100 @@ var adminTemplate = template.Must(template.New("admin").Funcs(template.FuncMap{
|
|||||||
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
setStatus('登录成功,已保存凭据:' + (data.access_key || '') + ';数据库:' + (data.path || ''), 'ok');
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
var refreshBtn = document.getElementById('models-refresh');
|
||||||
|
var testAllBtn = document.getElementById('models-test-all');
|
||||||
|
var statusEl = document.getElementById('models-status');
|
||||||
|
var tbody = document.querySelector('#models-table tbody');
|
||||||
|
if (!refreshBtn || !tbody) return;
|
||||||
|
var loaded = false;
|
||||||
|
|
||||||
|
function setStatus(text, state) { statusEl.textContent = text; statusEl.dataset.state = state || ''; }
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderRow(model) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.dataset.model = model;
|
||||||
|
tr.innerHTML = '<td>' + escapeHtml(model) + '</td>' +
|
||||||
|
'<td class="m-status"><span class="muted">未测试</span></td>' +
|
||||||
|
'<td class="num m-ttft">—</td>' +
|
||||||
|
'<td class="num m-total">—</td>' +
|
||||||
|
'<td><button class="btn btn-ghost m-test" type="button">测试</button></td>';
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
async function loadModels() {
|
||||||
|
setStatus('正在获取模型列表...', 'busy');
|
||||||
|
refreshBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/models');
|
||||||
|
var data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) { setStatus(data.error || '获取模型列表失败', 'err'); return; }
|
||||||
|
var models = data.models || [];
|
||||||
|
if (models.length === 0) { setStatus('上游未返回任何模型', 'err'); tbody.innerHTML = ''; return; }
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
for (var i = 0; i < models.length; i++) tbody.appendChild(renderRow(models[i]));
|
||||||
|
setStatus('共 ' + models.length + ' 个模型,点击测试检查可用性与延时', '');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('获取模型列表失败:' + e.message, 'err');
|
||||||
|
} finally {
|
||||||
|
refreshBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function testModel(model, row) {
|
||||||
|
var statusCell = row.querySelector('.m-status');
|
||||||
|
var ttftCell = row.querySelector('.m-ttft');
|
||||||
|
var totalCell = row.querySelector('.m-total');
|
||||||
|
var btn = row.querySelector('.m-test');
|
||||||
|
statusCell.innerHTML = '<span class="badge" style="background:var(--accent-soft);color:var(--accent);border-color:#dbe6fb">测试中...</span>';
|
||||||
|
ttftCell.textContent = '—';
|
||||||
|
totalCell.textContent = '—';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
var res = await fetch('/api/models/test', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: model }) });
|
||||||
|
var data = await res.json();
|
||||||
|
if (data.available) {
|
||||||
|
statusCell.innerHTML = '<span class="badge ok">可用</span>';
|
||||||
|
ttftCell.textContent = data.ttft_ms != null ? data.ttft_ms + 'ms' : '—';
|
||||||
|
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
|
||||||
|
} else {
|
||||||
|
statusCell.innerHTML = '<span class="badge err">不可用</span>';
|
||||||
|
statusCell.title = data.error || '';
|
||||||
|
totalCell.textContent = data.total_ms != null ? data.total_ms + 'ms' : '—';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusCell.innerHTML = '<span class="badge err">请求错误</span>';
|
||||||
|
statusCell.title = e.message;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshBtn.addEventListener('click', loadModels);
|
||||||
|
if (testAllBtn) testAllBtn.addEventListener('click', async function () {
|
||||||
|
var rows = tbody.querySelectorAll('tr');
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
await testModel(rows[i].dataset.model, rows[i]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tbody.addEventListener('click', function (ev) {
|
||||||
|
var btn = ev.target.closest('.m-test');
|
||||||
|
if (!btn) return;
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
if (row && row.dataset.model) testModel(row.dataset.model, row);
|
||||||
|
});
|
||||||
|
// lazy-load the model list the first time the tab becomes active
|
||||||
|
function loadIfActive() {
|
||||||
|
if (loaded) return;
|
||||||
|
var panel = document.querySelector('.tabpanel[data-tab="models"]');
|
||||||
|
if (panel && panel.classList.contains('active')) { loaded = true; loadModels(); }
|
||||||
|
}
|
||||||
|
var modelsTab = document.querySelector('.tab[data-tab="models"]');
|
||||||
|
if (modelsTab) modelsTab.addEventListener('click', loadIfActive);
|
||||||
|
loadIfActive();
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`))
|
</html>`))
|
||||||
|
|||||||
@@ -271,6 +271,100 @@ func TestStats(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdminModels verifies the admin /api/models endpoint returns the live
|
||||||
|
// model list advertised by the upstream gateway model-info endpoint.
|
||||||
|
func TestAdminModels(t *testing.T) {
|
||||||
|
upstream, proxy, st := setupTestServer(t)
|
||||||
|
defer upstream.Close()
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
creds := auth.Credentials{
|
||||||
|
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
|
||||||
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
||||||
|
}
|
||||||
|
if err := st.SaveCredentials(creds); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(proxy.URL + "/api/models")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := data["ok"].(bool); !ok {
|
||||||
|
t.Fatalf("models endpoint not ok: %v", data)
|
||||||
|
}
|
||||||
|
models, _ := data["models"].([]any)
|
||||||
|
if len(models) != 2 {
|
||||||
|
t.Fatalf("models = %v, want 2", models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelTest verifies the admin /api/models/test endpoint probes a model and
|
||||||
|
// reports availability plus latency against the mock streaming upstream.
|
||||||
|
func TestModelTest(t *testing.T) {
|
||||||
|
upstream, proxy, st := setupTestServer(t)
|
||||||
|
defer upstream.Close()
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
creds := auth.Credentials{
|
||||||
|
AccessKey: "AK", SecretKey: "SK", Token: "TOKEN",
|
||||||
|
APIKey: "sk-test-456", ModelBaseURL: upstream.URL, Email: "[email protected]",
|
||||||
|
}
|
||||||
|
if err := st.SaveCredentials(creds); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
|
||||||
|
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := data["ok"].(bool); !ok {
|
||||||
|
t.Fatalf("test endpoint not ok: %v", data)
|
||||||
|
}
|
||||||
|
if available, _ := data["available"].(bool); !available {
|
||||||
|
t.Fatalf("model should be available: %v", data)
|
||||||
|
}
|
||||||
|
if _, ok := data["ttft_ms"]; !ok {
|
||||||
|
t.Fatalf("ttft_ms should be present: %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelTestNoCredentials verifies the test endpoint reports unavailable
|
||||||
|
// gracefully when no credentials are configured, instead of erroring.
|
||||||
|
func TestModelTestNoCredentials(t *testing.T) {
|
||||||
|
upstream, proxy, _ := setupTestServer(t)
|
||||||
|
defer upstream.Close()
|
||||||
|
defer proxy.Close()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"model": "GLM-4.7"})
|
||||||
|
resp, err := http.Post(proxy.URL+"/api/models/test", "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var data map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ok, _ := data["ok"].(bool); !ok {
|
||||||
|
t.Fatalf("test endpoint should stay ok: %v", data)
|
||||||
|
}
|
||||||
|
if available, _ := data["available"].(bool); available {
|
||||||
|
t.Fatalf("model should not be available without creds: %v", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// totNum extracts an int from a JSON-decoded numeric value (float64).
|
// totNum extracts an int from a JSON-decoded numeric value (float64).
|
||||||
func totNum(v any) int {
|
func totNum(v any) int {
|
||||||
f, _ := v.(float64)
|
f, _ := v.(float64)
|
||||||
|
|||||||
Reference in New Issue
Block a user