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

- 在管理面板中集成网络代理配置显示和平台代理设置
- 添加钱包摘要和交易列表API接口及数据管理
- 实现SSE流式响应中的错误处理机制
- 添加全局HTTP代理环境变量配置支持
- 更新平台表单以支持代理模式选择和自定义代理地址
- 集成钱包交易查询过滤和分页功能
- 优化API错误详情解析和显示格式
This commit is contained in:
2026-05-11 23:02:10 +08:00
parent c992f1de60
commit f550c0acd5
30 changed files with 1455 additions and 76 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
return Response{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := httpClient(c.HTTPClient).Do(req)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
+6 -4
View File
@@ -33,7 +33,7 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(c.HTTPClient).Do(req)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
@@ -114,9 +114,11 @@ func joinURL(base string, path string) string {
return base + path
}
func httpClient(client *http.Client) *http.Client {
if client != nil {
return client
func httpClient(clients ...*http.Client) *http.Client {
for _, client := range clients {
if client != nil {
return client
}
}
return http.DefaultClient
}
+1
View File
@@ -16,6 +16,7 @@ type Request struct {
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
Stream bool
StreamDelta StreamDelta
}
+7 -7
View File
@@ -41,7 +41,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(c.HTTPClient).Do(req)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
@@ -69,7 +69,7 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey string) (Response, error) {
body := volcesVideoBody(request)
submitStartedAt := time.Now()
submitResult, submitRequestID, err := c.postJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
submitResult, submitRequestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks", apiKey, body)
submitFinishedAt := time.Now()
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, submitFinishedAt)
@@ -96,7 +96,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSON(ctx, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, "/contents/generations/tasks/"+upstreamTaskID, apiKey)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
if err != nil {
@@ -143,7 +143,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
}
func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
@@ -151,7 +151,7 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string,
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(c.HTTPClient).Do(req)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
@@ -160,13 +160,13 @@ func (c VolcesClient) postJSON(ctx context.Context, baseURL string, path string,
return result, requestID, err
}
func (c VolcesClient) getJSON(ctx context.Context, baseURL string, path string, apiKey string) (map[string]any, string, error) {
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
if err != nil {
return nil, "", err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(c.HTTPClient).Do(req)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
+28
View File
@@ -20,10 +20,13 @@ type Config struct {
TaskProgressCallbackTimeoutMS string
TaskProgressCallbackMaxAttempts string
CORSAllowedOrigin string
GlobalHTTPProxy string
GlobalHTTPProxySource string
LogLevel slog.Level
}
func Load() Config {
globalProxy := LoadGlobalHTTPProxyStatus()
return Config{
AppEnv: env("APP_ENV", "development"),
HTTPAddr: env("HTTP_ADDR", ":8088"),
@@ -42,10 +45,35 @@ func Load() Config {
TaskProgressCallbackTimeoutMS: env("TASK_PROGRESS_CALLBACK_TIMEOUT_MS", "5000"),
TaskProgressCallbackMaxAttempts: env("TASK_PROGRESS_CALLBACK_MAX_ATTEMPTS", "10"),
CORSAllowedOrigin: env("CORS_ALLOWED_ORIGIN", "http://localhost:5178,http://127.0.0.1:5178"),
GlobalHTTPProxy: globalProxy.HTTPProxy,
GlobalHTTPProxySource: globalProxy.Source,
LogLevel: logLevel(env("LOG_LEVEL", "info")),
}
}
type GlobalHTTPProxyStatus struct {
HTTPProxy string
Source string
}
func LoadGlobalHTTPProxyStatus() GlobalHTTPProxyStatus {
for _, key := range []string{
"AI_GATEWAY_GLOBAL_HTTP_PROXY",
"GLOBAL_HTTP_PROXY",
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
} {
if value := envValue(key); value != "" {
return GlobalHTTPProxyStatus{HTTPProxy: value, Source: key}
}
}
return GlobalHTTPProxyStatus{}
}
func gatewayDatabaseURL() string {
if value := envValue("AI_GATEWAY_DATABASE_URL"); value != "" {
return normalizePostgresURL(value)
@@ -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
}
+28 -2
View File
@@ -10,6 +10,8 @@ import (
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
@@ -186,6 +188,12 @@ func (s *Server) createPlatform(w http.ResponseWriter, r *http.Request) {
if input.AuthType == "" {
input.AuthType = "bearer"
}
config, err := netproxy.NormalizePlatformConfig(input.Config)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
input.Config = config
platform, err := s.store.CreatePlatform(r.Context(), input)
if err != nil {
s.logger.Error("create platform failed", "error", err)
@@ -211,6 +219,12 @@ func (s *Server) updatePlatform(w http.ResponseWriter, r *http.Request) {
if input.AuthType == "" {
input.AuthType = "bearer"
}
config, err := netproxy.NormalizePlatformConfig(input.Config)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
input.Config = config
platform, err := s.store.UpdatePlatform(r.Context(), r.PathValue("platformID"), input)
if err != nil {
if store.IsNotFound(err) {
@@ -541,7 +555,19 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return nil
})
if runErr != nil {
sendSSE(w, "error", map[string]any{"error": map[string]any{"message": runErr.Error(), "status": statusFromRunError(runErr)}})
status := statusFromRunError(runErr)
errorPayload := map[string]any{
"code": clients.ErrorCode(runErr),
"message": runErr.Error(),
"status": status,
}
if result.Task.ID != "" {
errorPayload["taskId"] = result.Task.ID
}
if result.Task.RequestID != "" {
errorPayload["requestId"] = result.Task.RequestID
}
sendSSE(w, "error", map[string]any{"error": errorPayload})
if flusher != nil {
flusher.Flush()
}
@@ -555,7 +581,7 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
}
result, runErr := s.runner.Execute(r.Context(), task, user)
if runErr != nil {
writeError(w, statusFromRunError(runErr), runErr.Error())
writeError(w, statusFromRunError(runErr), runErr.Error(), clients.ErrorCode(runErr))
return
}
writeJSON(w, http.StatusOK, result.Output)
+12 -7
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
func writeJSON(w http.ResponseWriter, status int, value any) {
@@ -12,13 +13,17 @@ func writeJSON(w http.ResponseWriter, status int, value any) {
_ = json.NewEncoder(w).Encode(value)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{
"error": map[string]any{
"message": message,
"status": status,
},
})
func writeError(w http.ResponseWriter, status int, message string, codes ...string) {
errorPayload := map[string]any{
"message": message,
"status": status,
}
if len(codes) > 0 {
if code := strings.TrimSpace(codes[0]); code != "" {
errorPayload["code"] = code
}
}
writeJSON(w, status, map[string]any{"error": errorPayload})
}
func sendSSE(w http.ResponseWriter, event string, payload any) {
+6
View File
@@ -75,6 +75,11 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
mux.Handle("PATCH /api/v1/api-keys/{apiKeyID}/disable", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.disableAPIKey)))
mux.Handle("DELETE /api/v1/api-keys/{apiKeyID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.deleteAPIKey)))
mux.Handle("GET /api/playground/api-keys", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listPlayableAPIKeys)))
mux.Handle("GET /api/workspace/wallet", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getWallet)))
mux.Handle("GET /api/workspace/wallet/transactions", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listWalletTransactions)))
mux.Handle("GET /api/workspace/tasks", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.listTasks)))
mux.Handle("GET /api/workspace/tasks/{taskID}", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.getTask)))
mux.Handle("GET /api/workspace/tasks/{taskID}/events", server.auth.Require(auth.PermissionBasic, http.HandlerFunc(server.taskEvents)))
mux.Handle("GET /api/admin/pricing/rules", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRules)))
mux.Handle("GET /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPricingRuleSets)))
mux.Handle("POST /api/admin/pricing/rule-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPricingRuleSet)))
@@ -85,6 +90,7 @@ func NewServer(cfg config.Config, db *store.Store, logger *slog.Logger) http.Han
mux.Handle("POST /api/admin/runtime/policy-sets", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createRuntimePolicySet)))
mux.Handle("PATCH /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRuntimePolicySet)))
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
mux.Handle("GET /api/admin/platforms", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listPlatforms)))
mux.Handle("POST /api/admin/platforms", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.createPlatform)))
mux.Handle("PATCH /api/admin/platforms/{platformID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updatePlatform)))
@@ -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
View File
@@ -0,0 +1,114 @@
package netproxy
import (
"fmt"
"net/url"
"strings"
)
const (
ModeNone = "none"
ModeGlobal = "global"
ModeCustom = "custom"
)
type Config struct {
Mode string
HTTPProxy string
}
func FromPlatformConfig(values map[string]any) Config {
config := Config{
Mode: stringFromMap(values, "proxyMode"),
HTTPProxy: stringFromMap(values, "httpProxy"),
}
if nested := recordFromAny(values["networkProxy"]); nested != nil {
if mode := stringFromMap(nested, "mode"); mode != "" {
config.Mode = mode
}
if proxy := stringFromMap(nested, "httpProxy"); proxy != "" {
config.HTTPProxy = proxy
}
}
return config
}
func Normalize(input Config) (Config, error) {
mode, err := normalizeMode(input.Mode)
if err != nil {
return Config{}, err
}
config := Config{Mode: mode}
if mode != ModeCustom {
return config, nil
}
normalized, _, err := ParseHTTPProxy(input.HTTPProxy)
if err != nil {
return Config{}, err
}
config.HTTPProxy = normalized
return config, nil
}
func NormalizePlatformConfig(values map[string]any) (map[string]any, error) {
next := map[string]any{}
for key, value := range values {
next[key] = value
}
config, err := Normalize(FromPlatformConfig(values))
if err != nil {
return nil, err
}
networkProxy := map[string]any{"mode": config.Mode}
if config.Mode == ModeCustom {
networkProxy["httpProxy"] = config.HTTPProxy
}
next["networkProxy"] = networkProxy
delete(next, "proxyMode")
delete(next, "httpProxy")
return next, nil
}
func ParseHTTPProxy(raw string) (string, *url.URL, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil, fmt.Errorf("http proxy is required")
}
if !strings.Contains(trimmed, "://") {
trimmed = "http://" + trimmed
}
parsed, err := url.Parse(trimmed)
if err != nil {
return "", nil, fmt.Errorf("invalid http proxy: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", nil, fmt.Errorf("http proxy scheme must be http or https")
}
if parsed.Host == "" {
return "", nil, fmt.Errorf("http proxy host is required")
}
return parsed.String(), parsed, nil
}
func normalizeMode(value string) (string, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "none", "off", "disabled", "disable":
return ModeNone, nil
case "global", "system", "env", "environment":
return ModeGlobal, nil
case "custom":
return ModeCustom, nil
default:
return "", fmt.Errorf("unsupported proxy mode %q", value)
}
}
func recordFromAny(value any) map[string]any {
record, _ := value.(map[string]any)
return record
}
func stringFromMap(values map[string]any, key string) string {
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
+73
View File
@@ -0,0 +1,73 @@
package runner
import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/netproxy"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
type httpClientCache struct {
none *http.Client
global *http.Client
mu sync.Mutex
custom map[string]*http.Client
}
func newHTTPClientCache() *httpClientCache {
return &httpClientCache{
none: newHTTPClient(nil),
global: newHTTPClient(http.ProxyFromEnvironment),
custom: map[string]*http.Client{},
}
}
func (s *Service) httpClientForCandidate(candidate store.RuntimeModelCandidate, simulated bool) (*http.Client, error) {
if simulated {
return s.httpClients.none, nil
}
config, err := netproxy.Normalize(netproxy.FromPlatformConfig(candidate.PlatformConfig))
if err != nil {
return nil, &clients.ClientError{Code: "invalid_proxy", Message: err.Error(), Retryable: false}
}
switch config.Mode {
case netproxy.ModeGlobal:
if strings.TrimSpace(s.cfg.GlobalHTTPProxy) != "" {
return s.httpClients.customClient(s.cfg.GlobalHTTPProxy)
}
return s.httpClients.global, nil
case netproxy.ModeCustom:
return s.httpClients.customClient(config.HTTPProxy)
default:
return s.httpClients.none, nil
}
}
func (c *httpClientCache) customClient(rawProxy string) (*http.Client, error) {
normalized, proxyURL, err := netproxy.ParseHTTPProxy(rawProxy)
if err != nil {
return nil, &clients.ClientError{Code: "invalid_proxy", Message: err.Error(), Retryable: false}
}
c.mu.Lock()
defer c.mu.Unlock()
if client := c.custom[normalized]; client != nil {
return client, nil
}
client := newHTTPClient(http.ProxyURL(proxyURL))
c.custom[normalized] = client
return client, nil
}
func newHTTPClient(proxy func(*http.Request) (*url.URL, error)) *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = proxy
return &http.Client{
Timeout: 120 * time.Second,
Transport: transport,
}
}
+116
View File
@@ -0,0 +1,116 @@
package runner
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestPlatformProxyModeNoneIgnoresEnvironmentProxy(t *testing.T) {
var proxyHits int
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyHits++
w.WriteHeader(http.StatusTeapot)
}))
defer proxy.Close()
t.Setenv("HTTP_PROXY", proxy.URL)
t.Setenv("http_proxy", proxy.URL)
var targetHits int
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits++
_, _ = w.Write([]byte("ok"))
}))
defer target.Close()
client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "none"}},
}, false)
if err != nil {
t.Fatalf("build http client: %v", err)
}
resp, err := client.Get(target.URL)
if err != nil {
t.Fatalf("get target: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK || targetHits != 1 || proxyHits != 0 {
t.Fatalf("unexpected status=%d targetHits=%d proxyHits=%d", resp.StatusCode, targetHits, proxyHits)
}
}
func TestPlatformProxyModeCustomUsesConfiguredHTTPProxy(t *testing.T) {
var targetHits int
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits++
_, _ = w.Write([]byte("target"))
}))
defer target.Close()
var proxyHits int
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyHits++
if r.URL.String() != target.URL && r.URL.String() != target.URL+"/" {
t.Fatalf("proxy received unexpected target URL %q", r.URL.String())
}
_, _ = w.Write([]byte("proxied"))
}))
defer proxy.Close()
client, err := testProxyService(config.Config{}).httpClientForCandidate(store.RuntimeModelCandidate{
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "custom", "httpProxy": proxy.URL}},
}, false)
if err != nil {
t.Fatalf("build http client: %v", err)
}
resp, err := client.Get(target.URL)
if err != nil {
t.Fatalf("get target through proxy: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 {
t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits)
}
}
func TestPlatformProxyModeGlobalUsesConfiguredGlobalHTTPProxy(t *testing.T) {
var targetHits int
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetHits++
_, _ = w.Write([]byte("target"))
}))
defer target.Close()
var proxyHits int
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
proxyHits++
_, _ = w.Write([]byte("proxied"))
}))
defer proxy.Close()
client, err := testProxyService(config.Config{GlobalHTTPProxy: proxy.URL}).httpClientForCandidate(store.RuntimeModelCandidate{
PlatformConfig: map[string]any{"networkProxy": map[string]any{"mode": "global"}},
}, false)
if err != nil {
t.Fatalf("build http client: %v", err)
}
resp, err := client.Get(target.URL)
if err != nil {
t.Fatalf("get target through global proxy: %v", err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK || proxyHits != 1 || targetHits != 0 {
t.Fatalf("unexpected status=%d proxyHits=%d targetHits=%d", resp.StatusCode, proxyHits, targetHits)
}
}
func testProxyService(cfg config.Config) *Service {
return &Service{cfg: cfg, httpClients: newHTTPClientCache()}
}
+23 -9
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
@@ -15,10 +14,11 @@ import (
)
type Service struct {
cfg config.Config
store *store.Store
logger *slog.Logger
clients map[string]clients.Client
cfg config.Config
store *store.Store
logger *slog.Logger
clients map[string]clients.Client
httpClients *httpClientCache
}
type Result struct {
@@ -27,17 +27,18 @@ type Result struct {
}
func New(cfg config.Config, db *store.Store, logger *slog.Logger) *Service {
httpClient := &http.Client{Timeout: 120 * time.Second}
httpClients := newHTTPClientCache()
return &Service{
cfg: cfg,
store: db,
logger: logger,
clients: map[string]clients.Client{
"openai": clients.OpenAIClient{HTTPClient: httpClient},
"gemini": clients.GeminiClient{HTTPClient: httpClient},
"volces": clients.VolcesClient{HTTPClient: httpClient},
"openai": clients.OpenAIClient{HTTPClient: httpClients.none},
"gemini": clients.GeminiClient{HTTPClient: httpClients.none},
"volces": clients.VolcesClient{HTTPClient: httpClients.none},
"simulation": clients.SimulationClient{},
},
httpClients: httpClients,
}
}
@@ -200,6 +201,18 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
}
defer s.store.RecordClientRelease(context.WithoutCancel(ctx), candidate.ClientID, "")
requestHTTPClient, err := s.httpClientForCandidate(candidate, simulated)
if err != nil {
_ = s.store.FinishTaskAttempt(ctx, store.FinishTaskAttemptInput{
AttemptID: attemptID,
Status: "failed",
Retryable: false,
Metrics: mergeMetrics(attemptMetrics(candidate, attemptNo, simulated), map[string]any{"error": err.Error(), "retryable": false}),
ErrorCode: clients.ErrorCode(err),
ErrorMessage: err.Error(),
})
return clients.Response{}, err
}
client := s.clientFor(candidate, simulated)
callStartedAt := time.Now()
response, err := client.Run(ctx, clients.Request{
@@ -208,6 +221,7 @@ func (s *Service) runCandidate(ctx context.Context, task store.GatewayTask, user
Model: task.Model,
Body: body,
Candidate: candidate,
HTTPClient: requestHTTPClient,
Stream: boolFromMap(body, "stream"),
StreamDelta: onDelta,
})