From f550c0acd598a35a0a6bb035ab306c86f4e9b330 Mon Sep 17 00:00:00 2001 From: wangbo Date: Mon, 11 May 2026 23:02:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=B7=BB=E5=8A=A0=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E4=BB=A3=E7=90=86=E9=85=8D=E7=BD=AE=E5=92=8C=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E4=BA=A4=E6=98=93=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在管理面板中集成网络代理配置显示和平台代理设置 - 添加钱包摘要和交易列表API接口及数据管理 - 实现SSE流式响应中的错误处理机制 - 添加全局HTTP代理环境变量配置支持 - 更新平台表单以支持代理模式选择和自定义代理地址 - 集成钱包交易查询过滤和分页功能 - 优化API错误详情解析和显示格式 --- apps/api/internal/clients/gemini.go | 2 +- apps/api/internal/clients/openai.go | 10 +- apps/api/internal/clients/types.go | 1 + apps/api/internal/clients/volces.go | 14 +- apps/api/internal/config/config.go | 28 ++ apps/api/internal/httpapi/config_handlers.go | 15 + .../httpapi/core_flow_integration_test.go | 105 ++++++ apps/api/internal/httpapi/handlers.go | 30 +- apps/api/internal/httpapi/response.go | 19 +- apps/api/internal/httpapi/server.go | 6 + apps/api/internal/httpapi/wallet_handlers.go | 76 +++++ apps/api/internal/netproxy/netproxy.go | 114 +++++++ apps/api/internal/runner/proxy.go | 73 +++++ apps/api/internal/runner/proxy_test.go | 116 +++++++ apps/api/internal/runner/service.go | 32 +- apps/web/src/App.tsx | 96 +++++- apps/web/src/api.ts | 147 ++++++++- apps/web/src/app-state.ts | 6 + apps/web/src/pages/AdminPage.tsx | 1 + apps/web/src/pages/PlaygroundPage.tsx | 34 +- apps/web/src/pages/WorkspacePage.tsx | 308 +++++++++++++++++- .../pages/admin/PlatformManagementPanel.tsx | 79 ++++- apps/web/src/pages/admin/platform-form.ts | 16 + apps/web/src/pages/playground-media.tsx | 38 ++- apps/web/src/routing.ts | 1 + apps/web/src/styles.css | 108 ++++++ apps/web/src/styles/pages.css | 8 + apps/web/src/styles/playground.css | 26 ++ apps/web/src/types.ts | 10 +- packages/contracts/src/index.ts | 12 + 30 files changed, 1455 insertions(+), 76 deletions(-) create mode 100644 apps/api/internal/httpapi/config_handlers.go create mode 100644 apps/api/internal/httpapi/wallet_handlers.go create mode 100644 apps/api/internal/netproxy/netproxy.go create mode 100644 apps/api/internal/runner/proxy.go create mode 100644 apps/api/internal/runner/proxy_test.go diff --git a/apps/api/internal/clients/gemini.go b/apps/api/internal/clients/gemini.go index fe01806..dc0ac50 100644 --- a/apps/api/internal/clients/gemini.go +++ b/apps/api/internal/clients/gemini.go @@ -27,7 +27,7 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error return Response{}, err } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient(c.HTTPClient).Do(req) + resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req) if err != nil { return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true} } diff --git a/apps/api/internal/clients/openai.go b/apps/api/internal/clients/openai.go index 534f148..eaf54b4 100644 --- a/apps/api/internal/clients/openai.go +++ b/apps/api/internal/clients/openai.go @@ -33,7 +33,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := httpClient(c.HTTPClient).Do(req) + resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req) if err != nil { return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true} } @@ -114,9 +114,11 @@ func joinURL(base string, path string) string { return base + path } -func httpClient(client *http.Client) *http.Client { - if client != nil { - return client +func httpClient(clients ...*http.Client) *http.Client { + for _, client := range clients { + if client != nil { + return client + } } return http.DefaultClient } diff --git a/apps/api/internal/clients/types.go b/apps/api/internal/clients/types.go index 8ddf62c..09b4e4f 100644 --- a/apps/api/internal/clients/types.go +++ b/apps/api/internal/clients/types.go @@ -16,6 +16,7 @@ type Request struct { Model string Body map[string]any Candidate store.RuntimeModelCandidate + HTTPClient *http.Client Stream bool StreamDelta StreamDelta } diff --git a/apps/api/internal/clients/volces.go b/apps/api/internal/clients/volces.go index acb967d..5deb9fc 100644 --- a/apps/api/internal/clients/volces.go +++ b/apps/api/internal/clients/volces.go @@ -41,7 +41,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := httpClient(c.HTTPClient).Do(req) + resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req) if err != nil { return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true} } @@ -69,7 +69,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey string) (Response, error) { body := volcesVideoBody(request) submitStartedAt := time.Now() - submitResult, submitRequestID, err := c.postJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body) + submitResult, submitRequestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body) submitFinishedAt := time.Now() if err != nil { return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, submitFinishedAt) @@ -96,7 +96,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri } pollStartedAt := time.Now() - pollResult, pollRequestID, err := c.getJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey) + pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey) pollFinishedAt := time.Now() requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID) if err != nil { @@ -143,7 +143,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri } } -func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) { +func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) { raw, _ := json.Marshal(body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw)) if err != nil { @@ -151,7 +151,7 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string, } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := httpClient(c.HTTPClient).Do(req) + resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req) if err != nil { return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true} } @@ -160,13 +160,13 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string, return result, requestID, err } -func (c VolcesClient) getJSON(ctx context.Context, baseURL string, path string, apiKey string) (map[string]any, string, error) { +func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil) if err != nil { return nil, "", err } req.Header.Set("Authorization", "Bearer "+apiKey) - resp, err := httpClient(c.HTTPClient).Do(req) + resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req) if err != nil { return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true} } diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go index 8902d76..e8498c2 100644 --- a/apps/api/internal/config/config.go +++ b/apps/api/internal/config/config.go @@ -20,10 +20,13 @@ type Config struct { TaskProgressCallbackTimeoutMS string TaskProgressCallbackMaxAttempts string CORSAllowedOrigin string + GlobalHTTPProxy string + GlobalHTTPProxySource string LogLevel slog.Level } func Load() Config { + globalProxy := LoadGlobalHTTPProxyStatus() return Config{ AppEnv: env("APP_ENV", "development"), HTTPAddr: env("HTTP_ADDR", ":8088"), @@ -42,10 +45,35 @@ func Load() Config { TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"), TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"), CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"), + GlobalHTTPProxy: globalProxy.HTTPProxy, + GlobalHTTPProxySource: globalProxy.Source, LogLevel: logLevel(env("LOG_LEVEL", "info")), } } +type GlobalHTTPProxyStatus struct { + HTTPProxy string + Source string +} + +func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus { + for _, key := range []string{ + "AI_GATEWAY_GLOBAL_HTTP_PROXY", + "GLOBAL_HTTP_PROXY", + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "ALL_PROXY", + "all_proxy", + } { + if value := envValue(key); value != "" { + return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key} + } + } + return GlobalHTTPProxyStatus{} +} + func gatewayDatabaseURL() string { if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" { return normalizePostgresURL(value) diff --git a/apps/api/internal/httpapi/config_handlers.go b/apps/api/internal/httpapi/config_handlers.go new file mode 100644 index 0000000..8c4d4ab --- /dev/null +++ b/apps/api/internal/httpapi/config_handlers.go @@ -0,0 +1,15 @@ +package httpapi + +import ( + "net/http" + "strings" +) + +func (s *Server) getNetworkProxyConfig(w http.ResponseWriter, r *http.Request) { + globalHTTPProxy := strings.TrimSpace(s.cfg.GlobalHTTPProxy) + writeJSON(w, http.StatusOK, map[string]any{ + "globalHttpProxy": globalHTTPProxy, + "globalHttpProxySet": globalHTTPProxy != "", + "globalHttpProxySource": strings.TrimSpace(s.cfg.GlobalHTTPProxySource), + }) +} diff --git a/apps/api/internal/httpapi/core_flow_integration_test.go b/apps/api/internal/httpapi/core_flow_integration_test.go index bd914bb..4ea62b6 100644 --- a/apps/api/internal/httpapi/core_flow_integration_test.go +++ b/apps/api/internal/httpapi/core_flow_integration_test.go @@ -233,6 +233,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil { ID string `json:"id"` Provider string `json:"provider"` PlatformKey string `json:"platformKey"` + Name string `json:"name"` Status string `json:"status"` } doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{ @@ -593,6 +594,51 @@ WHERE reference_type = 'gateway_task' if !floatNear(walletTransactionAmount, pricingTask.Task.FinalChargeAmount) { t.Fatalf("task billing transaction amount=%f want=%f", walletTransactionAmount, pricingTask.Task.FinalChargeAmount) } + var walletSummary struct { + Accounts []struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` + TotalSpent float64 `json:"totalSpent"` + } `json:"accounts"` + PrimaryAccount struct { + Currency string `json:"currency"` + Balance float64 `json:"balance"` + } `json:"primaryAccount"` + } + doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet", loginResponse.AccessToken, nil, http.StatusOK, &walletSummary) + if walletSummary.PrimaryAccount.Currency != "resource" || !floatNear(walletSummary.PrimaryAccount.Balance, walletBalanceAfter) || len(walletSummary.Accounts) == 0 { + t.Fatalf("workspace wallet should expose current resource balance, got %+v want balance=%f", walletSummary, walletBalanceAfter) + } + var walletTransactions struct { + Items []struct { + TransactionType string `json:"transactionType"` + Direction string `json:"direction"` + ReferenceID string `json:"referenceId"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + } `json:"items"` + Total int `json:"total"` + } + doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=20", loginResponse.AccessToken, nil, http.StatusOK, &walletTransactions) + if walletTransactions.Total == 0 || !walletTransactionListContains(walletTransactions.Items, pricingTask.Task.ID) { + t.Fatalf("workspace wallet transactions should include task billing debit, got %+v", walletTransactions) + } + var filteredWalletTransactions struct { + Items []struct { + TransactionType string `json:"transactionType"` + Direction string `json:"direction"` + ReferenceID string `json:"referenceId"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + Metadata map[string]any `json:"metadata"` + } `json:"items"` + Total int `json:"total"` + } + createdFrom := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339) + doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=1&q="+pricingModel+"&createdFrom="+createdFrom, loginResponse.AccessToken, nil, http.StatusOK, &filteredWalletTransactions) + if filteredWalletTransactions.Total == 0 || !walletTransactionListContainsWithMetadata(filteredWalletTransactions.Items, pricingTask.Task.ID, pricingModel, platform.Name, apiKeyResponse.APIKey.Name, pricingTask.Task.FinalChargeAmount) { + t.Fatalf("workspace wallet transaction filters should match model and expose task metadata, got %+v", filteredWalletTransactions) + } rateLimitedModel := "rate-limit-smoke-" + suffixText var rateLimitPolicySet struct { @@ -941,6 +987,21 @@ WHERE reference_type = 'gateway_task' if !taskListContains(taskList.Items, taskResponse.Task.ID) || !taskListContains(taskList.Items, pricingTask.Task.ID) { t.Fatalf("task list should include persisted task records, got %+v", taskList.Items) } + var workspaceTaskList struct { + Items []struct { + ID string `json:"id"` + Status string `json:"status"` + APIKeyName string `json:"apiKeyName"` + ModelType string `json:"modelType"` + FinalCharge float64 `json:"finalChargeAmount"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"errorMessage"` + } `json:"items"` + } + doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?limit=20", loginResponse.AccessToken, nil, http.StatusOK, &workspaceTaskList) + if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) { + t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items) + } req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil) if err != nil { @@ -1098,6 +1159,50 @@ func taskListContains(values []struct { return false } +func walletTransactionListContains(values []struct { + TransactionType string `json:"transactionType"` + Direction string `json:"direction"` + ReferenceID string `json:"referenceId"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` +}, target string) bool { + for _, value := range values { + if value.ReferenceID == target && value.TransactionType == "task_billing" && value.Direction == "debit" && value.Currency == "resource" { + return true + } + } + return false +} + +func walletTransactionListContainsWithMetadata(values []struct { + TransactionType string `json:"transactionType"` + Direction string `json:"direction"` + ReferenceID string `json:"referenceId"` + Currency string `json:"currency"` + Amount float64 `json:"amount"` + Metadata map[string]any `json:"metadata"` +}, target string, model string, platformName string, apiKeyName string, finalChargeAmount float64) bool { + for _, value := range values { + usage := objectValue(value.Metadata["usage"]) + billingSummary := objectValue(value.Metadata["billingSummary"]) + finalCharge, hasFinalCharge := numberValue(value.Metadata["finalChargeAmount"]) + billingTotal, hasBillingTotal := numberValue(billingSummary["totalAmount"]) + if value.ReferenceID == target && + value.Metadata["model"] == model && + value.Metadata["modelType"] == "text_generate" && + value.Metadata["platformName"] == platformName && + value.Metadata["apiKeyName"] == apiKeyName && + usage["totalTokens"] != nil && + hasFinalCharge && + hasBillingTotal && + floatNear(finalCharge, finalChargeAmount) && + floatNear(billingTotal, finalChargeAmount) { + return true + } + } + return false +} + func floatNear(value float64, expected float64) bool { return math.Abs(value-expected) < 0.000001 } diff --git a/apps/api/internal/httpapi/handlers.go b/apps/api/internal/httpapi/handlers.go index cf8382c..2bad30b 100644 --- a/apps/api/internal/httpapi/handlers.go +++ b/apps/api/internal/httpapi/handlers.go @@ -10,6 +10,8 @@ import ( "time" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) @@ -186,6 +188,12 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) { if input.AuthType == "" { input.AuthType = "bearer" } + config, err := netproxy.NormalizePlatformConfig(input.Config) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + input.Config = config platform, err := s.store.CreatePlatform(r.Context(), input) if err != nil { s.logger.Error("create platform failed", "error", err) @@ -211,6 +219,12 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) { if input.AuthType == "" { input.AuthType = "bearer" } + config, err := netproxy.NormalizePlatformConfig(input.Config) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + input.Config = config platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input) if err != nil { if store.IsNotFound(err) { @@ -541,7 +555,19 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler { return nil }) if runErr != nil { - sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}}) + status := statusFromRunError(runErr) + errorPayload := map[string]any{ + "code": clients.ErrorCode(runErr), + "message": runErr.Error(), + "status": status, + } + if result.Task.ID != "" { + errorPayload["taskId"] = result.Task.ID + } + if result.Task.RequestID != "" { + errorPayload["requestId"] = result.Task.RequestID + } + sendSSE(w, "error", map[string]any{"error": errorPayload}) if flusher != nil { flusher.Flush() } @@ -555,7 +581,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler { } result, runErr := s.runner.Execute(r.Context(), task, user) if runErr != nil { - writeError(w, statusFromRunError(runErr), runErr.Error()) + writeError(w, statusFromRunError(runErr), runErr.Error(), clients.ErrorCode(runErr)) return } writeJSON(w, http.StatusOK, result.Output) diff --git a/apps/api/internal/httpapi/response.go b/apps/api/internal/httpapi/response.go index f19eede..7939d48 100644 --- a/apps/api/internal/httpapi/response.go +++ b/apps/api/internal/httpapi/response.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" ) func writeJSON(w http.ResponseWriter, status int, value any) { @@ -12,13 +13,17 @@ func writeJSON(w http.ResponseWriter, status int, value any) { _ = json.NewEncoder(w).Encode(value) } -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]any{ - "error": map[string]any{ - "message": message, - "status": status, - }, - }) +func writeError(w http.ResponseWriter, status int, message string, codes ...string) { + errorPayload := map[string]any{ + "message": message, + "status": status, + } + if len(codes) > 0 { + if code := strings.TrimSpace(codes[0]); code != "" { + errorPayload["code"] = code + } + } + writeJSON(w, status, map[string]any{"error": errorPayload}) } func sendSSE(w http.ResponseWriter, event string, payload any) { diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go index d7cb8bf..713ba76 100644 --- a/apps/api/internal/httpapi/server.go +++ b/apps/api/internal/httpapi/server.go @@ -75,6 +75,11 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey))) mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey))) mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys))) + mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet))) + mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions))) + mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks))) + mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask))) + mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents))) mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules))) mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets))) mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet))) @@ -85,6 +90,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet))) mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet))) mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet))) + mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig))) mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms))) mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform))) mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform))) diff --git a/apps/api/internal/httpapi/wallet_handlers.go b/apps/api/internal/httpapi/wallet_handlers.go new file mode 100644 index 0000000..dc23d4c --- /dev/null +++ b/apps/api/internal/httpapi/wallet_handlers.go @@ -0,0 +1,76 @@ +package httpapi + +import ( + "net/http" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func (s *Server) getWallet(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + summary, err := s.store.GetWalletSummary(r.Context(), user, r.URL.Query().Get("currency")) + if err != nil { + s.logger.Error("get wallet failed", "error", err) + writeError(w, http.StatusInternalServerError, "get wallet failed") + return + } + writeJSON(w, http.StatusOK, summary) +} + +func (s *Server) listWalletTransactions(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + query := r.URL.Query() + page, err := positiveQueryInt(query.Get("page"), 1) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid page") + return + } + pageSizeRaw := query.Get("pageSize") + if pageSizeRaw == "" { + pageSizeRaw = query.Get("limit") + } + pageSize, err := positiveQueryInt(pageSizeRaw, 50) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid pageSize") + return + } + createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdFrom") + return + } + createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid createdTo") + return + } + result, err := s.store.ListWalletTransactions(r.Context(), user, store.WalletTransactionListFilter{ + Query: firstNonEmpty(query.Get("q"), query.Get("query")), + Direction: query.Get("direction"), + TransactionType: query.Get("transactionType"), + CreatedFrom: createdFrom, + CreatedTo: createdTo, + Page: page, + PageSize: pageSize, + }) + if err != nil { + s.logger.Error("list wallet transactions failed", "error", err) + writeError(w, http.StatusInternalServerError, "list wallet transactions failed") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "items": result.Items, + "total": result.Total, + "page": result.Page, + "pageSize": result.PageSize, + }) +} diff --git a/apps/api/internal/netproxy/netproxy.go b/apps/api/internal/netproxy/netproxy.go new file mode 100644 index 0000000..6a95d3b --- /dev/null +++ b/apps/api/internal/netproxy/netproxy.go @@ -0,0 +1,114 @@ +package netproxy + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + ModeNone = "none" + ModeGlobal = "global" + ModeCustom = "custom" +) + +type Config struct { + Mode string + HTTPProxy string +} + +func FromPlatformConfig(values map[string]any) Config { + config := Config{ + Mode: stringFromMap(values, "proxyMode"), + HTTPProxy: stringFromMap(values, "httpProxy"), + } + if nested := recordFromAny(values["networkProxy"]); nested != nil { + if mode := stringFromMap(nested, "mode"); mode != "" { + config.Mode = mode + } + if proxy := stringFromMap(nested, "httpProxy"); proxy != "" { + config.HTTPProxy = proxy + } + } + return config +} + +func Normalize(input Config) (Config, error) { + mode, err := normalizeMode(input.Mode) + if err != nil { + return Config{}, err + } + config := Config{Mode: mode} + if mode != ModeCustom { + return config, nil + } + normalized, _, err := ParseHTTPProxy(input.HTTPProxy) + if err != nil { + return Config{}, err + } + config.HTTPProxy = normalized + return config, nil +} + +func NormalizePlatformConfig(values map[string]any) (map[string]any, error) { + next := map[string]any{} + for key, value := range values { + next[key] = value + } + config, err := Normalize(FromPlatformConfig(values)) + if err != nil { + return nil, err + } + networkProxy := map[string]any{"mode": config.Mode} + if config.Mode == ModeCustom { + networkProxy["httpProxy"] = config.HTTPProxy + } + next["networkProxy"] = networkProxy + delete(next, "proxyMode") + delete(next, "httpProxy") + return next, nil +} + +func ParseHTTPProxy(raw string) (string, *url.URL, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "", nil, fmt.Errorf("http proxy is required") + } + if !strings.Contains(trimmed, "://") { + trimmed = "http://" + trimmed + } + parsed, err := url.Parse(trimmed) + if err != nil { + return "", nil, fmt.Errorf("invalid http proxy: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", nil, fmt.Errorf("http proxy scheme must be http or https") + } + if parsed.Host == "" { + return "", nil, fmt.Errorf("http proxy host is required") + } + return parsed.String(), parsed, nil +} + +func normalizeMode(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "none", "off", "disabled", "disable": + return ModeNone, nil + case "global", "system", "env", "environment": + return ModeGlobal, nil + case "custom": + return ModeCustom, nil + default: + return "", fmt.Errorf("unsupported proxy mode %q", value) + } +} + +func recordFromAny(value any) map[string]any { + record, _ := value.(map[string]any) + return record +} + +func stringFromMap(values map[string]any, key string) string { + value, _ := values[key].(string) + return strings.TrimSpace(value) +} diff --git a/apps/api/internal/runner/proxy.go b/apps/api/internal/runner/proxy.go new file mode 100644 index 0000000..f650ada --- /dev/null +++ b/apps/api/internal/runner/proxy.go @@ -0,0 +1,73 @@ +package runner + +import ( + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +type httpClientCache struct { + none *http.Client + global *http.Client + mu sync.Mutex + custom map[string]*http.Client +} + +func newHTTPClientCache() *httpClientCache { + return &httpClientCache{ + none: newHTTPClient(nil), + global: newHTTPClient(http.ProxyFromEnvironment), + custom: map[string]*http.Client{}, + } +} + +func (s *Service) httpClientForCandidate(candidate store.RuntimeModelCandidate, simulated bool) (*http.Client, error) { + if simulated { + return s.httpClients.none, nil + } + config, err := netproxy.Normalize(netproxy.FromPlatformConfig(candidate.PlatformConfig)) + if err != nil { + return nil, &clients.ClientError{Code: "invalid_proxy", Message: err.Error(), Retryable: false} + } + switch config.Mode { + case netproxy.ModeGlobal: + if strings.TrimSpace(s.cfg.GlobalHTTPProxy) != "" { + return s.httpClients.customClient(s.cfg.GlobalHTTPProxy) + } + return s.httpClients.global, nil + case netproxy.ModeCustom: + return s.httpClients.customClient(config.HTTPProxy) + default: + return s.httpClients.none, nil + } +} + +func (c *httpClientCache) customClient(rawProxy string) (*http.Client, error) { + normalized, proxyURL, err := netproxy.ParseHTTPProxy(rawProxy) + if err != nil { + return nil, &clients.ClientError{Code: "invalid_proxy", Message: err.Error(), Retryable: false} + } + c.mu.Lock() + defer c.mu.Unlock() + if client := c.custom[normalized]; client != nil { + return client, nil + } + client := newHTTPClient(http.ProxyURL(proxyURL)) + c.custom[normalized] = client + return client, nil +} + +func newHTTPClient(proxy func(*http.Request) (*url.URL, error)) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = proxy + return &http.Client{ + Timeout: 120 * time.Second, + Transport: transport, + } +} diff --git a/apps/api/internal/runner/proxy_test.go b/apps/api/internal/runner/proxy_test.go new file mode 100644 index 0000000..ebb8aca --- /dev/null +++ b/apps/api/internal/runner/proxy_test.go @@ -0,0 +1,116 @@ +package runner + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/config" + "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" +) + +func TestPlatformProxyModeNoneIgnoresEnvironmentProxy(t *testing.T) { + var proxyHits int + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + w.WriteHeader(http.StatusTeapot) + })) + defer proxy.Close() + t.Setenv("HTTP_PROXY", proxy.URL) + t.Setenv("http_proxy", proxy.URL) + + var targetHits int + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits++ + _, _ = w.Write([]byte("ok")) + })) + defer target.Close() + + client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{ + PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "none"}}, + }, false) + if err != nil { + t.Fatalf("build http client: %v", err) + } + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("get target: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || targetHits != 1 || proxyHits != 0 { + t.Fatalf("unexpected status=%d targetHits=%d proxyHits=%d", resp.StatusCode, targetHits, proxyHits) + } +} + +func TestPlatformProxyModeCustomUsesConfiguredHTTPProxy(t *testing.T) { + var targetHits int + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits++ + _, _ = w.Write([]byte("target")) + })) + defer target.Close() + + var proxyHits int + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + if r.URL.String() != target.URL && r.URL.String() != target.URL+"/" { + t.Fatalf("proxy received unexpected target URL %q", r.URL.String()) + } + _, _ = w.Write([]byte("proxied")) + })) + defer proxy.Close() + + client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{ + PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "custom", "httpProxy": proxy.URL}}, + }, false) + if err != nil { + t.Fatalf("build http client: %v", err) + } + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("get target through proxy: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 { + t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits) + } +} + +func TestPlatformProxyModeGlobalUsesConfiguredGlobalHTTPProxy(t *testing.T) { + var targetHits int + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + targetHits++ + _, _ = w.Write([]byte("target")) + })) + defer target.Close() + + var proxyHits int + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + proxyHits++ + _, _ = w.Write([]byte("proxied")) + })) + defer proxy.Close() + + client, err := testProxyService(config.Config{GlobalHTTPProxy: proxy.URL}).httpClientForCandidate(store.RuntimeModelCandidate{ + PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "global"}}, + }, false) + if err != nil { + t.Fatalf("build http client: %v", err) + } + resp, err := client.Get(target.URL) + if err != nil { + t.Fatalf("get target through global proxy: %v", err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 { + t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits) + } +} + +func testProxyService(cfg config.Config) *Service { + return &Service{cfg: cfg, httpClients: newHTTPClientCache()} +} diff --git a/apps/api/internal/runner/service.go b/apps/api/internal/runner/service.go index 24c303a..c2849aa 100644 --- a/apps/api/internal/runner/service.go +++ b/apps/api/internal/runner/service.go @@ -4,7 +4,6 @@ import ( "context" "errors" "log/slog" - "net/http" "strings" "time" @@ -15,10 +14,11 @@ import ( ) type Service struct { - cfg config.Config - store *store.Store - logger *slog.Logger - clients map[string]clients.Client + cfg config.Config + store *store.Store + logger *slog.Logger + clients map[string]clients.Client + httpClients *httpClientCache } type Result struct { @@ -27,17 +27,18 @@ type Result struct { } func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service { - httpClient := &http.Client{Timeout: 120 * time.Second} + httpClients := newHTTPClientCache() return &Service{ cfg: cfg, store: db, logger: logger, clients: map[string]clients.Client{ - "openai": clients.OpenAIClient{HTTPClient: httpClient}, - "gemini": clients.GeminiClient{HTTPClient: httpClient}, - "volces": clients.VolcesClient{HTTPClient: httpClient}, + "openai": clients.OpenAIClient{HTTPClient: httpClients.none}, + "gemini": clients.GeminiClient{HTTPClient: httpClients.none}, + "volces": clients.VolcesClient{HTTPClient: httpClients.none}, "simulation": clients.SimulationClient{}, }, + httpClients: httpClients, } } @@ -200,6 +201,18 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user } defer s.store.RecordClientRelease(context.WithoutCancel(ctx), candidate.ClientID, "") + requestHTTPClient, err := s.httpClientForCandidate(candidate, simulated) + if err != nil { + _ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{ + AttemptID: attemptID, + Status: "failed", + Retryable: false, + Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false}), + ErrorCode: clients.ErrorCode(err), + ErrorMessage: err.Error(), + }) + return clients.Response{}, err + } client := s.clientFor(candidate, simulated) callStartedAt := time.Now() response, err := client.Run(ctx, clients.Request{ @@ -208,6 +221,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user Model: task.Model, Body: body, Candidate: candidate, + HTTPClient: requestHTTPClient, Stream: boolFromMap(body, "stream"), StreamDelta: onDelta, }) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7fdb737..b57af61 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,11 +7,14 @@ import type { GatewayAccessRuleUpsertRequest, GatewayApiKey, GatewayAuditLog, + GatewayNetworkProxyConfig, GatewayTenantUpsertRequest, GatewayTask, GatewayUserUpsertRequest, GatewayTenant, GatewayUser, + GatewayWalletAccount, + GatewayWalletTransaction, IntegrationPlatform, ModelCatalogResponse, PlatformModel, @@ -39,9 +42,11 @@ import { deleteTenant, deleteUserGroup, getHealth, + getNetworkProxyConfig, getTask, - listAuditLogs, + getWalletSummary, listAccessRules, + listAuditLogs, listApiKeyAccessRules, listApiKeys, listBaseModels, @@ -55,6 +60,7 @@ import { listPricingRuleSets, listRuntimePolicySets, listTasks, + listWalletTransactions, listPublicBaseModels, listPublicCatalogProviders, listRateLimitWindows, @@ -112,6 +118,7 @@ import type { RegisterForm, TaskForm, WorkspaceTaskQuery, + WorkspaceTransactionQuery, WorkspaceSection, } from './types'; @@ -121,6 +128,7 @@ type DataKey = | 'playgroundApiKeys' | 'playgroundModels' | 'modelCatalog' + | 'networkProxyConfig' | 'platforms' | 'models' | 'providers' @@ -133,6 +141,8 @@ type DataKey = | 'users' | 'userGroups' | 'tasks' + | 'wallet' + | 'walletTransactions' | 'accessRules' | 'auditLogs' | 'apiKeys'; @@ -143,6 +153,7 @@ export function App() { const [adminSection, setAdminSection] = useState(initialRoute.adminSection); const [workspaceSection, setWorkspaceSection] = useState(initialRoute.workspaceSection); const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState(initialRoute.workspaceTaskQuery); + const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState(() => defaultWorkspaceTransactionQuery()); const [apiDocSection, setApiDocSection] = useState(initialRoute.apiDocSection); const [playgroundMode, setPlaygroundMode] = useState(initialRoute.playgroundMode); const [token, setToken] = useState(readStoredAccessToken); @@ -159,6 +170,7 @@ export function App() { summary: { modelCount: 0, sourceCount: 0 }, }); const [playgroundModels, setPlaygroundModels] = useState([]); + const [networkProxyConfig, setNetworkProxyConfig] = useState(null); const [providers, setProviders] = useState([]); const [baseModels, setBaseModels] = useState([]); const [pricingRules, setPricingRules] = useState([]); @@ -179,6 +191,9 @@ export function App() { const [taskResult, setTaskResult] = useState(null); const [tasks, setTasks] = useState([]); const [taskTotal, setTaskTotal] = useState(0); + const [walletAccounts, setWalletAccounts] = useState([]); + const [walletTransactions, setWalletTransactions] = useState([]); + const [walletTransactionTotal, setWalletTransactionTotal] = useState(0); const [coreState, setCoreState] = useState('idle'); const [coreMessage, setCoreMessage] = useState(''); const [state, setState] = useState('idle'); @@ -187,6 +202,8 @@ export function App() { const loadingDataKeysRef = useRef(new Set()); const loadedTaskQueryKeyRef = useRef(''); const currentTaskQueryKeyRef = useRef(''); + const loadedTransactionQueryKeyRef = useRef(''); + const currentTransactionQueryKeyRef = useRef(''); const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({ setBaseModels, setCoreMessage, @@ -207,14 +224,16 @@ export function App() { token, }); const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery); + const transactionListRequestKey = workspaceTransactionQueryKey(workspaceTransactionQuery); currentTaskQueryKeyRef.current = taskListRequestKey; + currentTransactionQueryKeyRef.current = transactionListRequestKey; useEffect(() => { void ensureData(['health']); }, []); useEffect(() => { void ensureRouteData(token); - }, [activePage, adminSection, taskListRequestKey, workspaceSection, token]); + }, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]); useEffect(() => { function handlePopState() { applyRoute(parseAppRoute()); @@ -249,6 +268,7 @@ export function App() { baseModels, modelCatalog, models, + networkProxyConfig, platforms, pricingRules, pricingRuleSets, @@ -260,7 +280,9 @@ export function App() { tenants, userGroups, users, - }), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]); + walletAccounts, + walletTransactions, + }), [accessRules, apiKeys, auditLogs, baseModels, modelCatalog, models, networkProxyConfig, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users, walletAccounts, walletTransactions]); async function refresh(nextToken = token) { await ensureRouteData(nextToken, true); @@ -275,6 +297,10 @@ export function App() { loadedDataKeysRef.current.delete('tasks'); loadingDataKeysRef.current.delete('tasks'); } + if (activePage === 'workspace' && workspaceSection === 'transactions' && loadedTransactionQueryKeyRef.current !== transactionListRequestKey) { + loadedDataKeysRef.current.delete('walletTransactions'); + loadingDataKeysRef.current.delete('walletTransactions'); + } await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force); } @@ -326,6 +352,9 @@ export function App() { case 'modelCatalog': setModelCatalog(await listModelCatalog(nextToken)); return; + case 'networkProxyConfig': + setNetworkProxyConfig(await getNetworkProxyConfig(nextToken)); + return; case 'playgroundModels': setPlaygroundModels((await listPlayableModels(nextToken)).items); return; @@ -373,6 +402,20 @@ export function App() { loadedTaskQueryKeyRef.current = requestKey; } return; + case 'wallet': { + const response = await getWalletSummary(nextToken); + setWalletAccounts(response.accounts); + return; + } + case 'walletTransactions': { + const requestKey = transactionListRequestKey; + const response = await listWalletTransactions(nextToken, { ...normalizeWorkspaceTransactionQuery(workspaceTransactionQuery), direction: 'debit' }); + if (requestKey !== currentTransactionQueryKeyRef.current) return; + setWalletTransactions(response.items); + setWalletTransactionTotal(response.total ?? response.items.length); + loadedTransactionQueryKeyRef.current = requestKey; + return; + } case 'accessRules': setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys' ? listApiKeyAccessRules(nextToken) @@ -707,7 +750,7 @@ export function App() { const detail = await getTask(credential, response.task.id); setTaskResult(detail); setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]); - invalidateDataKeys('tasks'); + invalidateDataKeys('tasks', 'wallet', 'walletTransactions'); setCoreState('ready'); setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`); } catch (err) { @@ -726,6 +769,7 @@ export function App() { setModels([]); setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } }); setPlaygroundModels([]); + setNetworkProxyConfig(null); setProviders([]); setBaseModels([]); setPricingRules([]); @@ -744,6 +788,10 @@ export function App() { setTaskResult(null); setTasks([]); setTaskTotal(0); + setWalletAccounts([]); + setWalletTransactions([]); + setWalletTransactionTotal(0); + setWorkspaceTransactionQuery(defaultWorkspaceTransactionQuery()); setCoreMessage(''); navigatePath('/'); } @@ -849,12 +897,15 @@ export function App() { state={coreState} taskQuery={workspaceTaskQuery} taskTotal={taskTotal} + transactionQuery={workspaceTransactionQuery} + transactionTotal={walletTransactionTotal} onBatchAccessRules={batchSaveAPIKeyAccessRules} onDeleteApiKey={removeAPIKey} onApiKeyFormChange={setApiKeyForm} onSectionChange={navigateWorkspaceSection} onSubmitApiKey={submitAPIKey} onTaskQueryChange={navigateWorkspaceTaskQuery} + onTransactionQueryChange={setWorkspaceTransactionQuery} onUseApiKeyForPlayground={useApiKeyForPlayground} /> ) : ( @@ -1010,6 +1061,39 @@ function nonEmptyRecord(value: Record | undefined) { return value && Object.keys(value).length > 0 ? value : undefined; } +function defaultWorkspaceTransactionQuery(): WorkspaceTransactionQuery { + return { + query: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: 10, + }; +} + +function normalizeWorkspaceTransactionQuery(query: WorkspaceTransactionQuery): WorkspaceTransactionQuery { + return { + query: query.query.trim(), + createdFrom: query.createdFrom.trim(), + createdTo: query.createdTo.trim(), + page: positiveInt(query.page, 1), + pageSize: clampTransactionPageSize(query.pageSize), + }; +} + +function workspaceTransactionQueryKey(query: WorkspaceTransactionQuery) { + return JSON.stringify(normalizeWorkspaceTransactionQuery(query)); +} + +function positiveInt(value: number, fallback: number) { + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +function clampTransactionPageSize(value: number) { + const normalized = positiveInt(value, 10); + return Math.min(100, Math.max(1, normalized)); +} + function dataKeysForRoute( activePage: PageKey, adminSection: AdminSection, @@ -1027,8 +1111,10 @@ function dataKeysForRoute( if (activePage === 'workspace') { if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys']; + if (workspaceSection === 'billing') return ['wallet']; if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels']; if (workspaceSection === 'tasks') return ['tasks']; + if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions']; return []; } @@ -1045,7 +1131,7 @@ function dataKeysForRoute( case 'baseModels': return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets']; case 'platforms': - return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets']; + return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig']; case 'tenants': return ['tenants', 'userGroups']; case 'users': diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index acaf269..dd7fbe6 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -12,9 +12,11 @@ import type { GatewayAuditLog, GatewayTenant, GatewayTenantUpsertRequest, + GatewayNetworkProxyConfig, GatewayTask, GatewayUser, GatewayUserUpsertRequest, + GatewayWalletTransaction, IntegrationPlatform, ListResponse, ModelCatalogResponse, @@ -30,11 +32,35 @@ import type { UserGroupUpsertRequest, WalletAdjustmentResponse, WalletBalanceAdjustmentRequest, + WalletSummaryResponse, } from '@easyai-ai-gateway/contracts'; import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types'; const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088'; +interface GatewayErrorDetails { + code?: string; + message: string; + requestId?: string; + status?: number; + taskId?: string; +} + +export class GatewayApiError extends Error { + readonly details: GatewayErrorDetails; + + constructor(details: GatewayErrorDetails | string) { + const normalized = typeof details === 'string' ? { message: details } : details; + super(formatGatewayErrorDetails(normalized)); + this.name = 'GatewayApiError'; + this.details = normalized; + } + + toString() { + return this.message; + } +} + export interface HealthResponse { ok: boolean; service: string; @@ -508,7 +534,7 @@ export async function* streamChatCompletionText( }); if (!response.ok) { const body = await response.text(); - throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`); + throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`)); } if (!response.body) { return; @@ -523,13 +549,15 @@ export async function* streamChatCompletionText( const events = buffer.split(/\n\n/); buffer = events.pop() ?? ''; for (const eventBlock of events) { - const delta = parseSSEBlockDelta(eventBlock); - if (delta) yield delta; + const event = parseSSEBlock(eventBlock); + if (event.error) throw new GatewayApiError(event.error); + if (event.delta) yield event.delta; } } if (buffer.trim()) { - const delta = parseSSEBlockDelta(buffer); - if (delta) yield delta; + const event = parseSSEBlock(buffer); + if (event.error) throw new GatewayApiError(event.error); + if (event.delta) yield event.delta; } } @@ -607,7 +635,7 @@ export async function estimatePricing( } export async function getTask(token: string, taskId: string): Promise { - return request(`/api/v1/tasks/${taskId}`, { token }); + return request(`/api/workspace/tasks/${taskId}`, { token }); } export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise> { @@ -619,7 +647,27 @@ export async function listTasks(token: string, query: WorkspaceTaskQuery): Promi if (query.modelType) search.set('modelType', query.modelType); if (query.createdFrom) search.set('createdFrom', query.createdFrom); if (query.createdTo) search.set('createdTo', query.createdTo); - return request>(`/api/v1/tasks?${search.toString()}`, { token }); + return request>(`/api/workspace/tasks?${search.toString()}`, { token }); +} + +export async function getWalletSummary(token: string): Promise { + return request('/api/workspace/wallet', { token }); +} + +export async function listWalletTransactions( + token: string, + input: { query?: string; direction?: string; transactionType?: string; createdFrom?: string; createdTo?: string; page?: number; pageSize?: number } = {}, +): Promise> { + const search = new URLSearchParams({ + page: String(input.page ?? 1), + pageSize: String(input.pageSize ?? 50), + }); + if (input.query) search.set('q', input.query); + if (input.direction) search.set('direction', input.direction); + if (input.transactionType) search.set('transactionType', input.transactionType); + if (input.createdFrom) search.set('createdFrom', input.createdFrom); + if (input.createdTo) search.set('createdTo', input.createdTo); + return request>(`/api/workspace/wallet/transactions?${search.toString()}`, { token }); } export function resolveApiAssetUrl(src: string) { @@ -631,6 +679,10 @@ export async function listRateLimitWindows(token: string): Promise>('/api/admin/runtime/rate-limit-windows', { token }); } +export async function getNetworkProxyConfig(token: string): Promise { + return request('/api/admin/config/network-proxy', { token }); +} + async function request( path: string, options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {}, @@ -649,7 +701,7 @@ async function request( }); if (!response.ok) { const body = await response.text(); - throw new Error(parseErrorMessage(body) || `Request failed: ${response.status}`); + throw new GatewayApiError(parseErrorDetails(body, response.status, `Request failed: ${response.status}`)); } if (response.status === 204) { return undefined as T; @@ -658,33 +710,96 @@ async function request( } function parseErrorMessage(body: string) { + return formatGatewayErrorDetails(parseErrorDetails(body)); +} + +function parseErrorDetails(body: string, status?: number, fallback = ''): GatewayErrorDetails { if (!body) { - return ''; + return { message: fallback, status }; } try { - const parsed = JSON.parse(body) as { error?: { message?: string } }; - return parsed.error?.message ?? body; + const parsed = JSON.parse(body) as unknown; + return errorDetailsFromParsed(parsed, status, fallback || body); } catch { - return body; + return { message: body || fallback, status }; } } -function parseSSEBlockDelta(block: string) { +function parseSSEBlock(block: string): { delta: string; error?: GatewayErrorDetails } { + const eventName = block + .split(/\n/) + .find((line) => line.startsWith('event:')) + ?.replace(/^event:\s?/, '') + .trim(); const data = block .split(/\n/) .filter((line) => line.startsWith('data:')) .map((line) => line.replace(/^data:\s?/, '')) .join('\n') .trim(); - if (!data || data === '[DONE]') return ''; + if (!data || data === '[DONE]') return { delta: '' }; try { const parsed = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>; delta?: string; + error?: unknown; output_text?: string; }; - return parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? ''; + if (eventName === 'error' || parsed.error) { + return { delta: '', error: errorDetailsFromParsed(parsed, undefined, data) }; + } + return { delta: parsed.choices?.[0]?.delta?.content ?? parsed.delta ?? parsed.output_text ?? '' }; } catch { - return data; + if (eventName === 'error') return { delta: '', error: { message: data } }; + return { delta: data }; } } + +function errorDetailsFromParsed(parsed: unknown, status?: number, fallback = ''): GatewayErrorDetails { + const root = recordFromUnknown(parsed); + if (!root) return { message: fallback, status }; + const error = recordFromUnknown(root.error); + const message = firstString( + error?.message, + error?.error, + root.message, + root.errorMessage, + fallback, + ); + return { + code: firstString(error?.code, error?.type, root.code, root.errorCode), + message: message || fallback, + requestId: firstString(error?.requestId, error?.request_id, root.requestId, root.request_id), + status: numberFromUnknown(error?.status) ?? numberFromUnknown(root.status) ?? status, + taskId: firstString(error?.taskId, error?.task_id, root.taskId, root.task_id), + }; +} + +function formatGatewayErrorDetails(details: GatewayErrorDetails) { + const message = details.message || '请求失败'; + const meta = [ + details.code ? `错误码: ${details.code}` : '', + details.status ? `状态: ${details.status}` : '', + details.requestId ? `RequestID: ${details.requestId}` : '', + details.taskId ? `TaskID: ${details.taskId}` : '', + ].filter(Boolean); + return meta.length ? `${message}(${meta.join(',')})` : message; +} + +function recordFromUnknown(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function firstString(...values: unknown[]) { + return values.map((value) => typeof value === 'string' ? value.trim() : '').find(Boolean) ?? ''; +} + +function numberFromUnknown(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} diff --git a/apps/web/src/app-state.ts b/apps/web/src/app-state.ts index a5811bb..9b75cf8 100644 --- a/apps/web/src/app-state.ts +++ b/apps/web/src/app-state.ts @@ -4,9 +4,12 @@ import type { GatewayAccessRule, GatewayApiKey, GatewayAuditLog, + GatewayNetworkProxyConfig, GatewayTask, GatewayTenant, GatewayUser, + GatewayWalletAccount, + GatewayWalletTransaction, IntegrationPlatform, ModelCatalogResponse, PlatformModel, @@ -24,6 +27,7 @@ export interface ConsoleData { baseModels: BaseModelCatalogItem[]; modelCatalog: ModelCatalogResponse; models: PlatformModel[]; + networkProxyConfig: GatewayNetworkProxyConfig | null; platforms: IntegrationPlatform[]; pricingRules: PricingRule[]; pricingRuleSets: PricingRuleSet[]; @@ -35,6 +39,8 @@ export interface ConsoleData { tenants: GatewayTenant[]; userGroups: UserGroup[]; users: GatewayUser[]; + walletAccounts: GatewayWalletAccount[]; + walletTransactions: GatewayWalletTransaction[]; } export interface StatItem { diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx index 3925950..a69e005 100644 --- a/apps/web/src/pages/AdminPage.tsx +++ b/apps/web/src/pages/AdminPage.tsx @@ -133,6 +133,7 @@ export function AdminPage(props: { { if (!isMountedRef.current) return; setMediaRuns((current) => updateMediaRun(current, run.localId, { - error: detail.error, + error: gatewayTaskErrorText(detail, '任务执行失败'), status: detail.status, task: detail, })); @@ -240,7 +243,7 @@ export function PlaygroundPage(props: { setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task })); const detail = await pollTaskUntilSettled(credential, response.task); setMediaRuns((current) => updateMediaRun(current, localId, { - error: detail.error, + error: gatewayTaskErrorText(detail, '任务执行失败'), status: detail.status, task: detail, })); @@ -453,13 +456,13 @@ function AssistantChatPlayground(props: { async *run({ abortSignal, messages }) { if (!props.token) { props.onLogin(); - throw new Error('请先登录后再测试模型。'); + throw new GatewayApiError('请先登录后再测试模型。'); } if (!activeApiKeySecret) { - throw new Error('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。'); + throw new GatewayApiError('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。'); } if (!props.selectedModel) { - throw new Error('当前没有可用的大模型,请确认用户组权限或平台模型配置。'); + throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。'); } let text = ''; for await (const delta of streamChatCompletionText( @@ -696,6 +699,8 @@ function AssistantChatComposer(props: { } function AssistantMessage() { + const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error'); + return ( @@ -704,16 +709,19 @@ function AssistantMessage() { -
+
- - 模型正在回复... - + + 调用失败 + + + {!hasError && ( + + 模型正在回复... + + )}
- -
调用失败,请检查模型、平台凭证或测试模式配置。
-
); } diff --git a/apps/web/src/pages/WorkspacePage.tsx b/apps/web/src/pages/WorkspacePage.tsx index edcbc43..94cabe5 100644 --- a/apps/web/src/pages/WorkspacePage.tsx +++ b/apps/web/src/pages/WorkspacePage.tsx @@ -1,18 +1,19 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; import { Popover as AntPopover } from 'antd'; -import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react'; -import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; +import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react'; +import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts'; import type { ConsoleData } from '../app-state'; import { EntityTable } from '../components/EntityTable'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui'; import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor'; -import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types'; +import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types'; const tabs = [ { value: 'overview', label: '个人总览', icon: }, { value: 'billing', label: '余额充值', icon: }, { value: 'apiKeys', label: 'API Key', icon: }, { value: 'tasks', label: '任务记录', icon: }, + { value: 'transactions', label: '消费记录', icon: }, ] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>; const taskPageSizeOptions = [10, 20, 50]; @@ -28,12 +29,15 @@ export function WorkspacePage(props: { state: LoadState; taskQuery: WorkspaceTaskQuery; taskTotal: number; + transactionQuery: WorkspaceTransactionQuery; + transactionTotal: number; onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise; onDeleteApiKey: (apiKeyId: string) => Promise; onApiKeyFormChange: (value: ApiKeyForm) => void; onSectionChange: (value: WorkspaceSection) => void; onSubmitApiKey: (event: FormEvent) => void | Promise; onTaskQueryChange: (value: WorkspaceTaskQuery) => void; + onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void; onUseApiKeyForPlayground: (apiKeyId?: string) => void; }) { return ( @@ -42,9 +46,10 @@ export function WorkspacePage(props: {
{props.section === 'overview' && } - {props.section === 'billing' && } + {props.section === 'billing' && } {props.section === 'apiKeys' && } {props.section === 'tasks' && } + {props.section === 'transactions' && }
@@ -82,7 +87,9 @@ function WorkspaceOverview(props: { data: ConsoleData }) { ); } -function BillingPanel() { +function BillingPanel(props: { walletAccounts: GatewayWalletAccount[] }) { + const primaryWallet = primaryWalletAccount(props.walletAccounts); + const availableBalance = primaryWallet ? primaryWallet.balance - primaryWallet.frozenBalance : 0; return (
@@ -90,9 +97,15 @@ function BillingPanel() { 余额 - resource - 0.00 - local + {primaryWallet?.currency ?? 'resource'} + {formatMoney(primaryWallet?.balance ?? 0)} + {primaryWallet?.status ?? 'active'} +
+ + + + +
@@ -111,6 +124,233 @@ function BillingPanel() { ); } +function ConsumptionPanel(props: { + data: ConsoleData; + query: WorkspaceTransactionQuery; + total: number; + onQueryChange: (value: WorkspaceTransactionQuery) => void; +}) { + const transactions = props.data.walletTransactions; + const transactionQuery = props.query; + const [pageJump, setPageJump] = useState(String(transactionQuery.page)); + const pageSizeOptions = useMemo(() => { + return Array.from(new Set([...taskPageSizeOptions, transactionQuery.pageSize])).sort((a, b) => a - b); + }, [transactionQuery.pageSize]); + const totalPages = Math.max(1, Math.ceil(props.total / transactionQuery.pageSize)); + const currentPage = Math.min(transactionQuery.page, totalPages); + const pageStart = props.total ? Math.min((currentPage - 1) * transactionQuery.pageSize + 1, props.total) : 0; + const pageEnd = Math.min(currentPage * transactionQuery.pageSize, props.total); + const hasActiveFilters = Boolean(transactionQuery.query || transactionQuery.createdFrom || transactionQuery.createdTo); + + useEffect(() => { + if (transactionQuery.page > totalPages) { + props.onQueryChange({ ...transactionQuery, page: totalPages }); + } + }, [props.onQueryChange, transactionQuery, totalPages]); + + useEffect(() => { + setPageJump(String(currentPage)); + }, [currentPage]); + + function updateQuery(value: string) { + props.onQueryChange({ ...transactionQuery, query: value, page: 1 }); + } + + function updateCreatedRange(value: { from: string; to: string }) { + props.onQueryChange({ ...transactionQuery, createdFrom: value.from, createdTo: value.to, page: 1 }); + } + + function updatePageSize(value: string) { + const nextPageSize = Number(value); + props.onQueryChange({ ...transactionQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 }); + } + + function updatePage(page: number) { + props.onQueryChange({ ...transactionQuery, page }); + } + + function submitPageJump() { + const parsed = Number(pageJump); + if (!Number.isFinite(parsed) || parsed <= 0) { + setPageJump(String(currentPage)); + return; + } + updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed)))); + } + + function resetFilters() { + props.onQueryChange({ + query: '', + createdFrom: '', + createdTo: '', + page: 1, + pageSize: transactionQuery.pageSize, + }); + } + + return ( + + + + + + + + + + {transactions.length ? ( + + + 消费时间 + 模型 + 平台 + 类型 + API Key + Token 消耗 + 扣费 + 余额变化 + 任务 + + {transactions.map((transaction) => ( + + ))} +
+ ) : ( +
+ 暂无消费记录 + 完成可计费任务后会生成消费流水。 +
+ )} + +
+ + 共 {props.total} 条 · {pageStart}-{pageEnd} +
+
{ + event.preventDefault(); + submitPageJump(); + }} + > + 第 {currentPage} / {totalPages} 页 + 跳至 + setPageJump(event.target.value)} + /> + + +
+ + + + +
+
+
+
+ ); +} + +function WalletTransactionRecord(props: { transaction: GatewayWalletTransaction }) { + const transaction = props.transaction; + const metadata = transaction.metadata; + const referenceId = transaction.referenceId || metadataString(transaction.metadata, 'taskId'); + const requestId = metadataString(metadata, 'requestId'); + const model = metadataString(metadata, 'resolvedModel') || metadataString(metadata, 'platformModelAlias') || metadataString(metadata, 'providerModel') || metadataString(metadata, 'model'); + const requestedModel = metadataString(metadata, 'requestedModel'); + const platform = metadataString(metadata, 'platformName') || metadataString(metadata, 'platformKey') || metadataString(metadata, 'provider') || metadataString(metadata, 'clientId'); + const provider = metadataString(metadata, 'provider'); + const taskType = metadataString(metadata, 'modelType') || metadataString(metadata, 'kind'); + const apiKey = metadataString(metadata, 'apiKeyName') || metadataString(metadata, 'apiKeyPrefix') || metadataString(metadata, 'apiKeyId'); + const chargeAmount = transactionChargeAmount(transaction); + const chargeCurrency = transactionChargeCurrency(transaction); + return ( + + {formatDateTime(transaction.createdAt)} + + + {model || '-'} + {requestedModel && requestedModel !== model && {requestedModel}} + + + + + {platform || '-'} + {provider && provider !== platform && {provider}} + + + + + {taskType || '-'} + {transactionTypeLabel(transaction.transactionType)} + + + {apiKey || '-'} + {formatTokenUsage(metadataObject(metadata, 'usage'))} + + + {transaction.direction === 'debit' ? '-' : '+'}{formatMoney(chargeAmount)} {chargeCurrency} + + + + + {formatMoney(transaction.balanceBefore)} + {formatMoney(transaction.balanceAfter)} + + + + + {referenceId ? {referenceId} : '-'} + {requestId && RequestID {requestId}} + + + + ); +} + function ApiKeyPanel(props: { apiKeyForm: ApiKeyForm; apiKeySecret: string; @@ -657,11 +897,35 @@ function taskAttemptFailureReason(attempt: NonNullable[ if (detail && code && detail !== code) return `${detail}(${code})`; return detail || code || '失败'; } + function formatCellValue(value: unknown) { if (value === undefined || value === null || value === '') return '-'; return String(value); } +function primaryWalletAccount(accounts: GatewayWalletAccount[]) { + return accounts.find((account) => account.currency === 'resource') ?? accounts[0]; +} + +function formatMoney(value: unknown) { + const numericValue = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numericValue)) return '0.00'; + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }).format(numericValue); +} + +function transactionTypeLabel(value: string) { + if (value === 'task_billing') return '任务扣费'; + if (value === 'admin_adjust') return '余额调整'; + if (value === 'recharge') return '充值'; + if (value === 'refund') return '退款'; + if (value === 'reserve') return '冻结'; + if (value === 'release') return '释放'; + return value || '-'; +} + function firstText(...values: Array) { for (const value of values) { if (typeof value === 'string' && value.trim()) return value.trim(); @@ -674,6 +938,34 @@ function metadataString(metadata: Record | undefined, key: stri return typeof value === 'string' && value.trim() ? value.trim() : ''; } +function metadataNumber(metadata: Record | undefined, key: string) { + const value = metadata?.[key]; + if (value === undefined || value === null || value === '') return null; + const numericValue = typeof value === 'number' ? value : Number(value); + return Number.isFinite(numericValue) ? numericValue : null; +} + +function metadataObject(metadata: Record | undefined, key: string): Record { + const value = metadata?.[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return value as Record; +} + +function objectString(value: Record, key: string) { + const next = value[key]; + return typeof next === 'string' && next.trim() ? next.trim() : ''; +} + +function transactionChargeAmount(transaction: GatewayWalletTransaction) { + return metadataNumber(transaction.metadata, 'finalChargeAmount') ?? transaction.amount; +} + +function transactionChargeCurrency(transaction: GatewayWalletTransaction) { + const summary = metadataObject(transaction.metadata, 'billingSummary'); + const finalCharge = metadataObject(summary, 'finalCharge'); + return objectString(finalCharge, 'currency') || objectString(summary, 'currency') || transaction.currency || 'resource'; +} + function formatTokenUsage(usage: Record) { const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens); const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens); diff --git a/apps/web/src/pages/admin/PlatformManagementPanel.tsx b/apps/web/src/pages/admin/PlatformManagementPanel.tsx index 185f3cb..3a10a4a 100644 --- a/apps/web/src/pages/admin/PlatformManagementPanel.tsx +++ b/apps/web/src/pages/admin/PlatformManagementPanel.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'; -import { Boxes, CheckCircle2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react'; +import { Boxes, CheckCircle2, Globe2, KeyRound, Pencil, Plus, RotateCcw, Search, ServerCog, ShieldCheck, SlidersHorizontal, Trash2, X } from 'lucide-react'; import type { BaseModelCatalogItem, CatalogProvider, IntegrationPlatform, PlatformModel, PricingRuleSet } from '@easyai-ai-gateway/contracts'; import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, EmptyState, FormDialog, Input, Label, ScreenMessage, Select, Table, TableCell, TableHead, TableRow } from '../../components/ui'; import type { LoadState, PlatformWithModelsInput } from '../../types'; @@ -23,6 +23,7 @@ import { ModelCatalogCard } from './ModelCatalogCard'; export function PlatformManagementPanel(props: { baseModels: BaseModelCatalogItem[]; message: string; + networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null; platforms: IntegrationPlatform[]; platformModels: PlatformModel[]; pricingRuleSets: PricingRuleSet[]; @@ -37,6 +38,7 @@ export function PlatformManagementPanel(props: { const [modelQuery, setModelQuery] = useState(''); const [selectedPlatformId, setSelectedPlatformId] = useState(''); const [validationMessage, setValidationMessage] = useState(''); + const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false); const [editingPlatform, setEditingPlatform] = useState(null); const [pendingDeletePlatform, setPendingDeletePlatform] = useState(null); const providerMap = useMemo(() => new Map(props.providers.map((item) => [item.providerKey, item])), [props.providers]); @@ -109,6 +111,17 @@ export function PlatformManagementPanel(props: { }); } + function updateProxyMode(proxyMode: PlatformWizardForm['proxyMode']) { + if (proxyMode === 'global') { + setGlobalProxyNoticeOpen(true); + } + setForm({ + ...form, + proxyMode, + httpProxy: proxyMode === 'custom' ? form.httpProxy : '', + }); + } + async function submit(event: FormEvent) { event.preventDefault(); const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) }); @@ -254,6 +267,30 @@ export function PlatformManagementPanel(props: { /> + } title="网络代理"> + + {form.proxyMode === 'custom' && ( + + )} + + } title="路由与计费"> + setGlobalProxyNoticeOpen(false)} + onConfirm={() => setGlobalProxyNoticeOpen(false)} + > +

当前设置代理服务器:{globalProxyStatusText(props.networkProxyConfig)}

+
model.platformId === platform.id); return { ...createEmptyPlatformForm(platform.provider, defaults), @@ -892,6 +943,8 @@ function platformToForm( tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'), concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'), testMode: readBoolean(config, 'testMode', false), + proxyMode: networkProxy.proxyMode, + httpProxy: networkProxy.httpProxy, supportBase64Input: readBoolean(config, 'supportBase64Input', true), supportUrlInput: readBoolean(config, 'supportUrlInput', true), selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels), @@ -969,6 +1022,16 @@ function readBoolean(source: Record, key: string, fallback: boo return typeof value === 'boolean' ? value : fallback; } +function readNetworkProxyConfig(config: Record): Pick { + const networkProxy = readRecord(config.networkProxy); + const mode = readString(networkProxy.mode || config.proxyMode); + const httpProxy = readString(networkProxy.httpProxy || config.httpProxy); + if (mode === 'global' || mode === 'custom') { + return { proxyMode: mode, httpProxy }; + } + return { proxyMode: 'none', httpProxy: '' }; +} + function readNumber(source: Record, key: string) { const value = source[key]; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; @@ -1056,7 +1119,19 @@ function platformRuntimeSummary(platform: IntegrationPlatform) { const retryPolicy = platform.retryPolicy ?? {}; const retryEnabled = readBoolean(retryPolicy, 'enabled', true); const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2; - return `优先级 ${platform.priority} · ${retryEnabled ? `最多重试 ${maxAttempts} 次` : '不重试'}`; + return `优先级 ${platform.priority} · ${retryEnabled ? `最多重试 ${maxAttempts} 次` : '不重试'} · ${proxyModeText(readNetworkProxyConfig(platform.config ?? {}).proxyMode)}`; +} + +function proxyModeText(mode: PlatformWizardForm['proxyMode']) { + if (mode === 'global') return '全局代理'; + if (mode === 'custom') return '自定义代理'; + return '直连'; +} + +function globalProxyStatusText(config: { globalHttpProxy?: string; globalHttpProxySet: boolean } | null) { + if (!config) return '读取中'; + const proxy = config.globalHttpProxy?.trim() ?? ''; + return config.globalHttpProxySet && proxy ? proxy : '未设置'; } function formatLimit(value: number) { diff --git a/apps/web/src/pages/admin/platform-form.ts b/apps/web/src/pages/admin/platform-form.ts index 486b585..2b75769 100644 --- a/apps/web/src/pages/admin/platform-form.ts +++ b/apps/web/src/pages/admin/platform-form.ts @@ -30,6 +30,8 @@ export interface PlatformWizardForm { tpmLimit: string; concurrencyLimit: string; testMode: boolean; + proxyMode: 'none' | 'global' | 'custom'; + httpProxy: string; supportBase64Input: boolean; supportUrlInput: boolean; modelDiscountFactor: string; @@ -75,6 +77,8 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec tpmLimit: '', concurrencyLimit: '', testMode: false, + proxyMode: 'none', + httpProxy: '', supportBase64Input: true, supportUrlInput: true, modelDiscountFactor: '', @@ -130,6 +134,7 @@ export function platformPayload(form: PlatformWizardForm, options: { preserveEmp credentials, config: { testMode: form.testMode, + networkProxy: networkProxyPayload(form), supportBase64Input: form.supportBase64Input, supportUrlInput: form.supportUrlInput, source: 'gateway-admin', @@ -250,6 +255,7 @@ export function validatePlatformForm(form: PlatformWizardForm, selectedCount: nu if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) { return '请填写 AccessKey 和 SecretKey,或开启测试模式。'; } + if (form.proxyMode === 'custom' && !form.httpProxy.trim()) return '请填写自定义 HTTP 代理地址。'; if (selectedCount === 0) return '请至少添加一个模型。'; return ''; } @@ -322,6 +328,16 @@ function rateLimitPolicyPayload(form: Pick @@ -517,7 +544,12 @@ function MediaTaskCard(props: { - {props.run.error &&

{props.run.error}

} + {errorText && ( +
+ 错误详情 + {errorText} +
+ )}