feat(admin): 添加网络代理配置和钱包交易功能

- 在管理面板中集成网络代理配置显示和平台代理设置
- 添加钱包摘要和交易列表API接口及数据管理
- 实现SSE流式响应中的错误处理机制
- 添加全局HTTP代理环境变量配置支持
- 更新平台表单以支持代理模式选择和自定义代理地址
- 集成钱包交易查询过滤和分页功能
- 优化API错误详情解析和显示格式
This commit is contained in:
wangbo 2026-05-11 23:02:10 +08:00
parent c992f1de60
commit f550c0acd5
30 changed files with 1455 additions and 76 deletions

View File

@ -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}
}

View File

@ -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
}

View File

@ -16,6 +16,7 @@ type Request struct {
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
Stream bool
StreamDelta StreamDelta
}

View File

@ -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}
}

View File

@ -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)

View File

@ -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),
})
}

View File

@ -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
}

View File

@ -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)

View File

@ -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) {

View File

@ -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)))

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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()}
}

View File

@ -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,
})

View File

@ -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<AdminSection>(initialRoute.adminSection);
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
const [token, setToken] = useState(readStoredAccessToken);
@ -159,6 +170,7 @@ export function App() {
summary: { modelCount: 0, sourceCount: 0 },
});
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
@ -179,6 +191,9 @@ export function App() {
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
const [tasks, setTasks] = useState<GatewayTask[]>([]);
const [taskTotal, setTaskTotal] = useState(0);
const [walletAccounts, setWalletAccounts] = useState<GatewayWalletAccount[]>([]);
const [walletTransactions, setWalletTransactions] = useState<GatewayWalletTransaction[]>([]);
const [walletTransactionTotal, setWalletTransactionTotal] = useState(0);
const [coreState, setCoreState] = useState<LoadState>('idle');
const [coreMessage, setCoreMessage] = useState('');
const [state, setState] = useState<LoadState>('idle');
@ -187,6 +202,8 @@ export function App() {
const loadingDataKeysRef = useRef(new Set<DataKey>());
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<string, unknown> | 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':

View File

@ -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<GatewayTask> {
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
return request<GatewayTask>(`/api/workspace/tasks/${taskId}`, { token });
}
export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise<ListResponse<GatewayTask>> {
@ -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<ListResponse<GatewayTask>>(`/api/v1/tasks?${search.toString()}`, { token });
return request<ListResponse<GatewayTask>>(`/api/workspace/tasks?${search.toString()}`, { token });
}
export async function getWalletSummary(token: string): Promise<WalletSummaryResponse> {
return request<WalletSummaryResponse>('/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<ListResponse<GatewayWalletTransaction>> {
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<ListResponse<GatewayWalletTransaction>>(`/api/workspace/wallet/transactions?${search.toString()}`, { token });
}
export function resolveApiAssetUrl(src: string) {
@ -631,6 +679,10 @@ export async function listRateLimitWindows(token: string): Promise<ListResponse<
return request<ListResponse<RateLimitWindow>>('/api/admin/runtime/rate-limit-windows', { token });
}
export async function getNetworkProxyConfig(token: string): Promise<GatewayNetworkProxyConfig> {
return request<GatewayNetworkProxyConfig>('/api/admin/config/network-proxy', { token });
}
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
@ -649,7 +701,7 @@ async function request<T>(
});
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<T>(
}
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<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
return value as Record<string, unknown>;
}
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;
}

View File

@ -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 {

View File

@ -133,6 +133,7 @@ export function AdminPage(props: {
<PlatformManagementPanel
baseModels={props.data.baseModels}
message={props.operationMessage}
networkProxyConfig={props.data.networkProxyConfig}
platformModels={props.data.models}
platforms={props.data.platforms}
pricingRuleSets={props.data.pricingRuleSets}

View File

@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import {
AssistantRuntimeProvider,
ComposerPrimitive,
ErrorPrimitive,
MessagePrimitive,
ThreadPrimitive,
useMessage,
useMessagePartText,
useLocalRuntime,
useThread,
@ -19,11 +21,12 @@ import { mermaid } from '@streamdown/mermaid';
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
import { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
import { Badge, Button, Select, Textarea } from '../components/ui';
import { createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
import { GatewayApiError, createImageGenerationTask, createVideoGenerationTask, getTask, streamChatCompletionText } from '../api';
import type { PlaygroundMode } from '../types';
import {
defaultMediaGenerationSettings,
deriveMediaModelCapabilities,
gatewayTaskErrorText,
mediaRequestPayload,
MediaSettingsPopover,
MediaTaskBoard,
@ -172,7 +175,7 @@ export function PlaygroundPage(props: {
.then((detail) => {
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 (
<MessagePrimitive.Root className="assistantMessage">
<MessagePrimitive.If user>
@ -704,16 +709,19 @@ function AssistantMessage() {
</div>
</MessagePrimitive.If>
<MessagePrimitive.If assistant>
<div className="assistantBubble assistant">
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
<MessagePrimitive.If hasContent={false}>
<span className="assistantTyping">...</span>
</MessagePrimitive.If>
<MessagePrimitive.Error>
<strong></strong>
<ErrorPrimitive.Message className="assistantErrorMessage" />
</MessagePrimitive.Error>
{!hasError && (
<MessagePrimitive.If hasContent={false}>
<span className="assistantTyping">...</span>
</MessagePrimitive.If>
)}
</div>
</MessagePrimitive.If>
<MessagePrimitive.Error>
<div className="assistantBubble error"></div>
</MessagePrimitive.Error>
</MessagePrimitive.Root>
);
}

View File

@ -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: <UserRound size={15} /> },
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
{ value: 'transactions', label: '消费记录', icon: <ReceiptText size={15} /> },
] 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<void>;
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSectionChange: (value: WorkspaceSection) => void;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
}) {
return (
@ -42,9 +46,10 @@ export function WorkspacePage(props: {
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
<div className="subPageContent">
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
{props.section === 'billing' && <BillingPanel />}
{props.section === 'billing' && <BillingPanel walletAccounts={props.data.walletAccounts} />}
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
{props.section === 'transactions' && <ConsumptionPanel data={props.data} query={props.transactionQuery} total={props.transactionTotal} onQueryChange={props.onTransactionQueryChange} />}
</div>
</div>
</div>
@ -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 (
<section className="contentGrid two">
<Card>
@ -90,9 +97,15 @@ function BillingPanel() {
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="balanceCard">
<span>resource</span>
<strong>0.00</strong>
<Badge variant="secondary">local</Badge>
<span>{primaryWallet?.currency ?? 'resource'}</span>
<strong>{formatMoney(primaryWallet?.balance ?? 0)}</strong>
<Badge variant={primaryWallet?.status === 'active' || !primaryWallet ? 'success' : 'secondary'}>{primaryWallet?.status ?? 'active'}</Badge>
<div className="walletMetricGrid">
<InfoItem label="可用余额" value={formatMoney(availableBalance)} />
<InfoItem label="冻结余额" value={formatMoney(primaryWallet?.frozenBalance ?? 0)} />
<InfoItem label="累计充值" value={formatMoney(primaryWallet?.totalRecharged ?? 0)} />
<InfoItem label="累计消费" value={formatMoney(primaryWallet?.totalSpent ?? 0)} />
</div>
</CardContent>
</Card>
<Card>
@ -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 (
<Card className="shTableViewportCard walletTransactionViewport">
<CardContent className="shTableViewportPanel">
<TableViewportLayout>
<TableToolbar className="walletTransactionFilters">
<Label className="taskRecordSearchLabel">
<span className="taskRecordSearchBox">
<Search size={15} />
<Input
value={transactionQuery.query}
placeholder="搜索任务 / 模型 / 平台 / 类型 / API Key / RequestID"
onChange={(event) => updateQuery(event.target.value)}
/>
</span>
</Label>
<Label className="taskRecordRangeLabel">
<DateTimeRangePicker
from={transactionQuery.createdFrom}
fromPlaceholder="开始日期"
to={transactionQuery.createdTo}
toPlaceholder="结束日期"
onChange={updateCreatedRange}
/>
</Label>
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
<RotateCcw size={14} />
</Button>
</TableToolbar>
{transactions.length ? (
<Table className="shTableViewport walletTransactionTable" density="compact">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead>Token </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{transactions.map((transaction) => (
<WalletTransactionRecord key={transaction.id} transaction={transaction} />
))}
</Table>
) : (
<div className="emptyState">
<strong></strong>
<span></span>
</div>
)}
<TableFooter>
<div className="shTableFooterGroup">
<Label>
<Select size="sm" value={String(transactionQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>{option} </option>
))}
</Select>
</Label>
<span> {props.total} · {pageStart}-{pageEnd}</span>
</div>
<form
className="shTablePageJump"
onSubmit={(event) => {
event.preventDefault();
submitPageJump();
}}
>
<span> {currentPage} / {totalPages} </span>
<span></span>
<Input
aria-label="跳转页码"
inputMode="numeric"
min={1}
max={totalPages}
size="xs"
type="number"
value={pageJump}
onChange={(event) => setPageJump(event.target.value)}
/>
<span></span>
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}></Button>
</form>
<TablePageActions>
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
<ChevronLeft size={14} />
</Button>
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
<ChevronRight size={14} />
</Button>
</TablePageActions>
</TableFooter>
</TableViewportLayout>
</CardContent>
</Card>
);
}
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 (
<TableRow>
<TableCell>{formatDateTime(transaction.createdAt)}</TableCell>
<TableCell className="walletTransactionModelCell">
<span className="walletTransactionPrimaryCell">
<strong>{model || '-'}</strong>
{requestedModel && requestedModel !== model && <small>{requestedModel}</small>}
</span>
</TableCell>
<TableCell className="walletTransactionPlatformCell">
<span className="walletTransactionPrimaryCell">
<strong>{platform || '-'}</strong>
{provider && provider !== platform && <small>{provider}</small>}
</span>
</TableCell>
<TableCell>
<span className="walletTransactionPrimaryCell">
<strong>{taskType || '-'}</strong>
<small>{transactionTypeLabel(transaction.transactionType)}</small>
</span>
</TableCell>
<TableCell>{apiKey || '-'}</TableCell>
<TableCell className="walletTransactionTokenCell">{formatTokenUsage(metadataObject(metadata, 'usage'))}</TableCell>
<TableCell>
<span className="walletTransactionAmount" data-direction={transaction.direction}>
{transaction.direction === 'debit' ? '-' : '+'}{formatMoney(chargeAmount)} {chargeCurrency}
</span>
</TableCell>
<TableCell>
<span className="walletBalanceChange">
<span>{formatMoney(transaction.balanceBefore)}</span>
<span>{formatMoney(transaction.balanceAfter)}</span>
</span>
</TableCell>
<TableCell>
<span className="walletTransactionRef">
{referenceId ? <code title={referenceId}>{referenceId}</code> : '-'}
{requestId && <small title={requestId}>RequestID {requestId}</small>}
</span>
</TableCell>
</TableRow>
);
}
function ApiKeyPanel(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
@ -657,11 +897,35 @@ function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[
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<unknown>) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim();
@ -674,6 +938,34 @@ function metadataString(metadata: Record<string, unknown> | undefined, key: stri
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function metadataNumber(metadata: Record<string, unknown> | 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<string, unknown> | undefined, key: string): Record<string, unknown> {
const value = metadata?.[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function objectString(value: Record<string, unknown>, 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<string, unknown>) {
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);

View File

@ -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<IntegrationPlatform | null>(null);
const [pendingDeletePlatform, setPendingDeletePlatform] = useState<IntegrationPlatform | null>(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<HTMLFormElement>) {
event.preventDefault();
const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) });
@ -254,6 +267,30 @@ export function PlatformManagementPanel(props: {
/>
</FormSection>
<FormSection icon={<Globe2 size={16} />} title="网络代理">
<Label>
<Select
value={form.proxyMode}
onChange={(event) => updateProxyMode(event.target.value as PlatformWizardForm['proxyMode'])}
>
<option value="none">使</option>
<option value="global">使</option>
<option value="custom"></option>
</Select>
</Label>
{form.proxyMode === 'custom' && (
<Label>
HTTP
<Input
value={form.httpProxy}
placeholder="http://127.0.0.1:7890"
onChange={(event) => setForm({ ...form, httpProxy: event.target.value })}
/>
</Label>
)}
</FormSection>
<FormSection icon={<SlidersHorizontal size={16} />} title="路由与计费">
<Label>
@ -296,6 +333,19 @@ export function PlatformManagementPanel(props: {
/>
</FormSection>
</FormDialog>
<ConfirmDialog
cancelLabel="关闭"
confirmLabel="知道了"
confirmVariant="default"
description="使用全局代理前,请确认网关服务已设置 AI_GATEWAY_GLOBAL_HTTP_PROXY 或 GLOBAL_HTTP_PROXY 环境变量;也兼容 HTTP_PROXY、HTTPS_PROXY、ALL_PROXY。修改环境变量后需要重启服务才会生效。"
loading={props.state === 'loading'}
open={globalProxyNoticeOpen}
title="使用全局代理"
onCancel={() => setGlobalProxyNoticeOpen(false)}
onConfirm={() => setGlobalProxyNoticeOpen(false)}
>
<p className="platformProxyStatus">{globalProxyStatusText(props.networkProxyConfig)}</p>
</ConfirmDialog>
<ConfirmDialog
confirmLabel="删除平台"
description="已绑定的模型会一并删除,删除后不可恢复。"
@ -868,6 +918,7 @@ function platformToForm(
const config = platform.config ?? {};
const retryPolicy = platform.retryPolicy ?? {};
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
const networkProxy = readNetworkProxyConfig(config);
const currentModels = platformModels.filter((model) => 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<string, unknown>, key: string, fallback: boo
return typeof value === 'boolean' ? value : fallback;
}
function readNetworkProxyConfig(config: Record<string, unknown>): Pick<PlatformWizardForm, 'proxyMode' | 'httpProxy'> {
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<string, unknown>, 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) {

View File

@ -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<PlatformWizardForm, 'rpmLimit' | 'rps
return rules.length ? { rules } : {};
}
function networkProxyPayload(form: PlatformWizardForm) {
if (form.proxyMode === 'custom') {
return {
mode: 'custom',
httpProxy: form.httpProxy.trim(),
};
}
return { mode: form.proxyMode };
}
function limitRule(metric: string, value: string, windowSeconds = 60) {
const limit = positiveNumber(value, 0);
if (!limit) return undefined;

View File

@ -47,6 +47,32 @@ export interface MediaGenerationRun {
task?: GatewayTask;
}
export function gatewayTaskErrorText(task: GatewayTask | undefined, fallback = '任务失败') {
if (!task) return fallback;
const result = recordFromUnknown(task.result);
const resultError = recordFromUnknown(result?.error);
const metrics = recordFromUnknown(task.metrics);
const hasErrorState = task.status === 'failed'
|| task.status === 'cancelled'
|| Boolean(task.error || task.errorMessage || task.errorCode || resultError || metrics?.error);
if (!hasErrorState) return '';
const message = firstString(
task.errorMessage,
task.error,
resultError?.message,
result?.message,
metrics?.error,
fallback,
);
const meta = uniqueStrings([
firstString(task.errorCode, resultError?.code, metrics?.errorCode) ? `错误码: ${firstString(task.errorCode, resultError?.code, metrics?.errorCode)}` : '',
firstString(task.requestId, resultError?.requestId, resultError?.request_id, metrics?.requestId) ? `RequestID: ${firstString(task.requestId, resultError?.requestId, resultError?.request_id, metrics?.requestId)}` : '',
task.id ? `TaskID: ${task.id}` : '',
]);
if (!message && !meta.length) return '';
return meta.length ? `${message || fallback}${meta.join('')}` : message;
}
interface AspectRatioOption {
label: string;
value: string;
@ -487,6 +513,7 @@ function MediaTaskCard(props: {
const unit = props.run.mode === 'video' ? '条' : '张';
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
const backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
const errorText = mediaRunErrorText(props.run);
return (
<article className="mediaTaskItem" data-status={props.run.status}>
@ -517,7 +544,12 @@ function MediaTaskCard(props: {
</div>
</div>
{props.run.error && <p className="mediaTaskError">{props.run.error}</p>}
{errorText && (
<div className="mediaTaskError">
<strong></strong>
<span>{errorText}</span>
</div>
)}
<footer className="mediaTaskActions">
{items[0] ? (
<Button asChild size="sm" variant="secondary">
@ -593,6 +625,10 @@ function mediaResultItems(run: MediaGenerationRun): MediaResultItem[] {
.filter((item): item is MediaResultItem => Boolean(item));
}
function mediaRunErrorText(run: MediaGenerationRun) {
return gatewayTaskErrorText(run.task, '') || run.error || '';
}
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
const record = recordFromUnknown(entry);
if (!record) return undefined;

View File

@ -23,6 +23,7 @@ const workspacePaths: Record<WorkspaceSection, string> = {
billing: '/workspace/billing',
apiKeys: '/workspace/api-keys',
tasks: '/workspace/tasks',
transactions: '/workspace/transactions',
};
const adminPaths: Record<AdminSection, string> = {

View File

@ -423,11 +423,105 @@ strong {
line-height: 1.45;
overflow-wrap: anywhere;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;
}
.walletMetricGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.walletTransactionViewport {
--sh-table-viewport-gap: 10px;
--sh-table-viewport-height: calc(100dvh - 90px);
margin-bottom: -52px;
}
.walletTransactionFilters {
grid-template-columns: minmax(260px, 1.2fr) minmax(360px, 1.1fr) auto;
gap: 10px;
}
.walletTransactionTable .shTableRow {
grid-template-columns: minmax(150px, 0.65fr) minmax(220px, 1fr) minmax(180px, 0.82fr) minmax(136px, 0.62fr) minmax(148px, 0.68fr) minmax(154px, 0.7fr) minmax(116px, 0.52fr) minmax(140px, 0.62fr) minmax(250px, 1.14fr);
min-width: 1494px;
align-items: start;
}
.walletTransactionPrimaryCell,
.walletTransactionRef {
display: grid;
min-width: 0;
gap: 3px;
line-height: 1.3;
}
.walletTransactionPrimaryCell strong,
.walletTransactionPrimaryCell small,
.walletTransactionRef small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.walletTransactionPrimaryCell strong {
color: var(--text-strong);
font-weight: var(--font-weight-semibold);
}
.walletTransactionPrimaryCell small,
.walletTransactionRef small {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.walletTransactionModelCell,
.walletTransactionPlatformCell,
.walletTransactionTokenCell {
overflow: visible;
white-space: normal;
}
.walletTransactionModelCell .walletTransactionPrimaryCell strong,
.walletTransactionModelCell .walletTransactionPrimaryCell small,
.walletTransactionPlatformCell .walletTransactionPrimaryCell strong,
.walletTransactionPlatformCell .walletTransactionPrimaryCell small {
overflow: visible;
text-overflow: clip;
white-space: normal;
word-break: break-word;
}
.walletTransactionAmount {
color: #166534;
font-weight: var(--font-weight-semibold);
}
.walletTransactionAmount[data-direction="debit"] {
color: #b42318;
}
.walletBalanceChange {
display: grid;
gap: 2px;
color: var(--text-soft);
line-height: 1.3;
}
.walletTransactionRef code {
display: block;
overflow: hidden;
color: var(--text-normal);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.taskJsonDialog {
width: min(920px, 100%);
}
@ -741,6 +835,14 @@ strong {
grid-template-columns: 1fr;
}
.walletTransactionFilters {
grid-template-columns: 1fr;
}
.walletMetricGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.shTableFooter {
align-items: flex-start;
flex-direction: column;
@ -750,3 +852,9 @@ strong {
flex-wrap: wrap;
}
}
@media (max-width: 560px) {
.walletMetricGrid {
grid-template-columns: 1fr;
}
}

View File

@ -1115,6 +1115,14 @@
font-weight: var(--font-weight-semibold);
}
.platformProxyStatus {
margin: 10px 0 0;
color: var(--text-normal);
font-size: 0.8125rem;
font-weight: var(--font-weight-semibold);
word-break: break-all;
}
.platformCredentialField {
gap: 6px;
}

View File

@ -811,11 +811,25 @@
}
.assistantBubble.error {
display: grid;
gap: 4px;
border-color: #f0d4d4;
background: #fff7f7;
color: #9f2f2f;
}
.assistantBubble.error strong {
color: #7f1d1d;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.assistantErrorMessage {
color: #9f2f2f;
line-height: var(--line-height-relaxed);
overflow-wrap: anywhere;
}
.assistantTyping {
color: var(--muted-foreground);
}
@ -1423,6 +1437,8 @@
}
.mediaTaskError {
display: grid;
gap: 4px;
width: fit-content;
max-width: 100%;
padding: 8px 10px;
@ -1431,6 +1447,16 @@
background: #fef2f2;
color: #991b1b;
font-size: var(--font-size-sm);
line-height: var(--line-height-relaxed);
}
.mediaTaskError strong {
color: #7f1d1d;
font-weight: var(--font-weight-semibold);
}
.mediaTaskError span {
overflow-wrap: anywhere;
}
.mediaTaskActions {

View File

@ -3,7 +3,7 @@ export type AuthMode = 'login' | 'register' | 'external';
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
export type PlaygroundMode = 'chat' | 'image' | 'video';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks';
export type WorkspaceSection = 'overview' | 'billing' | 'apiKeys' | 'tasks' | 'transactions';
export type ApiDocSection = 'chat' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
export type AdminSection =
| 'overview'
@ -53,6 +53,14 @@ export interface WorkspaceTaskQuery {
pageSize: number;
}
export interface WorkspaceTransactionQuery {
query: string;
createdFrom: string;
createdTo: string;
page: number;
pageSize: number;
}
export interface PlatformForm {
provider: string;
platformKey: string;

View File

@ -497,6 +497,7 @@ export interface GatewayWalletAccount {
export interface GatewayWalletTransaction {
id: string;
accountId: string;
currency?: 'resource' | 'credit' | 'cny' | 'usd' | string;
gatewayTenantId?: string;
gatewayUserId?: string;
direction: 'credit' | 'debit' | 'freeze' | 'unfreeze' | string;
@ -519,6 +520,11 @@ export interface WalletBalanceAdjustmentRequest {
metadata?: Record<string, unknown>;
}
export interface WalletSummaryResponse {
accounts: GatewayWalletAccount[];
primaryAccount: GatewayWalletAccount;
}
export interface WalletAdjustmentResponse {
account: GatewayWalletAccount;
before: GatewayWalletAccount;
@ -716,6 +722,12 @@ export interface RateLimitWindow {
updatedAt: string;
}
export interface GatewayNetworkProxyConfig {
globalHttpProxy?: string;
globalHttpProxySet: boolean;
globalHttpProxySource?: string;
}
export interface GatewayTask {
id: string;
kind: string;