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
|
return Response{}, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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 {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
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 {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
@ -114,9 +114,11 @@ func joinURL(base string, path string) string {
|
|||||||
return base + path
|
return base + path
|
||||||
}
|
}
|
||||||
|
|
||||||
func httpClient(client *http.Client) *http.Client {
|
func httpClient(clients ...*http.Client) *http.Client {
|
||||||
if client != nil {
|
for _, client := range clients {
|
||||||
return client
|
if client != nil {
|
||||||
|
return client
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return http.DefaultClient
|
return http.DefaultClient
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type Request struct {
|
|||||||
Model string
|
Model string
|
||||||
Body map[string]any
|
Body map[string]any
|
||||||
Candidate store.RuntimeModelCandidate
|
Candidate store.RuntimeModelCandidate
|
||||||
|
HTTPClient *http.Client
|
||||||
Stream bool
|
Stream bool
|
||||||
StreamDelta StreamDelta
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
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 {
|
if err != nil {
|
||||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
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) {
|
func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey string) (Response, error) {
|
||||||
body := volcesVideoBody(request)
|
body := volcesVideoBody(request)
|
||||||
submitStartedAt := time.Now()
|
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()
|
submitFinishedAt := time.Now()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, submitFinishedAt)
|
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()
|
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()
|
pollFinishedAt := time.Now()
|
||||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||||
if err != nil {
|
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)
|
raw, _ := json.Marshal(body)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
||||||
if err != nil {
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
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 {
|
if err != nil {
|
||||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
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
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
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 {
|
if err != nil {
|
||||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,10 +20,13 @@ type Config struct {
|
|||||||
TaskProgressCallbackTimeoutMS string
|
TaskProgressCallbackTimeoutMS string
|
||||||
TaskProgressCallbackMaxAttempts string
|
TaskProgressCallbackMaxAttempts string
|
||||||
CORSAllowedOrigin string
|
CORSAllowedOrigin string
|
||||||
|
GlobalHTTPProxy string
|
||||||
|
GlobalHTTPProxySource string
|
||||||
LogLevel slog.Level
|
LogLevel slog.Level
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
|
globalProxy := LoadGlobalHTTPProxyStatus()
|
||||||
return Config{
|
return Config{
|
||||||
AppEnv: env("APP_ENV", "development"),
|
AppEnv: env("APP_ENV", "development"),
|
||||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||||
@ -42,10 +45,35 @@ func Load() Config {
|
|||||||
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
|
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
|
||||||
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
|
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
|
||||||
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"),
|
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")),
|
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 {
|
func gatewayDatabaseURL() string {
|
||||||
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
|
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
|
||||||
return normalizePostgresURL(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"`
|
ID string `json:"id"`
|
||||||
Provider string `json:"provider"`
|
Provider string `json:"provider"`
|
||||||
PlatformKey string `json:"platformKey"`
|
PlatformKey string `json:"platformKey"`
|
||||||
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
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) {
|
if !floatNear(walletTransactionAmount, pricingTask.Task.FinalChargeAmount) {
|
||||||
t.Fatalf("task billing transaction amount=%f want=%f", 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
|
rateLimitedModel := "rate-limit-smoke-" + suffixText
|
||||||
var rateLimitPolicySet struct {
|
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) {
|
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)
|
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)
|
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1098,6 +1159,50 @@ func taskListContains(values []struct {
|
|||||||
return false
|
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 {
|
func floatNear(value float64, expected float64) bool {
|
||||||
return math.Abs(value-expected) < 0.000001
|
return math.Abs(value-expected) < 0.000001
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
"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"
|
"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 == "" {
|
if input.AuthType == "" {
|
||||||
input.AuthType = "bearer"
|
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)
|
platform, err := s.store.CreatePlatform(r.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("create platform failed", "error", err)
|
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 == "" {
|
if input.AuthType == "" {
|
||||||
input.AuthType = "bearer"
|
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)
|
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if store.IsNotFound(err) {
|
if store.IsNotFound(err) {
|
||||||
@ -541,7 +555,19 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if runErr != 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 {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
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)
|
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
writeError(w, statusFromRunError(runErr), runErr.Error())
|
writeError(w, statusFromRunError(runErr), runErr.Error(), clients.ErrorCode(runErr))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, result.Output)
|
writeJSON(w, http.StatusOK, result.Output)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
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)
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||||
writeJSON(w, status, map[string]any{
|
errorPayload := map[string]any{
|
||||||
"error": map[string]any{
|
"message": message,
|
||||||
"message": message,
|
"status": status,
|
||||||
"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) {
|
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("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("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/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/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("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)))
|
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("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("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("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("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("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)))
|
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"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -15,10 +14,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
cfg config.Config
|
cfg config.Config
|
||||||
store *store.Store
|
store *store.Store
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
clients map[string]clients.Client
|
clients map[string]clients.Client
|
||||||
|
httpClients *httpClientCache
|
||||||
}
|
}
|
||||||
|
|
||||||
type Result struct {
|
type Result struct {
|
||||||
@ -27,17 +27,18 @@ type Result struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
||||||
httpClient := &http.Client{Timeout: 120 * time.Second}
|
httpClients := newHTTPClientCache()
|
||||||
return &Service{
|
return &Service{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
store: db,
|
store: db,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
clients: map[string]clients.Client{
|
clients: map[string]clients.Client{
|
||||||
"openai": clients.OpenAIClient{HTTPClient: httpClient},
|
"openai": clients.OpenAIClient{HTTPClient: httpClients.none},
|
||||||
"gemini": clients.GeminiClient{HTTPClient: httpClient},
|
"gemini": clients.GeminiClient{HTTPClient: httpClients.none},
|
||||||
"volces": clients.VolcesClient{HTTPClient: httpClient},
|
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
|
||||||
"simulation": clients.SimulationClient{},
|
"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, "")
|
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)
|
client := s.clientFor(candidate, simulated)
|
||||||
callStartedAt := time.Now()
|
callStartedAt := time.Now()
|
||||||
response, err := client.Run(ctx, clients.Request{
|
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,
|
Model: task.Model,
|
||||||
Body: body,
|
Body: body,
|
||||||
Candidate: candidate,
|
Candidate: candidate,
|
||||||
|
HTTPClient: requestHTTPClient,
|
||||||
Stream: boolFromMap(body, "stream"),
|
Stream: boolFromMap(body, "stream"),
|
||||||
StreamDelta: onDelta,
|
StreamDelta: onDelta,
|
||||||
})
|
})
|
||||||
|
|||||||
@ -7,11 +7,14 @@ import type {
|
|||||||
GatewayAccessRuleUpsertRequest,
|
GatewayAccessRuleUpsertRequest,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
|
GatewayNetworkProxyConfig,
|
||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
GatewayTask,
|
GatewayTask,
|
||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
|
GatewayWalletAccount,
|
||||||
|
GatewayWalletTransaction,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
@ -39,9 +42,11 @@ import {
|
|||||||
deleteTenant,
|
deleteTenant,
|
||||||
deleteUserGroup,
|
deleteUserGroup,
|
||||||
getHealth,
|
getHealth,
|
||||||
|
getNetworkProxyConfig,
|
||||||
getTask,
|
getTask,
|
||||||
listAuditLogs,
|
getWalletSummary,
|
||||||
listAccessRules,
|
listAccessRules,
|
||||||
|
listAuditLogs,
|
||||||
listApiKeyAccessRules,
|
listApiKeyAccessRules,
|
||||||
listApiKeys,
|
listApiKeys,
|
||||||
listBaseModels,
|
listBaseModels,
|
||||||
@ -55,6 +60,7 @@ import {
|
|||||||
listPricingRuleSets,
|
listPricingRuleSets,
|
||||||
listRuntimePolicySets,
|
listRuntimePolicySets,
|
||||||
listTasks,
|
listTasks,
|
||||||
|
listWalletTransactions,
|
||||||
listPublicBaseModels,
|
listPublicBaseModels,
|
||||||
listPublicCatalogProviders,
|
listPublicCatalogProviders,
|
||||||
listRateLimitWindows,
|
listRateLimitWindows,
|
||||||
@ -112,6 +118,7 @@ import type {
|
|||||||
RegisterForm,
|
RegisterForm,
|
||||||
TaskForm,
|
TaskForm,
|
||||||
WorkspaceTaskQuery,
|
WorkspaceTaskQuery,
|
||||||
|
WorkspaceTransactionQuery,
|
||||||
WorkspaceSection,
|
WorkspaceSection,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@ -121,6 +128,7 @@ type DataKey =
|
|||||||
| 'playgroundApiKeys'
|
| 'playgroundApiKeys'
|
||||||
| 'playgroundModels'
|
| 'playgroundModels'
|
||||||
| 'modelCatalog'
|
| 'modelCatalog'
|
||||||
|
| 'networkProxyConfig'
|
||||||
| 'platforms'
|
| 'platforms'
|
||||||
| 'models'
|
| 'models'
|
||||||
| 'providers'
|
| 'providers'
|
||||||
@ -133,6 +141,8 @@ type DataKey =
|
|||||||
| 'users'
|
| 'users'
|
||||||
| 'userGroups'
|
| 'userGroups'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
|
| 'wallet'
|
||||||
|
| 'walletTransactions'
|
||||||
| 'accessRules'
|
| 'accessRules'
|
||||||
| 'auditLogs'
|
| 'auditLogs'
|
||||||
| 'apiKeys';
|
| 'apiKeys';
|
||||||
@ -143,6 +153,7 @@ export function App() {
|
|||||||
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
|
||||||
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
|
||||||
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
|
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
|
||||||
|
const [workspaceTransactionQuery, setWorkspaceTransactionQuery] = useState<WorkspaceTransactionQuery>(() => defaultWorkspaceTransactionQuery());
|
||||||
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
|
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
|
||||||
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
|
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
|
||||||
const [token, setToken] = useState(readStoredAccessToken);
|
const [token, setToken] = useState(readStoredAccessToken);
|
||||||
@ -159,6 +170,7 @@ export function App() {
|
|||||||
summary: { modelCount: 0, sourceCount: 0 },
|
summary: { modelCount: 0, sourceCount: 0 },
|
||||||
});
|
});
|
||||||
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
|
||||||
|
const [networkProxyConfig, setNetworkProxyConfig] = useState<GatewayNetworkProxyConfig | null>(null);
|
||||||
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
const [providers, setProviders] = useState<CatalogProvider[]>([]);
|
||||||
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
|
||||||
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
const [pricingRules, setPricingRules] = useState<PricingRule[]>([]);
|
||||||
@ -179,6 +191,9 @@ export function App() {
|
|||||||
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
|
||||||
const [tasks, setTasks] = useState<GatewayTask[]>([]);
|
const [tasks, setTasks] = useState<GatewayTask[]>([]);
|
||||||
const [taskTotal, setTaskTotal] = useState(0);
|
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 [coreState, setCoreState] = useState<LoadState>('idle');
|
||||||
const [coreMessage, setCoreMessage] = useState('');
|
const [coreMessage, setCoreMessage] = useState('');
|
||||||
const [state, setState] = useState<LoadState>('idle');
|
const [state, setState] = useState<LoadState>('idle');
|
||||||
@ -187,6 +202,8 @@ export function App() {
|
|||||||
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
const loadingDataKeysRef = useRef(new Set<DataKey>());
|
||||||
const loadedTaskQueryKeyRef = useRef('');
|
const loadedTaskQueryKeyRef = useRef('');
|
||||||
const currentTaskQueryKeyRef = useRef('');
|
const currentTaskQueryKeyRef = useRef('');
|
||||||
|
const loadedTransactionQueryKeyRef = useRef('');
|
||||||
|
const currentTransactionQueryKeyRef = useRef('');
|
||||||
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
|
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
|
||||||
setBaseModels,
|
setBaseModels,
|
||||||
setCoreMessage,
|
setCoreMessage,
|
||||||
@ -207,14 +224,16 @@ export function App() {
|
|||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery);
|
const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery);
|
||||||
|
const transactionListRequestKey = workspaceTransactionQueryKey(workspaceTransactionQuery);
|
||||||
currentTaskQueryKeyRef.current = taskListRequestKey;
|
currentTaskQueryKeyRef.current = taskListRequestKey;
|
||||||
|
currentTransactionQueryKeyRef.current = transactionListRequestKey;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureData(['health']);
|
void ensureData(['health']);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void ensureRouteData(token);
|
void ensureRouteData(token);
|
||||||
}, [activePage, adminSection, taskListRequestKey, workspaceSection, token]);
|
}, [activePage, adminSection, taskListRequestKey, transactionListRequestKey, workspaceSection, token]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handlePopState() {
|
function handlePopState() {
|
||||||
applyRoute(parseAppRoute());
|
applyRoute(parseAppRoute());
|
||||||
@ -249,6 +268,7 @@ export function App() {
|
|||||||
baseModels,
|
baseModels,
|
||||||
modelCatalog,
|
modelCatalog,
|
||||||
models,
|
models,
|
||||||
|
networkProxyConfig,
|
||||||
platforms,
|
platforms,
|
||||||
pricingRules,
|
pricingRules,
|
||||||
pricingRuleSets,
|
pricingRuleSets,
|
||||||
@ -260,7 +280,9 @@ export function App() {
|
|||||||
tenants,
|
tenants,
|
||||||
userGroups,
|
userGroups,
|
||||||
users,
|
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) {
|
async function refresh(nextToken = token) {
|
||||||
await ensureRouteData(nextToken, true);
|
await ensureRouteData(nextToken, true);
|
||||||
@ -275,6 +297,10 @@ export function App() {
|
|||||||
loadedDataKeysRef.current.delete('tasks');
|
loadedDataKeysRef.current.delete('tasks');
|
||||||
loadingDataKeysRef.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);
|
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,6 +352,9 @@ export function App() {
|
|||||||
case 'modelCatalog':
|
case 'modelCatalog':
|
||||||
setModelCatalog(await listModelCatalog(nextToken));
|
setModelCatalog(await listModelCatalog(nextToken));
|
||||||
return;
|
return;
|
||||||
|
case 'networkProxyConfig':
|
||||||
|
setNetworkProxyConfig(await getNetworkProxyConfig(nextToken));
|
||||||
|
return;
|
||||||
case 'playgroundModels':
|
case 'playgroundModels':
|
||||||
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
setPlaygroundModels((await listPlayableModels(nextToken)).items);
|
||||||
return;
|
return;
|
||||||
@ -373,6 +402,20 @@ export function App() {
|
|||||||
loadedTaskQueryKeyRef.current = requestKey;
|
loadedTaskQueryKeyRef.current = requestKey;
|
||||||
}
|
}
|
||||||
return;
|
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':
|
case 'accessRules':
|
||||||
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
|
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
|
||||||
? listApiKeyAccessRules(nextToken)
|
? listApiKeyAccessRules(nextToken)
|
||||||
@ -707,7 +750,7 @@ export function App() {
|
|||||||
const detail = await getTask(credential, response.task.id);
|
const detail = await getTask(credential, response.task.id);
|
||||||
setTaskResult(detail);
|
setTaskResult(detail);
|
||||||
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
setTasks((current) => [detail, ...current.filter((item) => item.id !== detail.id)]);
|
||||||
invalidateDataKeys('tasks');
|
invalidateDataKeys('tasks', 'wallet', 'walletTransactions');
|
||||||
setCoreState('ready');
|
setCoreState('ready');
|
||||||
setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`);
|
setCoreMessage(`${taskForm.kind} 已通过 ${apiKeySecret ? '本地 API Key' : '当前 Access Token'} 完成 simulation。`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -726,6 +769,7 @@ export function App() {
|
|||||||
setModels([]);
|
setModels([]);
|
||||||
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
|
||||||
setPlaygroundModels([]);
|
setPlaygroundModels([]);
|
||||||
|
setNetworkProxyConfig(null);
|
||||||
setProviders([]);
|
setProviders([]);
|
||||||
setBaseModels([]);
|
setBaseModels([]);
|
||||||
setPricingRules([]);
|
setPricingRules([]);
|
||||||
@ -744,6 +788,10 @@ export function App() {
|
|||||||
setTaskResult(null);
|
setTaskResult(null);
|
||||||
setTasks([]);
|
setTasks([]);
|
||||||
setTaskTotal(0);
|
setTaskTotal(0);
|
||||||
|
setWalletAccounts([]);
|
||||||
|
setWalletTransactions([]);
|
||||||
|
setWalletTransactionTotal(0);
|
||||||
|
setWorkspaceTransactionQuery(defaultWorkspaceTransactionQuery());
|
||||||
setCoreMessage('');
|
setCoreMessage('');
|
||||||
navigatePath('/');
|
navigatePath('/');
|
||||||
}
|
}
|
||||||
@ -849,12 +897,15 @@ export function App() {
|
|||||||
state={coreState}
|
state={coreState}
|
||||||
taskQuery={workspaceTaskQuery}
|
taskQuery={workspaceTaskQuery}
|
||||||
taskTotal={taskTotal}
|
taskTotal={taskTotal}
|
||||||
|
transactionQuery={workspaceTransactionQuery}
|
||||||
|
transactionTotal={walletTransactionTotal}
|
||||||
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
onBatchAccessRules={batchSaveAPIKeyAccessRules}
|
||||||
onDeleteApiKey={removeAPIKey}
|
onDeleteApiKey={removeAPIKey}
|
||||||
onApiKeyFormChange={setApiKeyForm}
|
onApiKeyFormChange={setApiKeyForm}
|
||||||
onSectionChange={navigateWorkspaceSection}
|
onSectionChange={navigateWorkspaceSection}
|
||||||
onSubmitApiKey={submitAPIKey}
|
onSubmitApiKey={submitAPIKey}
|
||||||
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
onTaskQueryChange={navigateWorkspaceTaskQuery}
|
||||||
|
onTransactionQueryChange={setWorkspaceTransactionQuery}
|
||||||
onUseApiKeyForPlayground={useApiKeyForPlayground}
|
onUseApiKeyForPlayground={useApiKeyForPlayground}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -1010,6 +1061,39 @@ function nonEmptyRecord(value: Record<string, unknown> | undefined) {
|
|||||||
return value && Object.keys(value).length > 0 ? value : 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(
|
function dataKeysForRoute(
|
||||||
activePage: PageKey,
|
activePage: PageKey,
|
||||||
adminSection: AdminSection,
|
adminSection: AdminSection,
|
||||||
@ -1027,8 +1111,10 @@ function dataKeysForRoute(
|
|||||||
|
|
||||||
if (activePage === 'workspace') {
|
if (activePage === 'workspace') {
|
||||||
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
if (workspaceSection === 'overview') return ['users', 'userGroups', 'apiKeys'];
|
||||||
|
if (workspaceSection === 'billing') return ['wallet'];
|
||||||
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
if (workspaceSection === 'apiKeys') return ['apiKeys', 'accessRules', 'playgroundModels'];
|
||||||
if (workspaceSection === 'tasks') return ['tasks'];
|
if (workspaceSection === 'tasks') return ['tasks'];
|
||||||
|
if (workspaceSection === 'transactions') return ['wallet', 'walletTransactions'];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1045,7 +1131,7 @@ function dataKeysForRoute(
|
|||||||
case 'baseModels':
|
case 'baseModels':
|
||||||
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
return ['baseModels', 'providers', 'pricingRuleSets', 'runtimePolicySets'];
|
||||||
case 'platforms':
|
case 'platforms':
|
||||||
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets'];
|
return ['platforms', 'models', 'providers', 'baseModels', 'pricingRuleSets', 'networkProxyConfig'];
|
||||||
case 'tenants':
|
case 'tenants':
|
||||||
return ['tenants', 'userGroups'];
|
return ['tenants', 'userGroups'];
|
||||||
case 'users':
|
case 'users':
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import type {
|
|||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayTenantUpsertRequest,
|
GatewayTenantUpsertRequest,
|
||||||
|
GatewayNetworkProxyConfig,
|
||||||
GatewayTask,
|
GatewayTask,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
GatewayUserUpsertRequest,
|
GatewayUserUpsertRequest,
|
||||||
|
GatewayWalletTransaction,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
@ -30,11 +32,35 @@ import type {
|
|||||||
UserGroupUpsertRequest,
|
UserGroupUpsertRequest,
|
||||||
WalletAdjustmentResponse,
|
WalletAdjustmentResponse,
|
||||||
WalletBalanceAdjustmentRequest,
|
WalletBalanceAdjustmentRequest,
|
||||||
|
WalletSummaryResponse,
|
||||||
} from '@easyai-ai-gateway/contracts';
|
} from '@easyai-ai-gateway/contracts';
|
||||||
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
|
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 {
|
export interface HealthResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
service: string;
|
service: string;
|
||||||
@ -508,7 +534,7 @@ export async function* streamChatCompletionText(
|
|||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
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) {
|
if (!response.body) {
|
||||||
return;
|
return;
|
||||||
@ -523,13 +549,15 @@ export async function* streamChatCompletionText(
|
|||||||
const events = buffer.split(/\n\n/);
|
const events = buffer.split(/\n\n/);
|
||||||
buffer = events.pop() ?? '';
|
buffer = events.pop() ?? '';
|
||||||
for (const eventBlock of events) {
|
for (const eventBlock of events) {
|
||||||
const delta = parseSSEBlockDelta(eventBlock);
|
const event = parseSSEBlock(eventBlock);
|
||||||
if (delta) yield delta;
|
if (event.error) throw new GatewayApiError(event.error);
|
||||||
|
if (event.delta) yield event.delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (buffer.trim()) {
|
if (buffer.trim()) {
|
||||||
const delta = parseSSEBlockDelta(buffer);
|
const event = parseSSEBlock(buffer);
|
||||||
if (delta) yield delta;
|
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> {
|
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>> {
|
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.modelType) search.set('modelType', query.modelType);
|
||||||
if (query.createdFrom) search.set('createdFrom', query.createdFrom);
|
if (query.createdFrom) search.set('createdFrom', query.createdFrom);
|
||||||
if (query.createdTo) search.set('createdTo', query.createdTo);
|
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) {
|
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 });
|
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>(
|
async function request<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
|
options: { token?: string; auth?: boolean; method?: string; body?: unknown } = {},
|
||||||
@ -649,7 +701,7 @@ async function request<T>(
|
|||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
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) {
|
if (response.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
@ -658,33 +710,96 @@ async function request<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseErrorMessage(body: string) {
|
function parseErrorMessage(body: string) {
|
||||||
|
return formatGatewayErrorDetails(parseErrorDetails(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorDetails(body: string, status?: number, fallback = ''): GatewayErrorDetails {
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return '';
|
return { message: fallback, status };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
const parsed = JSON.parse(body) as unknown;
|
||||||
return parsed.error?.message ?? body;
|
return errorDetailsFromParsed(parsed, status, fallback || body);
|
||||||
} catch {
|
} 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
|
const data = block
|
||||||
.split(/\n/)
|
.split(/\n/)
|
||||||
.filter((line) => line.startsWith('data:'))
|
.filter((line) => line.startsWith('data:'))
|
||||||
.map((line) => line.replace(/^data:\s?/, ''))
|
.map((line) => line.replace(/^data:\s?/, ''))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
.trim();
|
.trim();
|
||||||
if (!data || data === '[DONE]') return '';
|
if (!data || data === '[DONE]') return { delta: '' };
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data) as {
|
const parsed = JSON.parse(data) as {
|
||||||
choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>;
|
choices?: Array<{ delta?: { content?: string }; message?: { content?: string } }>;
|
||||||
delta?: string;
|
delta?: string;
|
||||||
|
error?: unknown;
|
||||||
output_text?: string;
|
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 {
|
} 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,
|
GatewayAccessRule,
|
||||||
GatewayApiKey,
|
GatewayApiKey,
|
||||||
GatewayAuditLog,
|
GatewayAuditLog,
|
||||||
|
GatewayNetworkProxyConfig,
|
||||||
GatewayTask,
|
GatewayTask,
|
||||||
GatewayTenant,
|
GatewayTenant,
|
||||||
GatewayUser,
|
GatewayUser,
|
||||||
|
GatewayWalletAccount,
|
||||||
|
GatewayWalletTransaction,
|
||||||
IntegrationPlatform,
|
IntegrationPlatform,
|
||||||
ModelCatalogResponse,
|
ModelCatalogResponse,
|
||||||
PlatformModel,
|
PlatformModel,
|
||||||
@ -24,6 +27,7 @@ export interface ConsoleData {
|
|||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
modelCatalog: ModelCatalogResponse;
|
modelCatalog: ModelCatalogResponse;
|
||||||
models: PlatformModel[];
|
models: PlatformModel[];
|
||||||
|
networkProxyConfig: GatewayNetworkProxyConfig | null;
|
||||||
platforms: IntegrationPlatform[];
|
platforms: IntegrationPlatform[];
|
||||||
pricingRules: PricingRule[];
|
pricingRules: PricingRule[];
|
||||||
pricingRuleSets: PricingRuleSet[];
|
pricingRuleSets: PricingRuleSet[];
|
||||||
@ -35,6 +39,8 @@ export interface ConsoleData {
|
|||||||
tenants: GatewayTenant[];
|
tenants: GatewayTenant[];
|
||||||
userGroups: UserGroup[];
|
userGroups: UserGroup[];
|
||||||
users: GatewayUser[];
|
users: GatewayUser[];
|
||||||
|
walletAccounts: GatewayWalletAccount[];
|
||||||
|
walletTransactions: GatewayWalletTransaction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatItem {
|
export interface StatItem {
|
||||||
|
|||||||
@ -133,6 +133,7 @@ export function AdminPage(props: {
|
|||||||
<PlatformManagementPanel
|
<PlatformManagementPanel
|
||||||
baseModels={props.data.baseModels}
|
baseModels={props.data.baseModels}
|
||||||
message={props.operationMessage}
|
message={props.operationMessage}
|
||||||
|
networkProxyConfig={props.data.networkProxyConfig}
|
||||||
platformModels={props.data.models}
|
platformModels={props.data.models}
|
||||||
platforms={props.data.platforms}
|
platforms={props.data.platforms}
|
||||||
pricingRuleSets={props.data.pricingRuleSets}
|
pricingRuleSets={props.data.pricingRuleSets}
|
||||||
|
|||||||
@ -2,8 +2,10 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|||||||
import {
|
import {
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
ComposerPrimitive,
|
ComposerPrimitive,
|
||||||
|
ErrorPrimitive,
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
|
useMessage,
|
||||||
useMessagePartText,
|
useMessagePartText,
|
||||||
useLocalRuntime,
|
useLocalRuntime,
|
||||||
useThread,
|
useThread,
|
||||||
@ -19,11 +21,12 @@ import { mermaid } from '@streamdown/mermaid';
|
|||||||
import type { GatewayApiKey, GatewayTask, PlatformModel } from '@easyai-ai-gateway/contracts';
|
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 { Bot, ChevronDown, Image as ImageIcon, MessageSquarePlus, Paperclip, Send, Sparkles, Video } from 'lucide-react';
|
||||||
import { Badge, Button, Select, Textarea } from '../components/ui';
|
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 type { PlaygroundMode } from '../types';
|
||||||
import {
|
import {
|
||||||
defaultMediaGenerationSettings,
|
defaultMediaGenerationSettings,
|
||||||
deriveMediaModelCapabilities,
|
deriveMediaModelCapabilities,
|
||||||
|
gatewayTaskErrorText,
|
||||||
mediaRequestPayload,
|
mediaRequestPayload,
|
||||||
MediaSettingsPopover,
|
MediaSettingsPopover,
|
||||||
MediaTaskBoard,
|
MediaTaskBoard,
|
||||||
@ -172,7 +175,7 @@ export function PlaygroundPage(props: {
|
|||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
if (!isMountedRef.current) return;
|
if (!isMountedRef.current) return;
|
||||||
setMediaRuns((current) => updateMediaRun(current, run.localId, {
|
setMediaRuns((current) => updateMediaRun(current, run.localId, {
|
||||||
error: detail.error,
|
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||||
status: detail.status,
|
status: detail.status,
|
||||||
task: detail,
|
task: detail,
|
||||||
}));
|
}));
|
||||||
@ -240,7 +243,7 @@ export function PlaygroundPage(props: {
|
|||||||
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
setMediaRuns((current) => updateMediaRun(current, localId, { status: response.task.status, task: response.task }));
|
||||||
const detail = await pollTaskUntilSettled(credential, response.task);
|
const detail = await pollTaskUntilSettled(credential, response.task);
|
||||||
setMediaRuns((current) => updateMediaRun(current, localId, {
|
setMediaRuns((current) => updateMediaRun(current, localId, {
|
||||||
error: detail.error,
|
error: gatewayTaskErrorText(detail, '任务执行失败'),
|
||||||
status: detail.status,
|
status: detail.status,
|
||||||
task: detail,
|
task: detail,
|
||||||
}));
|
}));
|
||||||
@ -453,13 +456,13 @@ function AssistantChatPlayground(props: {
|
|||||||
async *run({ abortSignal, messages }) {
|
async *run({ abortSignal, messages }) {
|
||||||
if (!props.token) {
|
if (!props.token) {
|
||||||
props.onLogin();
|
props.onLogin();
|
||||||
throw new Error('请先登录后再测试模型。');
|
throw new GatewayApiError('请先登录后再测试模型。');
|
||||||
}
|
}
|
||||||
if (!activeApiKeySecret) {
|
if (!activeApiKeySecret) {
|
||||||
throw new Error('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。');
|
throw new GatewayApiError('请选择可用于测试的 API Key;如果列表为空,请刷新或重新创建一个 Key。');
|
||||||
}
|
}
|
||||||
if (!props.selectedModel) {
|
if (!props.selectedModel) {
|
||||||
throw new Error('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
throw new GatewayApiError('当前没有可用的大模型,请确认用户组权限或平台模型配置。');
|
||||||
}
|
}
|
||||||
let text = '';
|
let text = '';
|
||||||
for await (const delta of streamChatCompletionText(
|
for await (const delta of streamChatCompletionText(
|
||||||
@ -696,6 +699,8 @@ function AssistantChatComposer(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AssistantMessage() {
|
function AssistantMessage() {
|
||||||
|
const hasError = useMessage((state) => state.status?.type === 'incomplete' && state.status.reason === 'error');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root className="assistantMessage">
|
<MessagePrimitive.Root className="assistantMessage">
|
||||||
<MessagePrimitive.If user>
|
<MessagePrimitive.If user>
|
||||||
@ -704,16 +709,19 @@ function AssistantMessage() {
|
|||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.If>
|
</MessagePrimitive.If>
|
||||||
<MessagePrimitive.If assistant>
|
<MessagePrimitive.If assistant>
|
||||||
<div className="assistantBubble assistant">
|
<div className={hasError ? 'assistantBubble assistant error' : 'assistantBubble assistant'}>
|
||||||
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
|
<MessagePrimitive.Parts components={{ Text: AssistantMarkdownText }} />
|
||||||
<MessagePrimitive.If hasContent={false}>
|
<MessagePrimitive.Error>
|
||||||
<span className="assistantTyping">模型正在回复...</span>
|
<strong>调用失败</strong>
|
||||||
</MessagePrimitive.If>
|
<ErrorPrimitive.Message className="assistantErrorMessage" />
|
||||||
|
</MessagePrimitive.Error>
|
||||||
|
{!hasError && (
|
||||||
|
<MessagePrimitive.If hasContent={false}>
|
||||||
|
<span className="assistantTyping">模型正在回复...</span>
|
||||||
|
</MessagePrimitive.If>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.If>
|
</MessagePrimitive.If>
|
||||||
<MessagePrimitive.Error>
|
|
||||||
<div className="assistantBubble error">调用失败,请检查模型、平台凭证或测试模式配置。</div>
|
|
||||||
</MessagePrimitive.Error>
|
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
|
||||||
import { Popover as AntPopover } from 'antd';
|
import { Popover as AntPopover } from 'antd';
|
||||||
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, ReceiptText, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
|
||||||
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, GatewayWalletAccount, GatewayWalletTransaction, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||||
import type { ConsoleData } from '../app-state';
|
import type { ConsoleData } from '../app-state';
|
||||||
import { EntityTable } from '../components/EntityTable';
|
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 { 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 { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
|
||||||
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types';
|
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery, WorkspaceTransactionQuery } from '../types';
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
|
||||||
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
|
{ value: 'billing', label: '余额充值', icon: <CreditCard size={15} /> },
|
||||||
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
|
{ value: 'apiKeys', label: 'API Key', icon: <KeyRound size={15} /> },
|
||||||
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
|
||||||
|
{ value: 'transactions', label: '消费记录', icon: <ReceiptText size={15} /> },
|
||||||
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
|
||||||
|
|
||||||
const taskPageSizeOptions = [10, 20, 50];
|
const taskPageSizeOptions = [10, 20, 50];
|
||||||
@ -28,12 +29,15 @@ export function WorkspacePage(props: {
|
|||||||
state: LoadState;
|
state: LoadState;
|
||||||
taskQuery: WorkspaceTaskQuery;
|
taskQuery: WorkspaceTaskQuery;
|
||||||
taskTotal: number;
|
taskTotal: number;
|
||||||
|
transactionQuery: WorkspaceTransactionQuery;
|
||||||
|
transactionTotal: number;
|
||||||
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
|
||||||
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
|
||||||
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
onApiKeyFormChange: (value: ApiKeyForm) => void;
|
||||||
onSectionChange: (value: WorkspaceSection) => void;
|
onSectionChange: (value: WorkspaceSection) => void;
|
||||||
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
|
||||||
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
|
||||||
|
onTransactionQueryChange: (value: WorkspaceTransactionQuery) => void;
|
||||||
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -42,9 +46,10 @@ export function WorkspacePage(props: {
|
|||||||
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
<Tabs className="sideTabs" value={props.section} tabs={tabs} onValueChange={props.onSectionChange} />
|
||||||
<div className="subPageContent">
|
<div className="subPageContent">
|
||||||
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
|
{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 === 'apiKeys' && <ApiKeyPanel {...props} />}
|
||||||
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
|
{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>
|
</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 (
|
return (
|
||||||
<section className="contentGrid two">
|
<section className="contentGrid two">
|
||||||
<Card>
|
<Card>
|
||||||
@ -90,9 +97,15 @@ function BillingPanel() {
|
|||||||
<CardTitle>余额</CardTitle>
|
<CardTitle>余额</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="balanceCard">
|
<CardContent className="balanceCard">
|
||||||
<span>resource</span>
|
<span>{primaryWallet?.currency ?? 'resource'}</span>
|
||||||
<strong>0.00</strong>
|
<strong>{formatMoney(primaryWallet?.balance ?? 0)}</strong>
|
||||||
<Badge variant="secondary">local</Badge>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<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: {
|
function ApiKeyPanel(props: {
|
||||||
apiKeyForm: ApiKeyForm;
|
apiKeyForm: ApiKeyForm;
|
||||||
apiKeySecret: string;
|
apiKeySecret: string;
|
||||||
@ -657,11 +897,35 @@ function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[
|
|||||||
if (detail && code && detail !== code) return `${detail}(${code})`;
|
if (detail && code && detail !== code) return `${detail}(${code})`;
|
||||||
return detail || code || '失败';
|
return detail || code || '失败';
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCellValue(value: unknown) {
|
function formatCellValue(value: unknown) {
|
||||||
if (value === undefined || value === null || value === '') return '-';
|
if (value === undefined || value === null || value === '') return '-';
|
||||||
return String(value);
|
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>) {
|
function firstText(...values: Array<unknown>) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
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() : '';
|
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>) {
|
function formatTokenUsage(usage: Record<string, unknown>) {
|
||||||
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
|
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);
|
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 { 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 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 { 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';
|
import type { LoadState, PlatformWithModelsInput } from '../../types';
|
||||||
@ -23,6 +23,7 @@ import { ModelCatalogCard } from './ModelCatalogCard';
|
|||||||
export function PlatformManagementPanel(props: {
|
export function PlatformManagementPanel(props: {
|
||||||
baseModels: BaseModelCatalogItem[];
|
baseModels: BaseModelCatalogItem[];
|
||||||
message: string;
|
message: string;
|
||||||
|
networkProxyConfig: { globalHttpProxy?: string; globalHttpProxySet: boolean; globalHttpProxySource?: string } | null;
|
||||||
platforms: IntegrationPlatform[];
|
platforms: IntegrationPlatform[];
|
||||||
platformModels: PlatformModel[];
|
platformModels: PlatformModel[];
|
||||||
pricingRuleSets: PricingRuleSet[];
|
pricingRuleSets: PricingRuleSet[];
|
||||||
@ -37,6 +38,7 @@ export function PlatformManagementPanel(props: {
|
|||||||
const [modelQuery, setModelQuery] = useState('');
|
const [modelQuery, setModelQuery] = useState('');
|
||||||
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
const [selectedPlatformId, setSelectedPlatformId] = useState('');
|
||||||
const [validationMessage, setValidationMessage] = useState('');
|
const [validationMessage, setValidationMessage] = useState('');
|
||||||
|
const [globalProxyNoticeOpen, setGlobalProxyNoticeOpen] = useState(false);
|
||||||
const [editingPlatform, setEditingPlatform] = useState<IntegrationPlatform | null>(null);
|
const [editingPlatform, setEditingPlatform] = useState<IntegrationPlatform | null>(null);
|
||||||
const [pendingDeletePlatform, setPendingDeletePlatform] = 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]);
|
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>) {
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) });
|
const validationMessage = validatePlatformForm(form, selectedModels.length, { allowEmptyCredentials: Boolean(editingPlatform) });
|
||||||
@ -254,6 +267,30 @@ export function PlatformManagementPanel(props: {
|
|||||||
/>
|
/>
|
||||||
</FormSection>
|
</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="路由与计费">
|
<FormSection icon={<SlidersHorizontal size={16} />} title="路由与计费">
|
||||||
<Label>
|
<Label>
|
||||||
定价规则
|
定价规则
|
||||||
@ -296,6 +333,19 @@ export function PlatformManagementPanel(props: {
|
|||||||
/>
|
/>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
</FormDialog>
|
</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
|
<ConfirmDialog
|
||||||
confirmLabel="删除平台"
|
confirmLabel="删除平台"
|
||||||
description="已绑定的模型会一并删除,删除后不可恢复。"
|
description="已绑定的模型会一并删除,删除后不可恢复。"
|
||||||
@ -868,6 +918,7 @@ function platformToForm(
|
|||||||
const config = platform.config ?? {};
|
const config = platform.config ?? {};
|
||||||
const retryPolicy = platform.retryPolicy ?? {};
|
const retryPolicy = platform.retryPolicy ?? {};
|
||||||
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
|
const rateLimitPolicy = platform.rateLimitPolicy ?? {};
|
||||||
|
const networkProxy = readNetworkProxyConfig(config);
|
||||||
const currentModels = platformModels.filter((model) => model.platformId === platform.id);
|
const currentModels = platformModels.filter((model) => model.platformId === platform.id);
|
||||||
return {
|
return {
|
||||||
...createEmptyPlatformForm(platform.provider, defaults),
|
...createEmptyPlatformForm(platform.provider, defaults),
|
||||||
@ -892,6 +943,8 @@ function platformToForm(
|
|||||||
tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'),
|
tpmLimit: readLimit(rateLimitPolicy, 'tpm_total'),
|
||||||
concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'),
|
concurrencyLimit: readLimit(rateLimitPolicy, 'concurrent'),
|
||||||
testMode: readBoolean(config, 'testMode', false),
|
testMode: readBoolean(config, 'testMode', false),
|
||||||
|
proxyMode: networkProxy.proxyMode,
|
||||||
|
httpProxy: networkProxy.httpProxy,
|
||||||
supportBase64Input: readBoolean(config, 'supportBase64Input', true),
|
supportBase64Input: readBoolean(config, 'supportBase64Input', true),
|
||||||
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
|
||||||
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
|
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;
|
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) {
|
function readNumber(source: Record<string, unknown>, key: string) {
|
||||||
const value = source[key];
|
const value = source[key];
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
@ -1056,7 +1119,19 @@ function platformRuntimeSummary(platform: IntegrationPlatform) {
|
|||||||
const retryPolicy = platform.retryPolicy ?? {};
|
const retryPolicy = platform.retryPolicy ?? {};
|
||||||
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
|
const retryEnabled = readBoolean(retryPolicy, 'enabled', true);
|
||||||
const maxAttempts = readNumber(retryPolicy, 'maxAttempts') ?? 2;
|
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) {
|
function formatLimit(value: number) {
|
||||||
|
|||||||
@ -30,6 +30,8 @@ export interface PlatformWizardForm {
|
|||||||
tpmLimit: string;
|
tpmLimit: string;
|
||||||
concurrencyLimit: string;
|
concurrencyLimit: string;
|
||||||
testMode: boolean;
|
testMode: boolean;
|
||||||
|
proxyMode: 'none' | 'global' | 'custom';
|
||||||
|
httpProxy: string;
|
||||||
supportBase64Input: boolean;
|
supportBase64Input: boolean;
|
||||||
supportUrlInput: boolean;
|
supportUrlInput: boolean;
|
||||||
modelDiscountFactor: string;
|
modelDiscountFactor: string;
|
||||||
@ -75,6 +77,8 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
|
|||||||
tpmLimit: '',
|
tpmLimit: '',
|
||||||
concurrencyLimit: '',
|
concurrencyLimit: '',
|
||||||
testMode: false,
|
testMode: false,
|
||||||
|
proxyMode: 'none',
|
||||||
|
httpProxy: '',
|
||||||
supportBase64Input: true,
|
supportBase64Input: true,
|
||||||
supportUrlInput: true,
|
supportUrlInput: true,
|
||||||
modelDiscountFactor: '',
|
modelDiscountFactor: '',
|
||||||
@ -130,6 +134,7 @@ export function platformPayload(form: PlatformWizardForm, options: { preserveEmp
|
|||||||
credentials,
|
credentials,
|
||||||
config: {
|
config: {
|
||||||
testMode: form.testMode,
|
testMode: form.testMode,
|
||||||
|
networkProxy: networkProxyPayload(form),
|
||||||
supportBase64Input: form.supportBase64Input,
|
supportBase64Input: form.supportBase64Input,
|
||||||
supportUrlInput: form.supportUrlInput,
|
supportUrlInput: form.supportUrlInput,
|
||||||
source: 'gateway-admin',
|
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) {
|
if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) {
|
||||||
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
|
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
|
||||||
}
|
}
|
||||||
|
if (form.proxyMode === 'custom' && !form.httpProxy.trim()) return '请填写自定义 HTTP 代理地址。';
|
||||||
if (selectedCount === 0) return '请至少添加一个模型。';
|
if (selectedCount === 0) return '请至少添加一个模型。';
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -322,6 +328,16 @@ function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rps
|
|||||||
return rules.length ? { rules } : {};
|
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) {
|
function limitRule(metric: string, value: string, windowSeconds = 60) {
|
||||||
const limit = positiveNumber(value, 0);
|
const limit = positiveNumber(value, 0);
|
||||||
if (!limit) return undefined;
|
if (!limit) return undefined;
|
||||||
|
|||||||
@ -47,6 +47,32 @@ export interface MediaGenerationRun {
|
|||||||
task?: GatewayTask;
|
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 {
|
interface AspectRatioOption {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@ -487,6 +513,7 @@ function MediaTaskCard(props: {
|
|||||||
const unit = props.run.mode === 'video' ? '条' : '张';
|
const unit = props.run.mode === 'video' ? '条' : '张';
|
||||||
const isPending = props.run.status === 'submitting' || props.run.status === 'queued' || props.run.status === 'running';
|
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 backdropItem = expectedCount === 1 && items[0]?.type === 'image' ? items[0] : undefined;
|
||||||
|
const errorText = mediaRunErrorText(props.run);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="mediaTaskItem" data-status={props.run.status}>
|
<article className="mediaTaskItem" data-status={props.run.status}>
|
||||||
@ -517,7 +544,12 @@ function MediaTaskCard(props: {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<footer className="mediaTaskActions">
|
||||||
{items[0] ? (
|
{items[0] ? (
|
||||||
<Button asChild size="sm" variant="secondary">
|
<Button asChild size="sm" variant="secondary">
|
||||||
@ -593,6 +625,10 @@ function mediaResultItems(run: MediaGenerationRun): MediaResultItem[] {
|
|||||||
.filter((item): item is MediaResultItem => Boolean(item));
|
.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 {
|
function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode, 'chat'>): MediaResultItem | undefined {
|
||||||
const record = recordFromUnknown(entry);
|
const record = recordFromUnknown(entry);
|
||||||
if (!record) return undefined;
|
if (!record) return undefined;
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const workspacePaths: Record<WorkspaceSection, string> = {
|
|||||||
billing: '/workspace/billing',
|
billing: '/workspace/billing',
|
||||||
apiKeys: '/workspace/api-keys',
|
apiKeys: '/workspace/api-keys',
|
||||||
tasks: '/workspace/tasks',
|
tasks: '/workspace/tasks',
|
||||||
|
transactions: '/workspace/transactions',
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminPaths: Record<AdminSection, string> = {
|
const adminPaths: Record<AdminSection, string> = {
|
||||||
|
|||||||
@ -423,11 +423,105 @@ strong {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.taskRecordJsonButton {
|
.taskRecordJsonButton {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: flex-start;
|
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 {
|
.taskJsonDialog {
|
||||||
width: min(920px, 100%);
|
width: min(920px, 100%);
|
||||||
}
|
}
|
||||||
@ -741,6 +835,14 @@ strong {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.walletTransactionFilters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.walletMetricGrid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.shTableFooter {
|
.shTableFooter {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -750,3 +852,9 @@ strong {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.walletMetricGrid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1115,6 +1115,14 @@
|
|||||||
font-weight: var(--font-weight-semibold);
|
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 {
|
.platformCredentialField {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -811,11 +811,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistantBubble.error {
|
.assistantBubble.error {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
border-color: #f0d4d4;
|
border-color: #f0d4d4;
|
||||||
background: #fff7f7;
|
background: #fff7f7;
|
||||||
color: #9f2f2f;
|
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 {
|
.assistantTyping {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
@ -1423,6 +1437,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mediaTaskError {
|
.mediaTaskError {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@ -1431,6 +1447,16 @@
|
|||||||
background: #fef2f2;
|
background: #fef2f2;
|
||||||
color: #991b1b;
|
color: #991b1b;
|
||||||
font-size: var(--font-size-sm);
|
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 {
|
.mediaTaskActions {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export type AuthMode = 'login' | 'register' | 'external';
|
|||||||
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
|
export type TaskKind = 'chat.completions' | 'images.generations' | 'images.edits';
|
||||||
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
|
export type PageKey = 'home' | 'playground' | 'models' | 'workspace' | 'admin' | 'docs';
|
||||||
export type PlaygroundMode = 'chat' | 'image' | 'video';
|
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 ApiDocSection = 'chat' | 'imageGeneration' | 'imageEdit' | 'pricing' | 'files';
|
||||||
export type AdminSection =
|
export type AdminSection =
|
||||||
| 'overview'
|
| 'overview'
|
||||||
@ -53,6 +53,14 @@ export interface WorkspaceTaskQuery {
|
|||||||
pageSize: number;
|
pageSize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceTransactionQuery {
|
||||||
|
query: string;
|
||||||
|
createdFrom: string;
|
||||||
|
createdTo: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformForm {
|
export interface PlatformForm {
|
||||||
provider: string;
|
provider: string;
|
||||||
platformKey: string;
|
platformKey: string;
|
||||||
|
|||||||
@ -497,6 +497,7 @@ export interface GatewayWalletAccount {
|
|||||||
export interface GatewayWalletTransaction {
|
export interface GatewayWalletTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
currency?: 'resource' | 'credit' | 'cny' | 'usd' | string;
|
||||||
gatewayTenantId?: string;
|
gatewayTenantId?: string;
|
||||||
gatewayUserId?: string;
|
gatewayUserId?: string;
|
||||||
direction: 'credit' | 'debit' | 'freeze' | 'unfreeze' | string;
|
direction: 'credit' | 'debit' | 'freeze' | 'unfreeze' | string;
|
||||||
@ -519,6 +520,11 @@ export interface WalletBalanceAdjustmentRequest {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WalletSummaryResponse {
|
||||||
|
accounts: GatewayWalletAccount[];
|
||||||
|
primaryAccount: GatewayWalletAccount;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WalletAdjustmentResponse {
|
export interface WalletAdjustmentResponse {
|
||||||
account: GatewayWalletAccount;
|
account: GatewayWalletAccount;
|
||||||
before: GatewayWalletAccount;
|
before: GatewayWalletAccount;
|
||||||
@ -716,6 +722,12 @@ export interface RateLimitWindow {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GatewayNetworkProxyConfig {
|
||||||
|
globalHttpProxy?: string;
|
||||||
|
globalHttpProxySet: boolean;
|
||||||
|
globalHttpProxySource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GatewayTask {
|
export interface GatewayTask {
|
||||||
id: string;
|
id: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user