Skip to content

Commit c0ab7fd

Browse files
committed
🤖 fix(proxy): add provider-scoped proxy handler
What: - add a dedicated ProviderProxyHandler for /provider/{id}/... requests - keep ProjectProxyHandler on its original project-only constructor and flow, and route provider scope through separate wiring - align ProviderProxyHandler structure and main/server wiring with the existing project-handler flow more closely - preserve provider-scoped router/header safety fixes, distinguish provider load failures from not-found responses, and harden the provider Playwright mock startup path - add provider-focused handler/router/e2e/playwright coverage Why: - keep provider scope independent from the existing project proxy implementation - remove the earlier PR diff that touched the original ProjectProxyHandler constructor line - make provider-scoped handling read like a parallel sibling to the project-scoped handler instead of an unrelated implementation style - address the latest review feedback with only the minimal required changes Tests: - go test ./internal/handler ./internal/router ./tests/e2e/... (pass) - MAXX_E2E_BASE_URL=http://127.0.0.1:9880 MAXX_E2E_USERNAME=admin MAXX_E2E_PASSWORD=test123 pnpm --dir tests/e2e/playwright test:provider-proxy-route (pass)
1 parent 1cfd130 commit c0ab7fd

File tree

17 files changed

+783
-72
lines changed

17 files changed

+783
-72
lines changed

cmd/maxx/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,7 @@ func main() {
376376
// Use already-created cached project repository for project proxy handler
377377
modelsHandler := handler.NewModelsHandler(responseModelRepo, cachedProviderRepo, cachedModelMappingRepo)
378378
projectProxyHandler := handler.NewProjectProxyHandler(proxyHandler, modelsHandler, cachedProjectRepo)
379+
providerProxyHandler := handler.NewProviderProxyHandler(proxyHandler, modelsHandler, cachedProviderRepo)
379380

380381
// Setup routes
381382
mux := http.NewServeMux()
@@ -422,7 +423,7 @@ func main() {
422423

423424
// Serve static files (Web UI) with project proxy support - must be last (default route)
424425
staticHandler := handler.NewStaticHandler()
425-
combinedHandler := handler.NewCombinedHandler(projectProxyHandler, staticHandler)
426+
combinedHandler := handler.NewCombinedHandler(projectProxyHandler, providerProxyHandler, staticHandler)
426427
mux.Handle("/", combinedHandler)
427428

428429
// Wrap with logging middleware
@@ -531,6 +532,7 @@ func main() {
531532
log.Printf(" Codex: http://localhost%s/v1/responses", *addr)
532533
log.Printf(" Gemini: http://localhost%s/v1beta/models/{model}:generateContent", *addr)
533534
log.Printf("Project proxy: http://localhost%s/project/{project-slug}/v1/messages (etc.)", *addr)
535+
log.Printf("Provider proxy: http://localhost%s/provider/{provider-id}/v1/messages (etc.)", *addr)
534536

535537
go func() {
536538
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {

internal/core/database.go

Lines changed: 44 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -73,27 +73,28 @@ type DatabaseRepos struct {
7373

7474
// ServerComponents 包含服务器运行所需的所有组件
7575
type ServerComponents struct {
76-
Router *router.Router
77-
WebSocketHub *handler.WebSocketHub
78-
WailsBroadcaster *event.WailsBroadcaster
79-
Executor *executor.Executor
80-
ClientAdapter *client.Adapter
81-
AdminService *service.AdminService
82-
ProxyHandler *handler.ProxyHandler
83-
ModelsHandler *handler.ModelsHandler
84-
AdminHandler *handler.AdminHandler
85-
AntigravityHandler *handler.AntigravityHandler
86-
KiroHandler *handler.KiroHandler
87-
CodexHandler *handler.CodexHandler
88-
CodexOAuthServer *CodexOAuthServer
89-
ClaudeHandler *handler.ClaudeHandler
90-
ClaudeOAuthServer *ClaudeOAuthServer
91-
ProjectProxyHandler *handler.ProjectProxyHandler
92-
RequestTracker *RequestTracker
93-
PprofManager *PprofManager
94-
AuthMiddleware *handler.AuthMiddleware
95-
AuthHandler *handler.AuthHandler
96-
BackupService *service.BackupService
76+
Router *router.Router
77+
WebSocketHub *handler.WebSocketHub
78+
WailsBroadcaster *event.WailsBroadcaster
79+
Executor *executor.Executor
80+
ClientAdapter *client.Adapter
81+
AdminService *service.AdminService
82+
ProxyHandler *handler.ProxyHandler
83+
ModelsHandler *handler.ModelsHandler
84+
AdminHandler *handler.AdminHandler
85+
AntigravityHandler *handler.AntigravityHandler
86+
KiroHandler *handler.KiroHandler
87+
CodexHandler *handler.CodexHandler
88+
CodexOAuthServer *CodexOAuthServer
89+
ClaudeHandler *handler.ClaudeHandler
90+
ClaudeOAuthServer *ClaudeOAuthServer
91+
ProjectProxyHandler *handler.ProjectProxyHandler
92+
ProviderProxyHandler *handler.ProviderProxyHandler
93+
RequestTracker *RequestTracker
94+
PprofManager *PprofManager
95+
AuthMiddleware *handler.AuthMiddleware
96+
AuthHandler *handler.AuthHandler
97+
BackupService *service.BackupService
9798
}
9899

99100
// InitializeDatabase 初始化数据库和所有仓库
@@ -415,27 +416,28 @@ func InitializeServerComponents(
415416
proxyHandler.SetRequestTracker(requestTracker)
416417

417418
components := &ServerComponents{
418-
Router: r,
419-
WebSocketHub: wsHub,
420-
WailsBroadcaster: wailsBroadcaster,
421-
Executor: exec,
422-
ClientAdapter: clientAdapter,
423-
AdminService: adminService,
424-
ProxyHandler: proxyHandler,
425-
ModelsHandler: modelsHandler,
426-
AdminHandler: adminHandler,
427-
AntigravityHandler: antigravityHandler,
428-
KiroHandler: kiroHandler,
429-
CodexHandler: codexHandler,
430-
CodexOAuthServer: codexOAuthServer,
431-
ClaudeHandler: claudeHandler,
432-
ClaudeOAuthServer: claudeOAuthServer,
433-
ProjectProxyHandler: projectProxyHandler,
434-
RequestTracker: requestTracker,
435-
PprofManager: pprofMgr,
436-
AuthMiddleware: authMiddleware,
437-
AuthHandler: authHandler,
438-
BackupService: backupService,
419+
Router: r,
420+
WebSocketHub: wsHub,
421+
WailsBroadcaster: wailsBroadcaster,
422+
Executor: exec,
423+
ClientAdapter: clientAdapter,
424+
AdminService: adminService,
425+
ProxyHandler: proxyHandler,
426+
ModelsHandler: modelsHandler,
427+
AdminHandler: adminHandler,
428+
AntigravityHandler: antigravityHandler,
429+
KiroHandler: kiroHandler,
430+
CodexHandler: codexHandler,
431+
CodexOAuthServer: codexOAuthServer,
432+
ClaudeHandler: claudeHandler,
433+
ClaudeOAuthServer: claudeOAuthServer,
434+
ProjectProxyHandler: projectProxyHandler,
435+
ProviderProxyHandler: handler.NewProviderProxyHandler(proxyHandler, modelsHandler, repos.CachedProviderRepo),
436+
RequestTracker: requestTracker,
437+
PprofManager: pprofMgr,
438+
AuthMiddleware: authMiddleware,
439+
AuthHandler: authHandler,
440+
BackupService: backupService,
439441
}
440442

441443
log.Printf("[Core] Server components initialized successfully")

internal/core/server.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,11 +106,12 @@ func (s *ManagedServer) setupRoutes() *http.ServeMux {
106106

107107
if s.config.ServeStatic {
108108
staticHandler := handler.NewStaticHandler()
109-
combinedHandler := handler.NewCombinedHandler(components.ProjectProxyHandler, staticHandler)
109+
combinedHandler := handler.NewCombinedHandler(components.ProjectProxyHandler, components.ProviderProxyHandler, staticHandler)
110110
mux.Handle("/", combinedHandler)
111111
log.Printf("[Server] Static file serving enabled")
112112
} else {
113-
mux.Handle("/", components.ProjectProxyHandler)
113+
combinedHandler := handler.NewCombinedHandler(components.ProjectProxyHandler, components.ProviderProxyHandler, http.NotFoundHandler())
114+
mux.Handle("/", combinedHandler)
114115
log.Printf("[Server] Static file serving disabled (Wails mode)")
115116
}
116117

internal/executor/flow_state.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type execState struct {
1919
tenantID uint64
2020
clientType domain.ClientType
2121
projectID uint64
22+
providerID uint64
2223
sessionID string
2324
requestModel string
2425
isStream bool

internal/executor/middleware_ingress.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ func (e *Executor) ingress(c *flow.Ctx) {
3636
state.projectID = pid
3737
}
3838
}
39+
if v, ok := c.Get(flow.KeyProviderID); ok {
40+
if pid, ok := v.(uint64); ok {
41+
state.providerID = pid
42+
}
43+
}
3944
if v, ok := c.Get(flow.KeySessionID); ok {
4045
if sid, ok := v.(string); ok {
4146
state.sessionID = sid
@@ -97,7 +102,7 @@ func (e *Executor) ingress(c *flow.Ctx) {
97102
IsStream: state.isStream,
98103
Status: "PENDING",
99104
APITokenID: state.apiTokenID,
100-
DevMode: state.apiTokenDevMode,
105+
DevMode: state.apiTokenDevMode,
101106
}
102107

103108
clearDetail := e.shouldClearRequestDetailFor(state)

internal/executor/middleware_route_match.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ func (e *Executor) routeMatch(c *flow.Ctx) {
2424
TenantID: state.tenantID,
2525
ClientType: state.clientType,
2626
ProjectID: state.projectID,
27+
ProviderID: state.providerID,
2728
RequestModel: state.requestModel,
2829
APITokenID: state.apiTokenID,
2930
})

internal/flow/keys.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const (
1010
KeyOriginalClientType = "original_client_type"
1111
KeySessionID = "session_id"
1212
KeyProjectID = "project_id"
13+
KeyProviderID = "provider_id"
1314
KeyRequestModel = "request_model"
1415
KeyMappedModel = "mapped_model"
1516
KeyRequestBody = "request_body"

internal/handler/provider_proxy.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package handler
2+
3+
import (
4+
"context"
5+
"log"
6+
"net/http"
7+
"strconv"
8+
"strings"
9+
10+
maxxctx "github.com/awsl-project/maxx/internal/context"
11+
"github.com/awsl-project/maxx/internal/repository"
12+
)
13+
14+
// ProviderProxyHandler wraps ProxyHandler to handle provider-prefixed proxy requests
15+
// like /provider/{id}/v1/messages, /provider/{id}/v1/chat/completions, etc.
16+
type ProviderProxyHandler struct {
17+
proxyHandler *ProxyHandler
18+
modelsHandler *ModelsHandler
19+
providerRepo repository.ProviderRepository
20+
}
21+
22+
// NewProviderProxyHandler creates a new provider proxy handler
23+
func NewProviderProxyHandler(
24+
proxyHandler *ProxyHandler,
25+
modelsHandler *ModelsHandler,
26+
providerRepo repository.ProviderRepository,
27+
) *ProviderProxyHandler {
28+
return &ProviderProxyHandler{
29+
proxyHandler: proxyHandler,
30+
modelsHandler: modelsHandler,
31+
providerRepo: providerRepo,
32+
}
33+
}
34+
35+
// ServeHTTP handles provider-prefixed proxy requests
36+
func (h *ProviderProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
37+
// Parse the path to extract provider ID and API path
38+
// Expected format: /provider/{id}/v1/messages, /provider/{id}/v1/chat/completions, etc.
39+
providerID, apiPath, ok := h.parseProviderPath(r.URL.Path)
40+
if !ok {
41+
writeError(w, http.StatusNotFound, "invalid provider proxy path")
42+
return
43+
}
44+
45+
// Parse and validate provider ID
46+
providerIDNum, err := strconv.ParseUint(providerID, 10, 64)
47+
if err != nil || providerIDNum == 0 {
48+
writeError(w, http.StatusBadRequest, "invalid provider id")
49+
return
50+
}
51+
52+
// Look up provider by ID
53+
tenantID := maxxctx.GetTenantID(r.Context())
54+
provider, err := h.providerRepo.GetByID(tenantID, providerIDNum)
55+
if err != nil {
56+
log.Printf("[ProviderProxy] failed to load provider tenant=%d id=%d: %v", tenantID, providerIDNum, err)
57+
writeError(w, http.StatusInternalServerError, "internal server error")
58+
return
59+
}
60+
if provider == nil {
61+
log.Printf("[ProviderProxy] Provider not found for id: %s", providerID)
62+
writeError(w, http.StatusNotFound, "provider not found")
63+
return
64+
}
65+
66+
log.Printf("[ProviderProxy] Routing request through provider: %s (ID: %d)", provider.Name, provider.ID)
67+
68+
r = r.WithContext(context.WithValue(r.Context(), providerIDContextKey{}, provider.ID))
69+
70+
// Rewrite the URL path to the standard API path
71+
r.URL.Path = apiPath
72+
73+
// Forward to the appropriate handler
74+
if apiPath == "/v1/models" {
75+
h.modelsHandler.ServeHTTP(w, r)
76+
return
77+
}
78+
h.proxyHandler.ServeHTTP(w, r)
79+
}
80+
81+
// parseProviderPath extracts the provider ID and API path from a provider-prefixed URL
82+
// Input: /provider/1/v1/messages
83+
// Output: ("1", "/v1/messages", true)
84+
func (h *ProviderProxyHandler) parseProviderPath(path string) (providerID, apiPath string, ok bool) {
85+
// Must start with /provider/
86+
if !strings.HasPrefix(path, "/provider/") {
87+
return "", "", false
88+
}
89+
90+
// Remove /provider/ prefix and split
91+
path = strings.TrimPrefix(path, "/provider/")
92+
parts := strings.SplitN(path, "/", 2)
93+
94+
if len(parts) < 2 {
95+
return "", "", false
96+
}
97+
98+
providerID = strings.TrimSpace(parts[0])
99+
if providerID == "" {
100+
return "", "", false
101+
}
102+
103+
apiPath = "/" + parts[1]
104+
105+
// Validate this looks like a valid API path
106+
if !isValidProviderAPIPath(apiPath) {
107+
return "", "", false
108+
}
109+
110+
return providerID, apiPath, true
111+
}
112+
113+
// isValidProviderAPIPath checks if the path is a known proxy API endpoint
114+
func isValidProviderAPIPath(path string) bool {
115+
// Claude API
116+
if path == "/v1/messages" || strings.HasPrefix(path, "/v1/messages/") {
117+
return true
118+
}
119+
// OpenAI API
120+
if path == "/v1/chat/completions" || strings.HasPrefix(path, "/v1/chat/completions/") {
121+
return true
122+
}
123+
// Codex API
124+
if path == "/responses" || strings.HasPrefix(path, "/responses/") {
125+
return true
126+
}
127+
if path == "/v1/responses" || strings.HasPrefix(path, "/v1/responses/") {
128+
return true
129+
}
130+
// Model list API
131+
if path == "/v1/models" || strings.HasPrefix(path, "/v1/models/") {
132+
return true
133+
}
134+
// Gemini API
135+
if path == "/v1beta/models" || strings.HasPrefix(path, "/v1beta/models/") {
136+
return true
137+
}
138+
return false
139+
}

0 commit comments

Comments
 (0)