build / build (push) Successful in 2m32s
By-model panel gains a segmented 1天/7天/全部 control. Selecting a range fetches /api/stats?since=<RFC3339> and re-renders only that table client-side, replicating the human/rate template helpers in JS; the existing /api/stats since param already drives the server-side filter. - internal/server/templates/admin.html: filter UI + JS, empty/loading states - internal/server/server_test.go: render test for the filter controls - scripts/test-instance.sh: build→start→stop→clean test-instance helper
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/stats"
|
|
"git.misaka.ren/M1saka/zhanlu_proxy/internal/store"
|
|
)
|
|
|
|
const testSM2Key = "8d6ee90b3c4d299ae5abd655dbc3547c110ae8aeff1de18b0df241f215f90748"
|
|
|
|
// setupTestServer spins up a mock Zhanlu upstream and a proxy server wired to
|
|
// it. The proxy is backed by a temp SQLite store so credentials persist in the
|
|
// db during the test instead of a JSON file.
|
|
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, *store.Store) {
|
|
t.Helper()
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/api/query/acepilot-h5/manager/code/getAuthCode":
|
|
writeJSON(w, http.StatusOK, map[string]any{"state": "OK"})
|
|
case "/api/query/acepilot-h5/manager/code/checkCode":
|
|
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
|
|
"result": true,
|
|
"ak": "BASE64AK", "sk": "BASE64SK", "license": "BASE64TOKEN",
|
|
}})
|
|
case "/api/acepilot/zhanlu/v1/login":
|
|
if r.Header.Get("plugin_type") != "zhanlu_ide" {
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{"state": "ERROR", "errorMessage": "bad plugin_type"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"state": "OK", "body": map[string]any{
|
|
"email": "[email protected]", "organization": "cmcc", "team": "ai",
|
|
}})
|
|
case "/user/api/v2/external/key/get-or-create":
|
|
writeJSON(w, http.StatusOK, map[string]any{"apiKey": "sk-test-456"})
|
|
case "/chat/completions":
|
|
if r.Header.Get("Authorization") != "Bearer sk-test-456" {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"message": "bad auth"}})
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"finish_reason\":null}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20,\"total_tokens\":30,\"prompt_tokens_details\":{\"cached_tokens\":4}}}\n\n")
|
|
_, _ = fmt.Fprint(w, "data: {\"id\":\"chatcmpl-x\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
|
|
_, _ = fmt.Fprint(w, "data: [DONE]\n\n")
|
|
case "/gateway/v1/model/info":
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": []map[string]any{
|
|
{"model_name": "GLM-4.7"}, {"id": "MiniMaxAI/MiniMax-M2.5"},
|
|
}})
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
|
|
st, err := store.Open(filepath.Join(t.TempDir(), "stats.db"))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
cfg := config.Config{
|
|
ListenAddr: ":0",
|
|
MobileLoginBaseURL: upstream.URL,
|
|
MobileModelBaseURL: upstream.URL,
|
|
UpstreamPath: "/chat/completions",
|
|
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
|
|
TokenDecryptKey: "3jw7woww2rvhla6k",
|
|
PublicKeyPEM: defaultTestPublicKey,
|
|
PhonePublicKeyPEM: defaultTestPublicKey,
|
|
SM2PrivateKey: testSM2Key,
|
|
PluginVersion: "1.4.2",
|
|
}
|
|
h := New(cfg, st)
|
|
proxy := httptest.NewServer(h)
|
|
return upstream, proxy, st
|
|
}
|
|
|
|
// TestPhoneLoginAndChat exercises the full v1.4.2 flow: SMS login, profile
|
|
// fetch, SM2 API-key provisioning, then OpenAI-compatible chat and models.
|
|
func TestPhoneLoginAndChat(t *testing.T) {
|
|
upstream, proxy, st := setupTestServer(t)
|
|
defer upstream.Close()
|
|
defer proxy.Close()
|
|
|
|
// 1. request phone code
|
|
resp, err := http.Post(proxy.URL+"/api/auth/code", "application/json", strings.NewReader(`{"telephone":"13800000000"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var codeResp map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&codeResp)
|
|
resp.Body.Close()
|
|
secret, _ := codeResp["secret"].(string)
|
|
|
|
// 2. login with phone code (server RSA-encrypts the telephone itself)
|
|
loginBody, _ := json.Marshal(map[string]string{"telephone": "13800000000", "code": "123456", "secret": secret})
|
|
resp, err = http.Post(proxy.URL+"/api/auth/login", "application/json", bytes.NewReader(loginBody))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var loginResp map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&loginResp)
|
|
resp.Body.Close()
|
|
if !okValue(loginResp) {
|
|
t.Fatalf("login failed: %v", loginResp)
|
|
}
|
|
|
|
// 3. credentials store should contain the provisioned api key
|
|
creds, err := st.LoadCredentials()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if creds.APIKey != "sk-test-456" {
|
|
t.Fatalf("apiKey = %q", creds.APIKey)
|
|
}
|
|
if creds.Email != "[email protected]" {
|
|
t.Fatalf("email = %q", creds.Email)
|
|
}
|
|
|
|
// 4. non-streaming chat completion
|
|
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`
|
|
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var chatResp map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
choices, _ := chatResp["choices"].([]any)
|
|
if len(choices) != 1 {
|
|
t.Fatalf("chat choices = %v", chatResp)
|
|
}
|
|
msg, _ := choices[0].(map[string]any)["message"].(map[string]any)
|
|
if msg["content"] != "hello" {
|
|
t.Fatalf("chat content = %v", msg)
|
|
}
|
|
|
|
// 5. models endpoint should prefer gateway model info
|
|
resp, err = http.Get(proxy.URL + "/v1/models")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var modelsResp map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&modelsResp)
|
|
items, _ := modelsResp["data"].([]any)
|
|
if len(items) != 2 {
|
|
t.Fatalf("models = %v", modelsResp)
|
|
}
|
|
}
|
|
|
|
func TestStreamingChat(t *testing.T) {
|
|
upstream, proxy, st := setupTestServer(t)
|
|
defer upstream.Close()
|
|
defer proxy.Close()
|
|
|
|
// Seed credentials directly with the api key into the store
|
|
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)
|
|
}
|
|
|
|
chatBody := `{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`
|
|
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(chatBody))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("status %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
body := string(raw)
|
|
if !strings.Contains(body, "data: ") || !strings.Contains(body, "hello") || !strings.Contains(body, "[DONE]") {
|
|
t.Fatalf("stream body: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestStats verifies both streaming and non-streaming chat paths record token
|
|
// usage into the store and that GET /api/stats aggregates them correctly.
|
|
func TestStats(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)
|
|
}
|
|
|
|
// non-streaming chat
|
|
resp, err := http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":false}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
|
|
// streaming chat
|
|
resp, err = http.Post(proxy.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"GLM-4.7","messages":[{"role":"user","content":"hi"}],"stream":true}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
|
|
// query stats
|
|
resp, err = http.Get(proxy.URL + "/api/stats")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var statsResp map[string]any
|
|
if err := json.NewDecoder(resp.Body).Decode(&statsResp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if enabled, _ := statsResp["enabled"].(bool); !enabled {
|
|
t.Fatalf("stats not enabled: %v", statsResp)
|
|
}
|
|
s, _ := statsResp["stats"].(map[string]any)
|
|
if s == nil {
|
|
t.Fatalf("no stats object: %v", statsResp)
|
|
}
|
|
totals, _ := s["totals"].(map[string]any)
|
|
if totals == nil {
|
|
t.Fatalf("no totals: %v", s)
|
|
}
|
|
if got := totNum(totals["requests"]); got != 2 {
|
|
t.Fatalf("requests = %v, want 2", totals["requests"])
|
|
}
|
|
if got := totNum(totals["prompt_tokens"]); got != 20 {
|
|
t.Fatalf("prompt_tokens = %v, want 20", totals["prompt_tokens"])
|
|
}
|
|
if got := totNum(totals["completion_tokens"]); got != 40 {
|
|
t.Fatalf("completion_tokens = %v, want 40", totals["completion_tokens"])
|
|
}
|
|
if got := totNum(totals["total_tokens"]); got != 60 {
|
|
t.Fatalf("total_tokens = %v, want 60", totals["total_tokens"])
|
|
}
|
|
if got := totNum(totals["cached_tokens"]); got != 8 {
|
|
t.Fatalf("cached_tokens = %v, want 8", totals["cached_tokens"])
|
|
}
|
|
if rate, _ := totals["cache_rate"].(float64); rate < 0.39 || rate > 0.41 {
|
|
t.Fatalf("cache_rate = %v, want ~0.4", totals["cache_rate"])
|
|
}
|
|
perModel, _ := s["per_model"].([]any)
|
|
if len(perModel) != 1 {
|
|
t.Fatalf("per_model = %v, want 1 entry", perModel)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// TestAdminRendersModelRangeFilter verifies the admin page renders without
|
|
// panic for both empty and populated summaries, and that the by-model panel
|
|
// carries the 1d/7d/all range filter controls and a client-renderable tbody.
|
|
func TestAdminRendersModelRangeFilter(t *testing.T) {
|
|
populated := &stats.Summary{
|
|
PerModel: []stats.ModelStat{
|
|
{Model: "GLM-4.7", Requests: 3, PromptTokens: 10, CompletionTokens: 20, TotalTokens: 30, CachedTokens: 4},
|
|
},
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
summary *stats.Summary
|
|
}{
|
|
{"empty", &stats.Summary{}},
|
|
{"populated", populated},
|
|
}
|
|
for _, tc := range cases {
|
|
var buf bytes.Buffer
|
|
if err := adminTemplate.Execute(&buf, map[string]any{
|
|
"Enabled": true,
|
|
"Stats": tc.summary,
|
|
"DBPath": "zhanlu.db",
|
|
"PasswordEnabled": false,
|
|
}); err != nil {
|
|
t.Fatalf("%s: render admin: %v", tc.name, err)
|
|
}
|
|
body := buf.String()
|
|
for _, want := range []string{
|
|
`id="model-range"`,
|
|
`data-range="1d"`,
|
|
`data-range="7d"`,
|
|
`data-range="all"`,
|
|
`id="model-tbody"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Fatalf("%s: admin output missing %q", tc.name, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// totNum extracts an int from a JSON-decoded numeric value (float64).
|
|
func totNum(v any) int {
|
|
f, _ := v.(float64)
|
|
return int(f)
|
|
}
|
|
|
|
func okValue(m map[string]any) bool {
|
|
ok, _ := m["ok"].(bool)
|
|
return ok
|
|
}
|
|
|
|
func TestMain(m *testing.M) {
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
var _ = context.Background
|
|
|
|
const defaultTestPublicKey = `-----BEGIN PUBLIC KEY-----
|
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxudxTewPgljUHEZHkusP7m3I+zA4/RGvuUMt6TtII/m4zwUOm/Y31zHBTmkCCt8k5vj9y+AmO0TsGmHooNQuMebakdmEWdcA5h7YAHHFbF2w5LcxIXjib08vgVpA+m3R5xPbLK+vfHe2aAX36b5nHReDNncY5vAl3U4CgIEBGPqyG67vJytRWqP+sfEdw5+m192Rf4SCGyiBzRmjiVlH3zeEBjdbOrkAnzKOVz6AHBl2q7LPLJKIzxjoAyhEp5qnDjHUFo5VZUgFwUOt83A/jbGMyzmjRoxBuvKcs9tBuorZyUwIsZN6E+rtQk2YqMPj4RkDsZ7LRmj6on8sN2rHQIDAQAB
|
|
-----END PUBLIC KEY-----`
|