Skip to content

Commit 704a401

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 - move provider route registration to explicit /provider/ handlers so static handling does not need provider-specific branching - rewrite provider route matching as its own router path instead of mixing provider scope into the project-routing control flow - preserve provider-scoped request propagation from ProviderProxyHandler into ProxyHandler, and keep provider-focused handler/router/e2e/playwright coverage Why: - keep provider scope independent from the existing project proxy implementation - reduce follow-up churn to only the required changes for provider routing - make provider-scoped matching reviewable as a parallel path instead of a project-routing patch 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 704a401

File tree

16 files changed

+828
-100
lines changed

16 files changed

+828
-100
lines changed

cmd/maxx/main.go

Lines changed: 3 additions & 0 deletions
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()
@@ -409,6 +410,8 @@ func main() {
409410
mux.Handle("/v1/responses/", proxyHandler)
410411
// Gemini API (Google AI Studio style)
411412
mux.Handle("/v1beta/models/", proxyHandler)
413+
// Provider-scoped proxy routes
414+
mux.Handle("/provider/", providerProxyHandler)
412415

413416
// Health check
414417
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {

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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ func (s *ManagedServer) setupRoutes() *http.ServeMux {
103103
})
104104

105105
mux.HandleFunc("/ws", components.WebSocketHub.HandleWebSocket)
106+
mux.Handle("/provider/", components.ProviderProxyHandler)
106107

107108
if s.config.ServeStatic {
108109
staticHandler := handler.NewStaticHandler()

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: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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 isProviderProxyPath(urlPath string) bool {
115+
return strings.HasPrefix(urlPath, "/provider/")
116+
}
117+
118+
func isValidProviderAPIPath(path string) bool {
119+
// Claude API
120+
if path == "/v1/messages" || strings.HasPrefix(path, "/v1/messages/") {
121+
return true
122+
}
123+
// OpenAI API
124+
if path == "/v1/chat/completions" || strings.HasPrefix(path, "/v1/chat/completions/") {
125+
return true
126+
}
127+
// Codex API
128+
if path == "/responses" || strings.HasPrefix(path, "/responses/") {
129+
return true
130+
}
131+
if path == "/v1/responses" || strings.HasPrefix(path, "/v1/responses/") {
132+
return true
133+
}
134+
// Model list API
135+
if path == "/v1/models" || strings.HasPrefix(path, "/v1/models/") {
136+
return true
137+
}
138+
// Gemini API
139+
if path == "/v1beta/models" || strings.HasPrefix(path, "/v1beta/models/") {
140+
return true
141+
}
142+
return false
143+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package handler
2+
3+
import "testing"
4+
5+
func TestParseProviderPath(t *testing.T) {
6+
h := &ProviderProxyHandler{}
7+
providerID, apiPath, ok := h.parseProviderPath("/provider/1/v1/chat/completions")
8+
if !ok {
9+
t.Fatal("expected provider path to parse")
10+
}
11+
if providerID != "1" {
12+
t.Fatalf("providerID = %q, want 1", providerID)
13+
}
14+
if apiPath != "/v1/chat/completions" {
15+
t.Fatalf("apiPath = %q, want /v1/chat/completions", apiPath)
16+
}
17+
}
18+
19+
func TestParseProviderPath_TrimsProviderID(t *testing.T) {
20+
h := &ProviderProxyHandler{}
21+
providerID, apiPath, ok := h.parseProviderPath("/provider/ 1 /v1/messages")
22+
if !ok {
23+
t.Fatal("expected provider path to parse")
24+
}
25+
if providerID != "1" {
26+
t.Fatalf("providerID = %q, want 1", providerID)
27+
}
28+
if apiPath != "/v1/messages" {
29+
t.Fatalf("apiPath = %q, want /v1/messages", apiPath)
30+
}
31+
}
32+
33+
func TestIsValidProviderAPIPath_AllowsExactAndSubpathsOnly(t *testing.T) {
34+
valid := []string{
35+
"/v1/messages",
36+
"/v1/messages/stream",
37+
"/v1/chat/completions",
38+
"/v1/chat/completions/extra",
39+
"/responses",
40+
"/responses/items",
41+
"/v1/responses",
42+
"/v1/responses/abc",
43+
"/v1/models",
44+
"/v1/models/list",
45+
"/v1beta/models",
46+
"/v1beta/models/gemini-2.5-pro",
47+
}
48+
for _, path := range valid {
49+
if !isValidProviderAPIPath(path) {
50+
t.Fatalf("expected %q to be valid", path)
51+
}
52+
}
53+
54+
invalid := []string{
55+
"/v1/messages-debug",
56+
"/v1/chat/completionsXYZ",
57+
"/responses123",
58+
"/v1/responsesXYZ",
59+
"/v1/models-debug",
60+
"/v1beta/modelsX",
61+
}
62+
for _, path := range invalid {
63+
if isValidProviderAPIPath(path) {
64+
t.Fatalf("expected %q to be invalid", path)
65+
}
66+
}
67+
}
68+
69+
func TestIsProviderProxyPath(t *testing.T) {
70+
if !isProviderProxyPath("/provider/1/v1/messages") {
71+
t.Fatal("expected provider path to be detected")
72+
}
73+
if isProviderProxyPath("/project/demo/v1/messages") {
74+
t.Fatal("did not expect project path to be detected as provider path")
75+
}
76+
if isProviderProxyPath("/providers") {
77+
t.Fatal("did not expect regular web route to be detected")
78+
}
79+
}

0 commit comments

Comments
 (0)