Add token usage stats and consolidate credentials in SQLite
build / build (push) Successful in 2m34s
build / build (push) Successful in 2m34s
- New internal/stats (types) and internal/store (SQLite owner: requests + credentials tables, WAL); store implements stats.Recorder. - Stream (SSE tee) and non-stream chat paths parse upstream usage incl. cached_tokens and record per-request; add /api/stats, /api/stats/reset, /admin/stats HTML with cache hit rate. - Drop credentials.json: remove auth file I/O and ZHANLU_CREDENTIALS_FILE; credential precedence is env vars > db row.
This commit is contained in:
+102
-13
@@ -15,12 +15,15 @@ import (
|
||||
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/auth"
|
||||
"git.misaka.ren/M1saka/zhanlu_proxy/internal/config"
|
||||
"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.
|
||||
func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string) {
|
||||
// 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 {
|
||||
@@ -47,7 +50,7 @@ func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string)
|
||||
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\":1}}\n\n")
|
||||
_, _ = 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":
|
||||
@@ -59,28 +62,32 @@ func setupTestServer(t *testing.T) (*httptest.Server, *httptest.Server, string)
|
||||
}
|
||||
}))
|
||||
|
||||
credsFile := filepath.Join(t.TempDir(), "credentials.json")
|
||||
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",
|
||||
CredentialsPath: credsFile,
|
||||
DBPath: filepath.Join(t.TempDir(), "zhanlu.db"),
|
||||
TokenDecryptKey: "3jw7woww2rvhla6k",
|
||||
PublicKeyPEM: defaultTestPublicKey,
|
||||
PhonePublicKeyPEM: defaultTestPublicKey,
|
||||
SM2PrivateKey: testSM2Key,
|
||||
PluginVersion: "1.4.2",
|
||||
}
|
||||
h := New(cfg)
|
||||
h := New(cfg, st)
|
||||
proxy := httptest.NewServer(h)
|
||||
return upstream, proxy, credsFile
|
||||
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, credsFile := setupTestServer(t)
|
||||
upstream, proxy, st := setupTestServer(t)
|
||||
defer upstream.Close()
|
||||
defer proxy.Close()
|
||||
|
||||
@@ -107,8 +114,8 @@ func TestPhoneLoginAndChat(t *testing.T) {
|
||||
t.Fatalf("login failed: %v", loginResp)
|
||||
}
|
||||
|
||||
// 3. credentials file should contain the provisioned api key
|
||||
creds, err := auth.LoadCredentials(credsFile)
|
||||
// 3. credentials store should contain the provisioned api key
|
||||
creds, err := st.LoadCredentials()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -154,11 +161,11 @@ func TestPhoneLoginAndChat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamingChat(t *testing.T) {
|
||||
upstream, proxy, credsFile := setupTestServer(t)
|
||||
upstream, proxy, st := setupTestServer(t)
|
||||
defer upstream.Close()
|
||||
defer proxy.Close()
|
||||
|
||||
// Seed credentials directly with the api key
|
||||
// Seed credentials directly with the api key into the store
|
||||
creds := auth.Credentials{
|
||||
AccessKey: "AK",
|
||||
SecretKey: "SK",
|
||||
@@ -167,7 +174,7 @@ func TestStreamingChat(t *testing.T) {
|
||||
ModelBaseURL: upstream.URL,
|
||||
Email: "[email protected]",
|
||||
}
|
||||
if err := auth.SaveCredentials(credsFile, creds); err != nil {
|
||||
if err := st.SaveCredentials(creds); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -188,6 +195,88 @@ func TestStreamingChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user