From f0f8abbbb6d96c3224627c70530f6c9ade512749 Mon Sep 17 00:00:00 2001 From: chuan Date: Wed, 19 Aug 2026 16:44:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8B=86=E5=88=86web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/webdemo/main.go | 26 + docs/dev.md | 2 + docs/modules/admin-console.md | 14 +- internal/plugin/management.go | 39 +- internal/plugin/management_test.go | 85 ++- internal/web/app/core/runtime.js | 143 ++++ internal/web/app/core/shared.js | 48 ++ internal/web/app/features/keys.js | 437 +++++++++++ internal/web/app/features/pricing.js | 285 +++++++ internal/web/app/features/usage.js | 251 ++++++ internal/web/app/main.js | 35 + internal/web/embed.go | 29 +- internal/web/styles/base.css | 41 + internal/web/styles/keys.css | 72 ++ internal/web/styles/layout.css | 19 + internal/web/styles/pricing.css | 36 + internal/web/styles/responsive.css | 3 + internal/web/styles/usage.css | 30 + internal/web/ui-config.js | 1 + internal/web/ui.html | 1062 +------------------------- internal/webdemo/fake_data.go | 205 +++++ internal/webdemo/fake_handler.go | 429 +++++++++++ internal/webdemo/server.go | 50 ++ internal/webdemo/server_test.go | 88 +++ 24 files changed, 2331 insertions(+), 1099 deletions(-) create mode 100644 cmd/webdemo/main.go create mode 100644 internal/web/app/core/runtime.js create mode 100644 internal/web/app/core/shared.js create mode 100644 internal/web/app/features/keys.js create mode 100644 internal/web/app/features/pricing.js create mode 100644 internal/web/app/features/usage.js create mode 100644 internal/web/app/main.js create mode 100644 internal/web/styles/base.css create mode 100644 internal/web/styles/keys.css create mode 100644 internal/web/styles/layout.css create mode 100644 internal/web/styles/pricing.css create mode 100644 internal/web/styles/responsive.css create mode 100644 internal/web/styles/usage.css create mode 100644 internal/web/ui-config.js create mode 100644 internal/webdemo/fake_data.go create mode 100644 internal/webdemo/fake_handler.go create mode 100644 internal/webdemo/server.go create mode 100644 internal/webdemo/server_test.go diff --git a/cmd/webdemo/main.go b/cmd/webdemo/main.go new file mode 100644 index 0000000..62b6c15 --- /dev/null +++ b/cmd/webdemo/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "billing/internal/webdemo" +) + +func main() { + listen := flag.String("listen", "127.0.0.1:8320", "HTTP listen address") + input := flag.String("input", "", "data input; supported value: fake") + flag.Parse() + + if *input != "fake" { + fmt.Fprintln(os.Stderr, "missing supported -input value: fake") + os.Exit(2) + } + + server := &http.Server{Addr: *listen, Handler: webdemo.NewServer(webdemo.NewFakeInput())} + log.Printf("billing web demo: http://%s/ui", *listen) + log.Fatal(server.ListenAndServe()) +} diff --git a/docs/dev.md b/docs/dev.md index db01c9a..6423c49 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -10,6 +10,8 @@ git submodule update --init --recursive 升级 submodule、构建宿主、本地替换、验收和远端发布使用 [`operations.md`](operations.md)。该文档同时记录已确认的上游测试失败及免复测条件。 +管理台界面开发使用 `go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320`。该命令用独立 Fake 输入端启动正式页面资源,不依赖本地 CPA 和 billing 数据库。 + 开发按依赖从少到多推进:先确定数据和规则,再实现存储与服务,最后接入外部协议和界面。核心逻辑不依赖框架类型,协议转换集中在边界层。涉及持久化时,先定义迁移和历史数据语义 开发的核心在于每一步的可预见性与可测试性 diff --git a/docs/modules/admin-console.md b/docs/modules/admin-console.md index 5ca7752..12ade7f 100644 --- a/docs/modules/admin-console.md +++ b/docs/modules/admin-console.md @@ -4,7 +4,7 @@ 管理台是 billing 各业务模块的统一操作和查看入口。它不单独保存业务事实,而是通过 CLIProxyAPI 受保护的 Management API 读取或修改 SQLite 中的真实配置与记录。 -当前管理台采用单页、紧凑布局,直接作为插件资源嵌入,不依赖 CDN、外部前端框架或独立构建服务。 +当前管理台采用单页、紧凑布局。HTML、CSS、页面逻辑和运行配置分别作为插件资源嵌入,不依赖 CDN、外部前端框架或独立构建服务。 ## 页面结构 @@ -100,6 +100,18 @@ Key 列表展示: - 所有修改仍由 CLIProxyAPI 管理认证和 billing 服务端规则校验; - 密钥无效时回退匿名只读数据,不额外显示状态标签。 +## Demo 开发服务器 + +Demo 使用独立 Fake 输入端。页面资源与正式插件相同,Fake 数据不写入 HTML、页面脚本或 billing 数据库。 + +只有需要界面开发时才使用启动参数: + +```powershell +go run ./cmd/webdemo -input fake -listen 127.0.0.1:8320 +``` + +启动后访问 `http://127.0.0.1:8320/ui`。Fake 输入端提供用户、额度、请求分页、筛选、账目、价格和目录接口。进程重启后恢复初始数据。 + ## 管理接口 管理接口统一挂载在: diff --git a/internal/plugin/management.go b/internal/plugin/management.go index a1eb9e5..40ad475 100644 --- a/internal/plugin/management.go +++ b/internal/plugin/management.go @@ -33,8 +33,24 @@ const ( resourceUI = "/ui" ) +var resourceAssets = []string{ + "/ui-config.js", + "/app/main.js", + "/app/core/runtime.js", + "/app/core/shared.js", + "/app/features/keys.js", + "/app/features/pricing.js", + "/app/features/usage.js", + "/styles/base.css", + "/styles/keys.css", + "/styles/layout.css", + "/styles/pricing.css", + "/styles/responsive.css", + "/styles/usage.css", +} + func managementRegistration() ManagementRegistrationResponse { - return ManagementRegistrationResponse{ + registration := ManagementRegistrationResponse{ Routes: []ManagementRoute{ {Method: http.MethodGet, Path: managementBase + routeUsage, Description: "查看最近的用量记录。"}, {Method: http.MethodGet, Path: managementBase + routeUsageSummary, Description: "查看用户与每日用量汇总。"}, @@ -59,6 +75,10 @@ func managementRegistration() ManagementRegistrationResponse { {Path: resourceBase + resourceUI, Menu: "用量记录", Description: "查看 CPA 最近收到的请求用量。"}, }, } + for _, path := range resourceAssets { + registration.Resources = append(registration.Resources, ResourceRoute{Path: resourceBase + path}) + } + return registration } func (a *App) handleManagement(raw []byte) ([]byte, error) { @@ -80,6 +100,12 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) { Body: web.UI(), }) } + if req.Method == http.MethodGet { + name := strings.TrimPrefix(path, resourceBase+"/") + if body, contentType, found := web.Asset(name); found { + return OKEnvelope(staticResourceResponse(contentType, body)) + } + } if req.Method == http.MethodGet && path == managementBase+routeUsage { return OKEnvelope(a.usageResponse(req.Query)) } @@ -143,6 +169,17 @@ func (a *App) handleManagement(raw []byte) ([]byte, error) { })) } +func staticResourceResponse(contentType string, body []byte) ManagementResponse { + return ManagementResponse{ + StatusCode: http.StatusOK, + Headers: http.Header{ + "Content-Type": []string{contentType}, + "Cache-Control": []string{"no-store"}, + }, + Body: body, + } +} + type usageListResponse struct { Records []usageListItem `json:"records"` Pagination usagePaginationResult `json:"pagination"` diff --git a/internal/plugin/management_test.go b/internal/plugin/management_test.go index 57f9e6e..6531f2a 100644 --- a/internal/plugin/management_test.go +++ b/internal/plugin/management_test.go @@ -57,9 +57,14 @@ func TestManagementRegistrationDeclaresUsageAPIAndUI(t *testing.T) { if len(registration.Routes) != 18 || registration.Routes[0].Path != managementBase+routeUsage || registration.Routes[1].Path != managementBase+routeUsageSummary || registration.Routes[2].Path != managementBase+routePrices { t.Fatalf("unexpected management routes: %+v", registration.Routes) } - if len(registration.Resources) != 1 || registration.Resources[0].Path != resourceBase+resourceUI { + if len(registration.Resources) != 1+len(resourceAssets) || registration.Resources[0].Path != resourceBase+resourceUI { t.Fatalf("unexpected resource routes: %+v", registration.Resources) } + for index, path := range resourceAssets { + if registration.Resources[index+1].Path != resourceBase+path { + t.Fatalf("resource %d = %q, want %q", index+1, registration.Resources[index+1].Path, resourceBase+path) + } + } } func TestReadOnlyResourceReturnsRealDataWithoutSecrets(t *testing.T) { @@ -554,52 +559,54 @@ func TestUsageManagementResponseContainsDisplayFields(t *testing.T) { } } -func TestUsageResourceServesTablePage(t *testing.T) { - response := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI) - page := string(response.Body) - if response.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") { - t.Fatalf("unexpected UI response: status=%d", response.StatusCode) +func TestUsageResourceServesFeatureModules(t *testing.T) { + pageResponse := managementCall(t, NewApp(), http.MethodGet, resourceBase+resourceUI) + page := string(pageResponse.Body) + if pageResponse.StatusCode != http.StatusOK || !strings.Contains(page, "请求明细") { + t.Fatalf("unexpected UI response: status=%d", pageResponse.StatusCode) } - for _, column := range []string{"Key / 别名", "推理强度", "生成速度", "缓存写入", "总成本", "客户端 IP"} { - if !strings.Contains(page, column) { - t.Fatalf("UI does not contain column %q", column) - } - } - if !strings.Contains(page, `return "compact"`) || !strings.Contains(page, "isCompactEndpoint(record.endpoint)") { - t.Fatal("UI does not contain compact display rules") - } - for _, feature := range []string{"创建 Key", "指定账号", "允许模型", "永久归档"} { + for _, feature := range []string{``, `id="page-buttons"`, `id="user-usage"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="editor-quota"`, `id="billing-ledger"`, `id="key-management" class="surface admin-only"`, `class="surface price-panel price-layout"`, `src="./ui-config.js"`, `type="module" src="./app/main.js"`, `href="./styles/base.css"`, `href="./styles/keys.css"`, `href="./styles/usage.css"`, `href="./styles/pricing.css"`} { if !strings.Contains(page, feature) { - t.Fatalf("UI does not contain managed access feature %q", feature) - } - } - for _, feature := range []string{``, ``, `id="page-buttons"`, "const PAGE_SIZE = 100", `id="user-usage"`, `id="daily-chart"`, `id="usage-filter-panel" class="usage-filter-panel"`, `id="usage-from"`, `id="usage-request-id"`, `id="apply-usage-filters"`, "SUMMARY_API", "READONLY_API", "dataFetch", "managementAuthorized", `overflow-y: hidden`, `id="editor-quota"`, `id="editor-reset-period"`, `id="billing-ledger"`, "deepseek-*", "请求结束后按实际费用扣款", `id="key-management" class="surface admin-only"`, `class="price-section admin-only"`, `id="price-editor-content" class="hidden"`, `row.setAttribute("role", "button")`, `id="long-price-section"`, "syncLongSectionVisibility"} { - if !strings.Contains(page, feature) { - t.Fatalf("UI does not contain workspace feature %q", feature) - } - } - if strings.Contains(page, " + + diff --git a/internal/webdemo/fake_data.go b/internal/webdemo/fake_data.go new file mode 100644 index 0000000..68abe67 --- /dev/null +++ b/internal/webdemo/fake_data.go @@ -0,0 +1,205 @@ +package webdemo + +import ( + "fmt" + "sync" + "time" +) + +type fakeInput struct { + mu sync.Mutex + keys []fakeKey + upstreams []fakeUpstream + models []string + usage []fakeUsage + prices []map[string]any + catalog []map[string]any + ledger []fakeLedger + now time.Time +} + +const fakeUsageCount = 50_000 + +type fakeKey struct { + ID string `json:"id"` + Name string `json:"name"` + Secret string `json:"secret"` + MaskedSecret string `json:"masked_secret"` + Status string `json:"status"` + RouteMode string `json:"route_mode"` + UpstreamAccountID string `json:"upstream_account_id"` + AllModels bool `json:"all_models"` + Models []string `json:"models"` + Billing map[string]any `json:"billing"` +} + +type fakeUpstream struct { + ID string `json:"id"` + CPAAuthID string `json:"cpa_auth_id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + Priority int `json:"priority"` + Disabled bool `json:"disabled"` + Unavailable bool `json:"unavailable"` +} + +type fakeUsage struct { + RequestID string `json:"request_id"` + TraceID string `json:"trace_id"` + RequestedAt time.Time `json:"requested_at"` + ManagedKeyID string `json:"managed_key_id"` + KeyAlias string `json:"key_alias"` + APIKey string `json:"api_key"` + AuthID string `json:"auth_id"` + AuthIndex string `json:"auth_index"` + AuthType string `json:"auth_type"` + Model string `json:"model"` + ReasoningEffort string `json:"reasoning_effort"` + ServiceTier string `json:"service_tier"` + Speed string `json:"speed"` + Failed bool `json:"failed"` + Outcome string `json:"outcome"` + StatusCode int `json:"status_code"` + RequestType string `json:"request_type"` + Endpoint string `json:"endpoint"` + TTFTMilliseconds int64 `json:"ttft_ms"` + SpeedTPS float64 `json:"speed_tps"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CacheReadTokens int64 `json:"cache_read_tokens"` + CacheWriteTokens int64 `json:"cache_write_tokens"` + CacheRate float64 `json:"cache_rate"` + TotalTokens int64 `json:"total_tokens"` + CostAvailable bool `json:"cost_available"` + CostUSD float64 `json:"cost_usd"` + ClientIP string `json:"client_ip"` +} + +type fakeLedger struct { + ID int64 `json:"id"` + KeyID string `json:"key_id"` + Kind string `json:"kind"` + AmountUSD string `json:"amount_usd"` + BalanceAfterUSD string `json:"balance_after_usd"` + RequestID string `json:"request_id,omitempty"` + Model string `json:"model,omitempty"` + OccurredAt time.Time `json:"occurred_at"` +} + +func NewFakeInput() Input { + now := time.Now() + input := &fakeInput{now: now} + input.upstreams = []fakeUpstream{ + {ID: "upstream-primary", CPAAuthID: "codex:deepseek:primary", Provider: "codex", DisplayName: "DeepSeek 主账号", Priority: 10}, + {ID: "upstream-backup", CPAAuthID: "codex:deepseek:backup", Provider: "codex", DisplayName: "DeepSeek 备用账号", Priority: 20}, + {ID: "upstream-paused", CPAAuthID: "codex:openai:paused", Provider: "codex", DisplayName: "OpenAI 暂停账号", Priority: 30, Disabled: true}, + } + input.models = []string{"deepseek-v4-flash", "deepseek-reasoner", "gpt-5.6-sol"} + input.keys = fakeKeys(now) + input.usage = fakeUsageRecords(now, input.keys, input.upstreams, input.models) + input.prices = fakePrices() + input.catalog = fakeCatalog() + input.ledger = fakeLedgerRecords(now, input.keys, input.models) + return input +} + +func fakeKeys(now time.Time) []fakeKey { + monthlyReset := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, now.Location()) + monthlyStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + return []fakeKey{ + {ID: "key_default", Name: "默认用户", Secret: "demo-default-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "auto", AllModels: true, Billing: fakeBilling("50", "18.427631", "31.572369", "monthly", monthlyReset, 8, 1, monthlyStart)}, + {ID: "key_alice", Name: "Alice", Secret: "demo-alice-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-primary", Models: []string{"deepseek-*", "gpt-5.6-sol"}, Billing: fakeBilling("20", "6.983214", "13.016786", "weekly", now.Add(4*24*time.Hour), 4, 0, now.Add(-3*24*time.Hour))}, + {ID: "key_bob", Name: "Bob", Secret: "demo-bob-000000", MaskedSecret: "de******0000", Status: "active", RouteMode: "strict", UpstreamAccountID: "upstream-backup", Models: []string{"deepseek-v4-flash"}, Billing: fakeBilling("10", "9.764502", "0.235498", "none", time.Time{}, 2, 2, now.Add(-18*24*time.Hour))}, + {ID: "key_carol", Name: "Carol", Secret: "demo-carol-000000", MaskedSecret: "de******0000", Status: "disabled", RouteMode: "auto", Models: []string{"gpt-5.6-sol"}, Billing: fakeBilling("15", "2.154800", "12.845200", "monthly", monthlyReset, 4, 0, monthlyStart)}, + } +} + +func fakeBilling(quota, spent, balance, period string, next time.Time, concurrency, active int, started time.Time) map[string]any { + var nextValue any + if !next.IsZero() { + nextValue = next + } + return map[string]any{"quota_usd": quota, "spent_usd": spent, "balance_usd": balance, "reset_period": period, "next_reset_at": nextValue, "max_concurrency": concurrency, "active_requests": active, "cycle_started_at": started} +} + +func fakeUsageRecords(now time.Time, keys []fakeKey, upstreams []fakeUpstream, models []string) []fakeUsage { + records := make([]fakeUsage, fakeUsageCount) + for index := range records { + key := keys[index%len(keys)] + inputTokens := int64(1200 + (index*977)%48000) + outputTokens := int64(180 + (index*113)%6200) + reasoningTokens := int64(0) + if index%3 == 0 { + reasoningTokens = int64(80 + (index*31)%1600) + } + cacheReadTokens := int64(0) + if index%4 == 0 { + cacheReadTokens = inputTokens * 62 / 100 + } else if index%4 == 1 { + cacheReadTokens = inputTokens * 18 / 100 + } + outcome, statusCode := "succeeded", httpStatusOK + if index%31 == 0 { + outcome, statusCode = "failed", 502 + } else if index%43 == 0 { + outcome, statusCode = "canceled", 499 + } else if index%47 == 0 { + outcome, statusCode = "rejected", 429 + } + endpoint, requestType := "/v1/responses", "SSE" + if index%17 == 0 { + endpoint, requestType = "/v1/responses/compact", "JSON" + } else if index%11 == 0 { + endpoint = "/v1/chat/completions" + } + clientIP := "" + if index%5 == 0 { + clientIP = fmt.Sprintf("10.0.0.%d", index%20+10) + } + totalTokens := inputTokens + outputTokens + records[index] = fakeUsage{ + RequestID: fmt.Sprintf("req_demo_%05d", index+1), TraceID: fmt.Sprintf("trace_demo_%05d", index+1), RequestedAt: now.Add(-time.Duration(index) * 19 * time.Minute), ManagedKeyID: key.ID, KeyAlias: key.Name, APIKey: key.MaskedSecret, + AuthID: upstreams[index%2].CPAAuthID, AuthIndex: fmt.Sprint(index%2 + 1), AuthType: "codex", Model: models[index%len(models)], ReasoningEffort: []string{"high", "medium", "low"}[index%3], ServiceTier: map[bool]string{true: "priority", false: "auto"}[index%8 == 0], Speed: map[bool]string{true: "fast", false: ""}[index%8 == 0], + Failed: outcome == "failed", Outcome: outcome, StatusCode: statusCode, RequestType: requestType, Endpoint: endpoint, TTFTMilliseconds: int64(180 + index%720), SpeedTPS: 24 + float64(index%55)*0.7, + InputTokens: inputTokens, OutputTokens: outputTokens, ReasoningTokens: reasoningTokens, CacheReadTokens: cacheReadTokens, CacheWriteTokens: map[bool]int64{true: 240, false: 0}[index%13 == 0], CacheRate: float64(cacheReadTokens) / float64(inputTokens) * 100, TotalTokens: totalTokens, CostAvailable: true, CostUSD: float64(inputTokens)*0.00000014 + float64(outputTokens)*0.00000028, ClientIP: clientIP, + } + } + return records +} + +const httpStatusOK = 200 + +func fakePrices() []map[string]any { + return []map[string]any{ + {"model": "deepseek-v4-flash", "base": map[string]any{"input_per_1m": "0.14", "cache_read_per_1m": "0.014", "cache_write_per_1m": "0.14", "output_per_1m": "0.28"}, "fast_pricing_enabled": false, "fast_multiplier": "2.5", "source": map[string]any{"kind": "models.dev", "provider": "deepseek", "model": "deepseek-v4-flash", "catalog_id": "deepseek/deepseek-v4-flash"}}, + {"model": "gpt-5.6-sol", "base": map[string]any{"input_per_1m": "5", "cache_read_per_1m": "0.5", "cache_write_per_1m": "6.25", "output_per_1m": "30"}, "long_context": map[string]any{"threshold_input_tokens": 272000, "comparison": "gt", "input_per_1m": "10", "cache_read_per_1m": "1", "cache_write_per_1m": "12.5", "output_per_1m": "45"}, "fast_pricing_enabled": true, "fast_multiplier": "2.5", "source": map[string]any{"kind": "manual"}}, + } +} + +func fakeCatalog() []map[string]any { + return []map[string]any{ + {"id": "deepseek/deepseek-v4-flash", "provider": "deepseek", "provider_name": "DeepSeek", "model": "deepseek-v4-flash", "model_name": "DeepSeek V4 Flash", "base": map[string]any{"input_per_1m": "0.14", "cache_read_per_1m": "0.014", "cache_write_per_1m": "0.14", "output_per_1m": "0.28"}}, + {"id": "openai/gpt-5.6-sol", "provider": "openai", "provider_name": "OpenAI", "model": "gpt-5.6-sol", "model_name": "GPT-5.6 Sol", "base": map[string]any{"input_per_1m": "5", "cache_read_per_1m": "0.5", "cache_write_per_1m": "6.25", "output_per_1m": "30"}, "long_context": map[string]any{"threshold_input_tokens": 272000, "comparison": "gt", "input_per_1m": "10", "cache_read_per_1m": "1", "cache_write_per_1m": "12.5", "output_per_1m": "45"}}, + } +} + +func fakeLedgerRecords(now time.Time, keys []fakeKey, models []string) []fakeLedger { + entries := make([]fakeLedger, 0, len(keys)*8) + for keyIndex, key := range keys { + for index := 0; index < 8; index++ { + entry := fakeLedger{ID: int64(keyIndex*10 + index + 1), KeyID: key.ID, Kind: "charge", AmountUSD: fmt.Sprintf("-%.6f", 0.08+float64(index)*0.017), BalanceAfterUSD: fmt.Sprintf("%.6f", toFloat(key.Billing["balance_usd"])+float64(index)*0.097), RequestID: fmt.Sprintf("req_demo_%05d", keyIndex+index*len(keys)+1), Model: models[index%len(models)], OccurredAt: now.Add(-time.Duration(index) * 5 * time.Hour)} + if index == 7 { + entry.Kind, entry.AmountUSD, entry.RequestID, entry.Model = "quota_change", fmt.Sprint(key.Billing["quota_usd"]), "", "" + } + entries = append(entries, entry) + } + } + return entries +} + +func toFloat(value any) float64 { + var result float64 + _, _ = fmt.Sscan(fmt.Sprint(value), &result) + return result +} diff --git a/internal/webdemo/fake_handler.go b/internal/webdemo/fake_handler.go new file mode 100644 index 0000000..4f5349d --- /dev/null +++ b/internal/webdemo/fake_handler.go @@ -0,0 +1,429 @@ +package webdemo + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +func (input *fakeInput) ServeHTTP(response http.ResponseWriter, request *http.Request) { + input.mu.Lock() + defer input.mu.Unlock() + + response.Header().Set("Content-Type", "application/json; charset=utf-8") + path := strings.TrimPrefix(request.URL.Path, managementBase) + var payload any + var err error + switch { + case request.Method == http.MethodGet && path == "/usage": + payload = input.usagePage(request) + case request.Method == http.MethodGet && path == "/usage-summary": + payload = input.dashboard() + case request.Method == http.MethodGet && path == "/keys": + payload = input.listKeys(request.URL.Query().Get("include_archived") == "1") + case request.Method == http.MethodPost && path == "/keys": + payload, err = input.createKey(request) + case request.Method == http.MethodPatch && path == "/keys": + payload, err = input.updateKey(request) + case request.Method == http.MethodDelete && path == "/keys": + payload, err = input.archiveKey(request) + case request.Method == http.MethodGet && path == "/key-stats": + payload = input.keyStats(request.URL.Query().Get("id")) + case request.Method == http.MethodGet && path == "/upstreams": + payload = map[string]any{"accounts": input.upstreams} + case request.Method == http.MethodGet && path == "/model-suggestions": + payload = map[string]any{"models": input.models} + case request.Method == http.MethodGet && path == "/billing-ledger": + payload = input.billingLedger(request) + case request.Method == http.MethodPost && path == "/billing-reset": + payload, err = input.resetBilling(request) + case request.Method == http.MethodGet && path == "/prices": + payload = map[string]any{"prices": input.prices} + case request.Method == http.MethodPut && path == "/prices": + payload, err = input.putPrice(request) + case request.Method == http.MethodDelete && path == "/prices": + payload, err = input.deletePrice(request) + case request.Method == http.MethodGet && path == "/price-catalog": + payload = input.searchCatalog(request.URL.Query().Get("q")) + case request.Method == http.MethodPost && path == "/prices/import": + payload, err = input.importPrice(request) + case request.Method == http.MethodPost && path == "/price-catalog/refresh": + payload = map[string]any{"catalog": input.catalogInfo(), "changes": []any{}} + case request.Method == http.MethodPost && path == "/price-catalog/apply": + payload = map[string]any{"updated": 0} + default: + writeJSON(response, http.StatusNotFound, map[string]any{"error": map[string]string{"code": "not_found", "message": request.Method + " " + path}}) + return + } + if err != nil { + writeJSON(response, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "fake_input_error", "message": err.Error()}}) + return + } + writeJSON(response, http.StatusOK, payload) +} + +func writeJSON(response http.ResponseWriter, status int, payload any) { + response.WriteHeader(status) + _ = json.NewEncoder(response).Encode(payload) +} + +func decodeBody(request *http.Request, target any) error { + decoder := json.NewDecoder(request.Body) + decoder.UseNumber() + return decoder.Decode(target) +} + +func (input *fakeInput) listKeys(includeArchived bool) map[string]any { + keys := make([]fakeKey, 0, len(input.keys)) + for _, key := range input.keys { + if includeArchived || key.Status != "archived" { + keys = append(keys, key) + } + } + return map[string]any{"keys": keys} +} + +func (input *fakeInput) createKey(request *http.Request) (fakeKey, error) { + var value struct { + Name string `json:"name"` + Secret string `json:"secret"` + RouteMode string `json:"route_mode"` + UpstreamAccountID string `json:"upstream_account_id"` + AllModels bool `json:"all_models"` + Models []string `json:"models"` + Billing map[string]any `json:"billing"` + } + if err := decodeBody(request, &value); err != nil { + return fakeKey{}, err + } + if strings.TrimSpace(value.Name) == "" { + return fakeKey{}, fmt.Errorf("name is required") + } + if value.Secret == "" { + value.Secret = fmt.Sprintf("demo-generated-%06d", len(input.keys)+1) + } + key := fakeKey{ID: fmt.Sprintf("key_demo_%d", len(input.keys)+1), Name: value.Name, Secret: value.Secret, MaskedSecret: maskFakeSecret(value.Secret), Status: "active", RouteMode: value.RouteMode, UpstreamAccountID: value.UpstreamAccountID, AllModels: value.AllModels, Models: value.Models, Billing: normalizeFakeBilling(value.Billing, nil)} + input.keys = append(input.keys, key) + return key, nil +} + +func (input *fakeInput) updateKey(request *http.Request) (fakeKey, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return fakeKey{}, err + } + index := input.keyIndex(fmt.Sprint(value["id"])) + if index < 0 { + return fakeKey{}, fmt.Errorf("key not found") + } + key := input.keys[index] + if name := strings.TrimSpace(fmt.Sprint(value["name"])); name != "" { + key.Name = name + } + if status := strings.TrimSpace(fmt.Sprint(value["status"])); status != "" { + key.Status = status + } + if routeMode := strings.TrimSpace(fmt.Sprint(value["route_mode"])); routeMode != "" { + key.RouteMode = routeMode + } + key.UpstreamAccountID = stringValue(value["upstream_account_id"]) + if allModels, ok := value["all_models"].(bool); ok { + key.AllModels = allModels + } + if models, ok := value["models"].([]any); ok { + key.Models = make([]string, 0, len(models)) + for _, model := range models { + key.Models = append(key.Models, fmt.Sprint(model)) + } + } + if billing, ok := value["billing"].(map[string]any); ok { + key.Billing = normalizeFakeBilling(billing, key.Billing) + } + input.keys[index] = key + return key, nil +} + +func (input *fakeInput) archiveKey(request *http.Request) (map[string]any, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return nil, err + } + index := input.keyIndex(fmt.Sprint(value["id"])) + if index < 0 { + return nil, fmt.Errorf("key not found") + } + input.keys[index].Status = "archived" + return map[string]any{"archived": true}, nil +} + +func (input *fakeInput) keyIndex(id string) int { + for index := range input.keys { + if input.keys[index].ID == id { + return index + } + } + return -1 +} + +func maskFakeSecret(value string) string { + if len(value) < 6 { + return "******" + } + return value[:2] + "******" + value[len(value)-4:] +} + +func normalizeFakeBilling(value, previous map[string]any) map[string]any { + result := map[string]any{"quota_usd": "0", "spent_usd": "0", "balance_usd": "0", "reset_period": "none", "next_reset_at": nil, "max_concurrency": 4, "active_requests": 0, "cycle_started_at": time.Now()} + for key, item := range previous { + result[key] = item + } + for key, item := range value { + result[key] = item + } + quota := toFloat(result["quota_usd"]) + spent := toFloat(result["spent_usd"]) + result["quota_usd"] = fmt.Sprint(result["quota_usd"]) + result["balance_usd"] = fmt.Sprintf("%.6f", quota-spent) + return result +} + +func stringValue(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func (input *fakeInput) usagePage(request *http.Request) map[string]any { + query := request.URL.Query() + records := make([]fakeUsage, 0, len(input.usage)) + for _, record := range input.usage { + if query.Get("key_id") != "" && query.Get("key_id") != record.ManagedKeyID || query.Get("model") != "" && !strings.Contains(strings.ToLower(record.Model), strings.ToLower(query.Get("model"))) || query.Get("result") != "" && query.Get("result") != usageResult(record) || query.Get("auth_id") != "" && query.Get("auth_id") != record.AuthID || query.Get("endpoint") != "" && query.Get("endpoint") != endpointName(record.Endpoint) || query.Get("request_id") != "" && query.Get("request_id") != record.RequestID { + continue + } + if from := parseTime(query.Get("from")); !from.IsZero() && record.RequestedAt.Before(from) { + continue + } + if to := parseTime(query.Get("to")); !to.IsZero() && !record.RequestedAt.Before(to) { + continue + } + records = append(records, record) + } + pageSize := boundedInt(query.Get("page_size"), 100, 1, 100) + totalPages := int(math.Ceil(float64(len(records)) / float64(pageSize))) + page := boundedInt(query.Get("page"), 1, 1, max(1, totalPages)) + start := min((page-1)*pageSize, len(records)) + end := min(start+pageSize, len(records)) + previous, next := "", "" + if page > 1 { + previous = "fake-previous" + } + if page < totalPages { + next = "fake-next" + } + return map[string]any{"records": records[start:end], "pagination": map[string]any{"page": page, "page_size": pageSize, "total": len(records), "total_pages": totalPages, "previous_cursor": previous, "next_cursor": next}} +} + +func usageResult(record fakeUsage) string { + if record.Outcome == "canceled" || record.Outcome == "rejected" || record.Outcome == "failed" { + return record.Outcome + } + return "succeeded" +} + +func endpointName(value string) string { + if strings.HasSuffix(value, "/responses/compact") { + return "compact" + } + if strings.HasSuffix(value, "/chat/completions") { + return "chat" + } + return "responses" +} + +func parseTime(value string) time.Time { + parsed, _ := time.Parse(time.RFC3339, value) + return parsed +} + +func boundedInt(value string, fallback, minimum, maximum int) int { + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return min(max(parsed, minimum), maximum) +} + +func summarize(records []fakeUsage) map[string]any { + var inputTokens, outputTokens, totalTokens int64 + var cost float64 + for _, record := range records { + inputTokens += record.InputTokens + outputTokens += record.OutputTokens + totalTokens += record.TotalTokens + if record.CostAvailable { + cost += record.CostUSD + } + } + return map[string]any{"requests": len(records), "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": totalTokens, "cost_usd": cost, "cost_micros": int64(math.Round(cost * 1_000_000))} +} + +func (input *fakeInput) dashboard() map[string]any { + today := startOfDay(input.now) + todayRecords := filterUsage(input.usage, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) }) + users := make([]map[string]any, 0, len(input.keys)) + for _, key := range input.keys { + records := filterUsage(input.usage, func(record fakeUsage) bool { return record.ManagedKeyID == key.ID }) + current := filterUsage(records, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) }) + var last any + if len(records) > 0 { + last = records[0].RequestedAt + } + users = append(users, map[string]any{"key_id": key.ID, "key_alias": key.Name, "today": summarize(current), "last_used_at": last}) + } + days := make([]map[string]any, 0, 7) + for offset := 6; offset >= 0; offset-- { + start := today.AddDate(0, 0, -offset) + end := start.AddDate(0, 0, 1) + records := filterUsage(input.usage, func(record fakeUsage) bool { + return !record.RequestedAt.Before(start) && record.RequestedAt.Before(end) + }) + days = append(days, map[string]any{"date": start.Format("2006-01-02"), "total_tokens": summarize(records)["total_tokens"]}) + } + return map[string]any{"today": summarize(todayRecords), "users": users, "days": days} +} + +func startOfDay(value time.Time) time.Time { + return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, value.Location()) +} + +func filterUsage(records []fakeUsage, keep func(fakeUsage) bool) []fakeUsage { + result := make([]fakeUsage, 0, len(records)) + for _, record := range records { + if keep(record) { + result = append(result, record) + } + } + return result +} + +func (input *fakeInput) keyStats(id string) map[string]any { + records := filterUsage(input.usage, func(record fakeUsage) bool { return record.ManagedKeyID == id }) + today := startOfDay(input.now) + current := filterUsage(records, func(record fakeUsage) bool { return !record.RequestedAt.Before(today) }) + return map[string]any{"stats": map[string]any{"total": summarize(records), "today": summarize(current)}, "recent": records[:min(10, len(records))]} +} + +func (input *fakeInput) billingLedger(request *http.Request) map[string]any { + id := request.URL.Query().Get("id") + limit := boundedInt(request.URL.Query().Get("limit"), 50, 1, 100) + entries := make([]fakeLedger, 0, limit) + for _, entry := range input.ledger { + if entry.KeyID == id && len(entries) < limit { + entries = append(entries, entry) + } + } + return map[string]any{"entries": entries} +} + +func (input *fakeInput) resetBilling(request *http.Request) (map[string]any, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return nil, err + } + index := input.keyIndex(fmt.Sprint(value["id"])) + if index < 0 { + return nil, fmt.Errorf("key not found") + } + input.keys[index].Billing["spent_usd"] = "0" + input.keys[index].Billing["balance_usd"] = fmt.Sprint(input.keys[index].Billing["quota_usd"]) + input.keys[index].Billing["cycle_started_at"] = time.Now() + return input.keys[index].Billing, nil +} + +func (input *fakeInput) putPrice(request *http.Request) (map[string]any, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return nil, err + } + model := strings.TrimSpace(fmt.Sprint(value["model"])) + if model == "" { + return nil, fmt.Errorf("model is required") + } + value["source"] = map[string]any{"kind": "manual"} + input.storePrice(model, value) + return value, nil +} + +func (input *fakeInput) deletePrice(request *http.Request) (map[string]any, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return nil, err + } + model := fmt.Sprint(value["model"]) + filtered := input.prices[:0] + for _, price := range input.prices { + if fmt.Sprint(price["model"]) != model { + filtered = append(filtered, price) + } + } + input.prices = filtered + return map[string]any{"deleted": model}, nil +} + +func (input *fakeInput) storePrice(model string, value map[string]any) { + for index := range input.prices { + if fmt.Sprint(input.prices[index]["model"]) == model { + input.prices[index] = value + return + } + } + input.prices = append(input.prices, value) + sort.Slice(input.prices, func(left, right int) bool { + return fmt.Sprint(input.prices[left]["model"]) < fmt.Sprint(input.prices[right]["model"]) + }) +} + +func (input *fakeInput) catalogInfo() map[string]any { + return map[string]any{"revision": "fake-2026-08-19", "models": len(input.catalog), "fetched_at": input.now} +} + +func (input *fakeInput) searchCatalog(query string) map[string]any { + normalized := strings.ToLower(strings.TrimSpace(query)) + models := make([]map[string]any, 0, len(input.catalog)) + for _, entry := range input.catalog { + values := []any{entry["id"], entry["provider"], entry["provider_name"], entry["model"], entry["model_name"]} + for _, value := range values { + if normalized == "" || strings.Contains(strings.ToLower(fmt.Sprint(value)), normalized) { + models = append(models, entry) + break + } + } + } + return map[string]any{"loaded": true, "catalog": input.catalogInfo(), "models": models} +} + +func (input *fakeInput) importPrice(request *http.Request) (map[string]any, error) { + var value map[string]any + if err := decodeBody(request, &value); err != nil { + return nil, err + } + model, catalogID := fmt.Sprint(value["model"]), fmt.Sprint(value["catalog_id"]) + for _, entry := range input.catalog { + if fmt.Sprint(entry["id"]) != catalogID { + continue + } + price := map[string]any{"model": model, "base": entry["base"], "fast_pricing_enabled": false, "fast_multiplier": "2.5", "source": map[string]any{"kind": "models.dev", "provider": entry["provider"], "model": entry["model"], "catalog_id": catalogID, "revision": "fake-2026-08-19"}} + if longContext, ok := entry["long_context"]; ok { + price["long_context"] = longContext + } + input.storePrice(model, price) + return price, nil + } + return nil, fmt.Errorf("catalog entry not found") +} diff --git a/internal/webdemo/server.go b/internal/webdemo/server.go new file mode 100644 index 0000000..00f9802 --- /dev/null +++ b/internal/webdemo/server.go @@ -0,0 +1,50 @@ +package webdemo + +import ( + "net/http" + + "billing/internal/web" +) + +const managementBase = "/v0/management/plugins/billing" + +type Input interface { + ServeHTTP(http.ResponseWriter, *http.Request) +} + +func NewServer(input Input) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + http.Redirect(response, request, "/ui", http.StatusTemporaryRedirect) + }) + mux.HandleFunc("GET /ui", asset("text/html; charset=utf-8", web.UI())) + mux.HandleFunc("GET /ui-config.js", asset("text/javascript; charset=utf-8", []byte(`window.BILLING_UI_CONFIG = Object.freeze({managementBase:"/v0/management/plugins/billing",managementKey:"demo",lockManagementKey:true,hideManagementKey:true});`))) + mux.HandleFunc("GET /styles/{name}", embeddedAsset("styles/")) + mux.HandleFunc("GET /app/{path...}", embeddedAsset("app/")) + mux.Handle(managementBase+"/", input) + return mux +} + +func embeddedAsset(prefix string) http.HandlerFunc { + return func(response http.ResponseWriter, request *http.Request) { + name := request.PathValue("name") + if path := request.PathValue("path"); path != "" { + name = path + } + body, contentType, found := web.Asset(prefix + name) + if !found { + http.NotFound(response, request) + return + } + asset(contentType, body)(response, request) + } +} + +func asset(contentType string, body []byte) http.HandlerFunc { + return func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Content-Type", contentType) + response.Header().Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + _, _ = response.Write(body) + } +} diff --git a/internal/webdemo/server_test.go b/internal/webdemo/server_test.go new file mode 100644 index 0000000..e32a02b --- /dev/null +++ b/internal/webdemo/server_test.go @@ -0,0 +1,88 @@ +package webdemo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestServerServesSplitUIAndFakeInput(t *testing.T) { + server := httptest.NewServer(NewServer(NewFakeInput())) + defer server.Close() + + for _, path := range []string{"/ui", "/ui-config.js", "/styles/base.css", "/styles/keys.css", "/styles/usage.css", "/styles/pricing.css", "/app/main.js", "/app/core/runtime.js", "/app/features/keys.js", "/app/features/usage.js", "/app/features/pricing.js"} { + response, err := http.Get(server.URL + path) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + response.Body.Close() + t.Fatalf("GET %s status = %d", path, response.StatusCode) + } + response.Body.Close() + } + + response, err := http.Get(server.URL + managementBase + "/usage?page=2&page_size=100&model=deepseek") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var usage struct { + Records []fakeUsage `json:"records"` + Page map[string]any `json:"pagination"` + } + if err := json.NewDecoder(response.Body).Decode(&usage); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || len(usage.Records) == 0 || usage.Page["page"] != float64(2) { + t.Fatalf("unexpected fake usage response: status=%d records=%d page=%v", response.StatusCode, len(usage.Records), usage.Page) + } + for _, record := range usage.Records { + if !strings.Contains(record.Model, "deepseek") { + t.Fatalf("unexpected filtered model %q", record.Model) + } + } + + response, err = http.Get(server.URL + managementBase + "/usage?page=1&page_size=1") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var allUsage struct { + Page struct { + Total int `json:"total"` + } `json:"pagination"` + } + if err := json.NewDecoder(response.Body).Decode(&allUsage); err != nil { + t.Fatal(err) + } + if allUsage.Page.Total != fakeUsageCount { + t.Fatalf("fake usage total = %d, want %d", allUsage.Page.Total, fakeUsageCount) + } +} + +func TestFakeInputSupportsKeyMutation(t *testing.T) { + server := httptest.NewServer(NewServer(NewFakeInput())) + defer server.Close() + + body := `{"name":"Demo User","route_mode":"auto","all_models":true,"billing":{"quota_usd":"25","reset_period":"none","max_concurrency":4}}` + request, err := http.NewRequest(http.MethodPost, server.URL+managementBase+"/keys", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var key fakeKey + if err := json.NewDecoder(response.Body).Decode(&key); err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || key.Name != "Demo User" || key.Billing["balance_usd"] != "25.000000" { + t.Fatalf("unexpected created key: status=%d key=%+v", response.StatusCode, key) + } +}