feat(admin): 添加网络代理配置和钱包交易功能
- 在管理面板中集成网络代理配置显示和平台代理设置 - 添加钱包摘要和交易列表API接口及数据管理 - 实现SSE流式响应中的错误处理机制 - 添加全局HTTP代理环境变量配置支持 - 更新平台表单以支持代理模式选择和自定义代理地址 - 集成钱包交易查询过滤和分页功能 - 优化API错误详情解析和显示格式
This commit is contained in:
parent
c992f1de60
commit
f550c0acd5
@ -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}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ type Request struct {
|
||||
Model string
|
||||
Body map[string]any
|
||||
Candidate store.RuntimeModelCandidate
|
||||
HTTPClient *http.Client
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
}
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
15
apps/api/internal/httpapi/config_handlers.go
Normal file
15
apps/api/internal/httpapi/config_handlers.go
Normal 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),
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)))
|
||||
|
||||
76
apps/api/internal/httpapi/wallet_handlers.go
Normal file
76
apps/api/internal/httpapi/wallet_handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
114
apps/api/internal/netproxy/netproxy.go
Normal file
114
apps/api/internal/netproxy/netproxy.go
Normal 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)
|
||||
}
|
||||
73
apps/api/internal/runner/proxy.go
Normal file
73
apps/api/internal/runner/proxy.go
Normal 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,
|
||||
}
|
||||
}
|
||||
116
apps/api/internal/runner/proxy_test.go
Normal file
116
apps/api/internal/runner/proxy_test.go
Normal 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()}
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user