feat(admin): 添加网络代理配置和钱包交易功能
- 在管理面板中集成网络代理配置显示和平台代理设置 - 添加钱包摘要和交易列表API接口及数据管理 - 实现SSE流式响应中的错误处理机制 - 添加全局HTTP代理环境变量配置支持 - 更新平台表单以支持代理模式选择和自定义代理地址 - 集成钱包交易查询过滤和分页功能 - 优化API错误详情解析和显示格式
This commit is contained in:
@@ -27,7 +27,7 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
|
||||
return Response{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := httpClient(c.HTTPClient).Do(req)
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient(c.HTTPClient).Do(req)
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
@@ -114,9 +114,11 @@ func joinURL(base string, path string) string {
|
||||
return base + path
|
||||
}
|
||||
|
||||
func httpClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
func httpClient(clients ...*http.Client) *http.Client {
|
||||
for _, client := range clients {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ type Request struct {
|
||||
Model string
|
||||
Body map[string]any
|
||||
Candidate store.RuntimeModelCandidate
|
||||
HTTPClient *http.Client
|
||||
Stream bool
|
||||
StreamDelta StreamDelta
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
|
||||
resp, err := httpClient(c.HTTPClient).Do(req)
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
|
||||
func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey string) (Response, error) {
|
||||
body := volcesVideoBody(request)
|
||||
submitStartedAt := time.Now()
|
||||
submitResult, submitRequestID, err := c.postJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
|
||||
submitResult, submitRequestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
|
||||
submitFinishedAt := time.Now()
|
||||
if err != nil {
|
||||
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, submitFinishedAt)
|
||||
@@ -96,7 +96,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
}
|
||||
|
||||
pollStartedAt := time.Now()
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
|
||||
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
|
||||
pollFinishedAt := time.Now()
|
||||
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
|
||||
if err != nil {
|
||||
@@ -143,7 +143,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
|
||||
}
|
||||
}
|
||||
|
||||
func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
|
||||
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
|
||||
raw, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
@@ -151,7 +151,7 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string,
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient(c.HTTPClient).Do(req)
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
@@ -160,13 +160,13 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string,
|
||||
return result, requestID, err
|
||||
}
|
||||
|
||||
func (c VolcesClient) getJSON(ctx context.Context, baseURL string, path string, apiKey string) (map[string]any, string, error) {
|
||||
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
resp, err := httpClient(c.HTTPClient).Do(req)
|
||||
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
|
||||
if err != nil {
|
||||
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,13 @@ type Config struct {
|
||||
TaskProgressCallbackTimeoutMS string
|
||||
TaskProgressCallbackMaxAttempts string
|
||||
CORSAllowedOrigin string
|
||||
GlobalHTTPProxy string
|
||||
GlobalHTTPProxySource string
|
||||
LogLevel slog.Level
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
globalProxy := LoadGlobalHTTPProxyStatus()
|
||||
return Config{
|
||||
AppEnv: env("APP_ENV", "development"),
|
||||
HTTPAddr: env("HTTP_ADDR", ":8088"),
|
||||
@@ -42,10 +45,35 @@ func Load() Config {
|
||||
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
|
||||
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
|
||||
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"),
|
||||
GlobalHTTPProxy: globalProxy.HTTPProxy,
|
||||
GlobalHTTPProxySource: globalProxy.Source,
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
}
|
||||
}
|
||||
|
||||
type GlobalHTTPProxyStatus struct {
|
||||
HTTPProxy string
|
||||
Source string
|
||||
}
|
||||
|
||||
func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus {
|
||||
for _, key := range []string{
|
||||
"AI_GATEWAY_GLOBAL_HTTP_PROXY",
|
||||
"GLOBAL_HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
} {
|
||||
if value := envValue(key); value != "" {
|
||||
return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key}
|
||||
}
|
||||
}
|
||||
return GlobalHTTPProxyStatus{}
|
||||
}
|
||||
|
||||
func gatewayDatabaseURL() string {
|
||||
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
|
||||
return normalizePostgresURL(value)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) getNetworkProxyConfig(w http.ResponseWriter, r *http.Request) {
|
||||
globalHTTPProxy := strings.TrimSpace(s.cfg.GlobalHTTPProxy)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"globalHttpProxy": globalHTTPProxy,
|
||||
"globalHttpProxySet": globalHTTPProxy != "",
|
||||
"globalHttpProxySource": strings.TrimSpace(s.cfg.GlobalHTTPProxySource),
|
||||
})
|
||||
}
|
||||
@@ -233,6 +233,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
PlatformKey string `json:"platformKey"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodPost, "/api/admin/platforms", loginResponse.AccessToken, map[string]any{
|
||||
@@ -593,6 +594,51 @@ WHERE reference_type = 'gateway_task'
|
||||
if !floatNear(walletTransactionAmount, pricingTask.Task.FinalChargeAmount) {
|
||||
t.Fatalf("task billing transaction amount=%f want=%f", walletTransactionAmount, pricingTask.Task.FinalChargeAmount)
|
||||
}
|
||||
var walletSummary struct {
|
||||
Accounts []struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
TotalSpent float64 `json:"totalSpent"`
|
||||
} `json:"accounts"`
|
||||
PrimaryAccount struct {
|
||||
Currency string `json:"currency"`
|
||||
Balance float64 `json:"balance"`
|
||||
} `json:"primaryAccount"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet", loginResponse.AccessToken, nil, http.StatusOK, &walletSummary)
|
||||
if walletSummary.PrimaryAccount.Currency != "resource" || !floatNear(walletSummary.PrimaryAccount.Balance, walletBalanceAfter) || len(walletSummary.Accounts) == 0 {
|
||||
t.Fatalf("workspace wallet should expose current resource balance, got %+v want balance=%f", walletSummary, walletBalanceAfter)
|
||||
}
|
||||
var walletTransactions struct {
|
||||
Items []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=20", loginResponse.AccessToken, nil, http.StatusOK, &walletTransactions)
|
||||
if walletTransactions.Total == 0 || !walletTransactionListContains(walletTransactions.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("workspace wallet transactions should include task billing debit, got %+v", walletTransactions)
|
||||
}
|
||||
var filteredWalletTransactions struct {
|
||||
Items []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
} `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
createdFrom := time.Now().Add(-1 * time.Hour).UTC().Format(time.RFC3339)
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/wallet/transactions?direction=debit&pageSize=1&q="+pricingModel+"&createdFrom="+createdFrom, loginResponse.AccessToken, nil, http.StatusOK, &filteredWalletTransactions)
|
||||
if filteredWalletTransactions.Total == 0 || !walletTransactionListContainsWithMetadata(filteredWalletTransactions.Items, pricingTask.Task.ID, pricingModel, platform.Name, apiKeyResponse.APIKey.Name, pricingTask.Task.FinalChargeAmount) {
|
||||
t.Fatalf("workspace wallet transaction filters should match model and expose task metadata, got %+v", filteredWalletTransactions)
|
||||
}
|
||||
|
||||
rateLimitedModel := "rate-limit-smoke-" + suffixText
|
||||
var rateLimitPolicySet struct {
|
||||
@@ -941,6 +987,21 @@ WHERE reference_type = 'gateway_task'
|
||||
if !taskListContains(taskList.Items, taskResponse.Task.ID) || !taskListContains(taskList.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("task list should include persisted task records, got %+v", taskList.Items)
|
||||
}
|
||||
var workspaceTaskList struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
APIKeyName string `json:"apiKeyName"`
|
||||
ModelType string `json:"modelType"`
|
||||
FinalCharge float64 `json:"finalChargeAmount"`
|
||||
ErrorCode string `json:"errorCode"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
} `json:"items"`
|
||||
}
|
||||
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?limit=20", loginResponse.AccessToken, nil, http.StatusOK, &workspaceTaskList)
|
||||
if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) {
|
||||
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+taskResponse.Task.ID+"/events", nil)
|
||||
if err != nil {
|
||||
@@ -1098,6 +1159,50 @@ func taskListContains(values []struct {
|
||||
return false
|
||||
}
|
||||
|
||||
func walletTransactionListContains(values []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
}, target string) bool {
|
||||
for _, value := range values {
|
||||
if value.ReferenceID == target && value.TransactionType == "task_billing" && value.Direction == "debit" && value.Currency == "resource" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func walletTransactionListContainsWithMetadata(values []struct {
|
||||
TransactionType string `json:"transactionType"`
|
||||
Direction string `json:"direction"`
|
||||
ReferenceID string `json:"referenceId"`
|
||||
Currency string `json:"currency"`
|
||||
Amount float64 `json:"amount"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}, target string, model string, platformName string, apiKeyName string, finalChargeAmount float64) bool {
|
||||
for _, value := range values {
|
||||
usage := objectValue(value.Metadata["usage"])
|
||||
billingSummary := objectValue(value.Metadata["billingSummary"])
|
||||
finalCharge, hasFinalCharge := numberValue(value.Metadata["finalChargeAmount"])
|
||||
billingTotal, hasBillingTotal := numberValue(billingSummary["totalAmount"])
|
||||
if value.ReferenceID == target &&
|
||||
value.Metadata["model"] == model &&
|
||||
value.Metadata["modelType"] == "text_generate" &&
|
||||
value.Metadata["platformName"] == platformName &&
|
||||
value.Metadata["apiKeyName"] == apiKeyName &&
|
||||
usage["totalTokens"] != nil &&
|
||||
hasFinalCharge &&
|
||||
hasBillingTotal &&
|
||||
floatNear(finalCharge, finalChargeAmount) &&
|
||||
floatNear(billingTotal, finalChargeAmount) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func floatNear(value float64, expected float64) bool {
|
||||
return math.Abs(value-expected) < 0.000001
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -186,6 +188,12 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
config, err := netproxy.NormalizePlatformConfig(input.Config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
input.Config = config
|
||||
platform, err := s.store.CreatePlatform(r.Context(), input)
|
||||
if err != nil {
|
||||
s.logger.Error("create platform failed", "error", err)
|
||||
@@ -211,6 +219,12 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
|
||||
if input.AuthType == "" {
|
||||
input.AuthType = "bearer"
|
||||
}
|
||||
config, err := netproxy.NormalizePlatformConfig(input.Config)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
input.Config = config
|
||||
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
@@ -541,7 +555,19 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return nil
|
||||
})
|
||||
if runErr != nil {
|
||||
sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}})
|
||||
status := statusFromRunError(runErr)
|
||||
errorPayload := map[string]any{
|
||||
"code": clients.ErrorCode(runErr),
|
||||
"message": runErr.Error(),
|
||||
"status": status,
|
||||
}
|
||||
if result.Task.ID != "" {
|
||||
errorPayload["taskId"] = result.Task.ID
|
||||
}
|
||||
if result.Task.RequestID != "" {
|
||||
errorPayload["requestId"] = result.Task.RequestID
|
||||
}
|
||||
sendSSE(w, "error", map[string]any{"error": errorPayload})
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
@@ -555,7 +581,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
}
|
||||
result, runErr := s.runner.Execute(r.Context(), task, user)
|
||||
if runErr != nil {
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error())
|
||||
writeError(w, statusFromRunError(runErr), runErr.Error(), clients.ErrorCode(runErr))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result.Output)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
@@ -12,13 +13,17 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
},
|
||||
})
|
||||
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
|
||||
errorPayload := map[string]any{
|
||||
"message": message,
|
||||
"status": status,
|
||||
}
|
||||
if len(codes) > 0 {
|
||||
if code := strings.TrimSpace(codes[0]); code != "" {
|
||||
errorPayload["code"] = code
|
||||
}
|
||||
}
|
||||
writeJSON(w, status, map[string]any{"error": errorPayload})
|
||||
}
|
||||
|
||||
func sendSSE(w http.ResponseWriter, event string, payload any) {
|
||||
|
||||
@@ -75,6 +75,11 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
|
||||
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
|
||||
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
|
||||
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
|
||||
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
|
||||
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
|
||||
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
|
||||
mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
|
||||
mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
|
||||
mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
|
||||
@@ -85,6 +90,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
|
||||
mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
|
||||
mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
|
||||
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
|
||||
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
|
||||
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestPlatformProxyModeNoneIgnoresEnvironmentProxy(t *testing.T) {
|
||||
var proxyHits int
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyHits++
|
||||
w.WriteHeader(http.StatusTeapot)
|
||||
}))
|
||||
defer proxy.Close()
|
||||
t.Setenv("HTTP_PROXY", proxy.URL)
|
||||
t.Setenv("http_proxy", proxy.URL)
|
||||
|
||||
var targetHits int
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHits++
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{
|
||||
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "none"}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build http client: %v", err)
|
||||
}
|
||||
resp, err := client.Get(target.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get target: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || targetHits != 1 || proxyHits != 0 {
|
||||
t.Fatalf("unexpected status=%d targetHits=%d proxyHits=%d", resp.StatusCode, targetHits, proxyHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformProxyModeCustomUsesConfiguredHTTPProxy(t *testing.T) {
|
||||
var targetHits int
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHits++
|
||||
_, _ = w.Write([]byte("target"))
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
var proxyHits int
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyHits++
|
||||
if r.URL.String() != target.URL && r.URL.String() != target.URL+"/" {
|
||||
t.Fatalf("proxy received unexpected target URL %q", r.URL.String())
|
||||
}
|
||||
_, _ = w.Write([]byte("proxied"))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{
|
||||
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "custom", "httpProxy": proxy.URL}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build http client: %v", err)
|
||||
}
|
||||
resp, err := client.Get(target.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get target through proxy: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 {
|
||||
t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformProxyModeGlobalUsesConfiguredGlobalHTTPProxy(t *testing.T) {
|
||||
var targetHits int
|
||||
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
targetHits++
|
||||
_, _ = w.Write([]byte("target"))
|
||||
}))
|
||||
defer target.Close()
|
||||
|
||||
var proxyHits int
|
||||
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxyHits++
|
||||
_, _ = w.Write([]byte("proxied"))
|
||||
}))
|
||||
defer proxy.Close()
|
||||
|
||||
client, err := testProxyService(config.Config{GlobalHTTPProxy: proxy.URL}).httpClientForCandidate(store.RuntimeModelCandidate{
|
||||
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "global"}},
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build http client: %v", err)
|
||||
}
|
||||
resp, err := client.Get(target.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("get target through global proxy: %v", err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 {
|
||||
t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits)
|
||||
}
|
||||
}
|
||||
|
||||
func testProxyService(cfg config.Config) *Service {
|
||||
return &Service{cfg: cfg, httpClients: newHTTPClientCache()}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,10 +14,11 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
clients map[string]clients.Client
|
||||
cfg config.Config
|
||||
store *store.Store
|
||||
logger *slog.Logger
|
||||
clients map[string]clients.Client
|
||||
httpClients *httpClientCache
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -27,17 +27,18 @@ type Result struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
|
||||
httpClient := &http.Client{Timeout: 120 * time.Second}
|
||||
httpClients := newHTTPClientCache()
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
store: db,
|
||||
logger: logger,
|
||||
clients: map[string]clients.Client{
|
||||
"openai": clients.OpenAIClient{HTTPClient: httpClient},
|
||||
"gemini": clients.GeminiClient{HTTPClient: httpClient},
|
||||
"volces": clients.VolcesClient{HTTPClient: httpClient},
|
||||
"openai": clients.OpenAIClient{HTTPClient: httpClients.none},
|
||||
"gemini": clients.GeminiClient{HTTPClient: httpClients.none},
|
||||
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
|
||||
"simulation": clients.SimulationClient{},
|
||||
},
|
||||
httpClients: httpClients,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +201,18 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
}
|
||||
defer s.store.RecordClientRelease(context.WithoutCancel(ctx), candidate.ClientID, "")
|
||||
|
||||
requestHTTPClient, err := s.httpClientForCandidate(candidate, simulated)
|
||||
if err != nil {
|
||||
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
|
||||
AttemptID: attemptID,
|
||||
Status: "failed",
|
||||
Retryable: false,
|
||||
Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false}),
|
||||
ErrorCode: clients.ErrorCode(err),
|
||||
ErrorMessage: err.Error(),
|
||||
})
|
||||
return clients.Response{}, err
|
||||
}
|
||||
client := s.clientFor(candidate, simulated)
|
||||
callStartedAt := time.Now()
|
||||
response, err := client.Run(ctx, clients.Request{
|
||||
@@ -208,6 +221,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
|
||||
Model: task.Model,
|
||||
Body: body,
|
||||
Candidate: candidate,
|
||||
HTTPClient: requestHTTPClient,
|
||||
Stream: boolFromMap(body, "stream"),
|
||||
StreamDelta: onDelta,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user