diff --git a/docs/2026-06-06-V3-1-GATEWAY-PLAN.md b/docs/2026-06-06-V3-1-GATEWAY-PLAN.md new file mode 100644 index 00000000..cf1930b9 --- /dev/null +++ b/docs/2026-06-06-V3-1-GATEWAY-PLAN.md @@ -0,0 +1,46 @@ +# V3-1 网关治理执行计划 + +> 目标:在 CRM 新增公开 `/v1/chat/completions` 入口,用户在入口用 portal key 认证,CRM 校验 `admin_status` 后再转发到宿主,实现 0 延迟的 pause/resume 治理阻断。 + +## 改动概览 + +| 文件 | 改动 | +| ------------------------------------------------ | ------------------------------------------------------------------------------- | +| `internal/app/http_api.go` | 新增 `POST /v1/chat/completions` 公开路由(无 admin auth),调用治理校验 + 转发 | +| `internal/app/route_proxy_api.go` | 新增 user-key 认证和治理校验的入口函数 | +| `internal/app/openaicompat_handler.go`(新文件) | OpenAI-compatible 请求解析 + user-key auth 提取 | +| `deploy/tksea-portal/nginx/` 或部署脚本 | 加 `location /v1 { proxy_pass http://127.0.0.1:18190; }` | + +## 执行分步 + +### Step 1: 新增公开 `/v1/chat/completions` 路由 + +在 `internal/app/http_api.go` 中,在 `NewAPIHandlerWithAuth` 函数注册路由处(在 admin auth 区块之外)添加: + +```go +mux.Handle("POST /v1/chat/completions", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlePublicChatCompletions(w, r, actions.UserKeyHandler) +})) +``` + +注意必须放在 `requireAdminAccess` 包的外面,这样用户不需要 admin token。 + +### Step 2: 实现 `handlePublicChatCompletions` + +```go +func handlePublicChatCompletions(w http.ResponseWriter, r *http.Request, ukh *UserKeyHandler) { + // 1. 提取 Bearer token → user_key_id + // 2. 查 user_keys 表,校验 admin_status != "paused" + // 3. 获取 key 的 subject_id 和 logical_group_id + // 4. 调用 buildRouteChatCompletionsAction 或 buildProxyRouteChatCompletionsAction 转发 + // - 传入 gateway_api_key = managed key(来自 user_key 的 managed subscription) + // - 传入 subject_id = user_key 的 owner_subject_id + // - 传入 model = 请求体中的 model + // - 传入 messages = 请求体中的 messages + // 5. 返回 OpenAI-compatible chat completion 响应形状 +} +``` + +### Step 3: deploy 脚本更新 + +更新 nginx 配置,让 `location /v1` 指向 CRM 的 18190。 diff --git a/internal/app/app.go b/internal/app/app.go index 22214483..46ebfe29 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -17,7 +17,7 @@ type Server struct { func NewServer(listenAddr string, handler http.Handler, listenerFactory ListenerFactory) *Server { if handler == nil { - handler = NewAPIHandler("", ActionSet{}) + handler = NewAPIHandler("", ActionSet{}, "") } server := &Server{ server: &http.Server{ diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0b40ace6..4798cb16 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -27,7 +27,7 @@ import ( ) func TestServeExposesHealthz(t *testing.T) { - server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil) + server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}, ""), nil) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen() error = %v", err) @@ -66,7 +66,7 @@ func TestRunReturnsAfterContextCancellation(t *testing.T) { t.Fatalf("net.Listen() error = %v", err) } - server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) { + server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}, ""), func(string, string) (net.Listener, error) { return listener, nil }) @@ -93,7 +93,7 @@ func TestRunReturnsAfterContextCancellation(t *testing.T) { func TestRunReturnsListenError(t *testing.T) { wantErr := errors.New("listen failed") - server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), func(string, string) (net.Listener, error) { + server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}, ""), func(string, string) (net.Listener, error) { return nil, wantErr }) @@ -104,7 +104,7 @@ func TestRunReturnsListenError(t *testing.T) { } func TestServeReturnsListenerError(t *testing.T) { - server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}), nil) + server := NewServer("127.0.0.1:0", NewAPIHandler("admin-token", ActionSet{}, ""), nil) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen() error = %v", err) @@ -121,7 +121,7 @@ func TestServeReturnsListenerError(t *testing.T) { } func TestAPIRejectsMissingAdminToken(t *testing.T) { - handler := NewAPIHandler("secret-token", ActionSet{}) + handler := NewAPIHandler("secret-token", ActionSet{}, "") request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/pack.zip"}, "") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusUnauthorized) @@ -141,7 +141,7 @@ func TestAPIAdminSessionLoginSetsCookieAndAuthorizesSubsequentRequest(t *testing ListPacks: func(context.Context) ([]PackInfo, error) { return []PackInfo{{PackID: "openai-cn-pack", Version: "1.1.6"}}, nil }, - }) + }, "") loginRequest := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{ "username": "admin", @@ -177,7 +177,7 @@ func TestAPIAdminSessionRejectsInvalidPassword(t *testing.T) { Token: "secret-token", Username: "admin", Password: "pass-123", - }, ActionSet{}) + }, ActionSet{}, "") request := httptestRequest(t, http.MethodPost, "/api/admin/session/login", map[string]any{ "username": "admin", "password": "wrong", @@ -192,7 +192,7 @@ func TestAPIAdminSessionLogoutClearsCookie(t *testing.T) { Token: "secret-token", Username: "admin", Password: "pass-123", - }, ActionSet{}) + }, ActionSet{}, "") request := httptestRequest(t, http.MethodPost, "/api/admin/session/logout", nil, "") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusNoContent) @@ -219,7 +219,7 @@ func TestAPIAdminSessionMeReportsAuthenticationState(t *testing.T) { Now: func() time.Time { return now }, - }, ActionSet{}) + }, ActionSet{}, "") request := httptestRequest(t, http.MethodGet, "/api/admin/session", nil, "") response := httptestRecorder(handler, request) @@ -253,7 +253,7 @@ func TestAPIInstallPackReturnsSummary(t *testing.T) { Providers: []sqlite.Provider{{ProviderID: "deepseek", DisplayName: "DeepSeek"}}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -275,7 +275,7 @@ func TestAPIPreviewProviderReturnsSummary(t *testing.T) { }, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1", "k2"}, "mode": "partial"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -301,7 +301,7 @@ func TestAPICreateProviderDraftReturnsCreated(t *testing.T) { Manifest: map[string]any{"provider_id": req.ProviderID}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/provider-drafts", map[string]any{ "pack_id": "openai-cn-pack", "provider_id": "openai-zhongzhuan", @@ -332,7 +332,7 @@ func TestAPIListProviderDraftsReturnsCollection(t *testing.T) { SourceHostID: "remote43-current-host", }}, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/provider-drafts?pack_id=openai-cn-pack", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -369,7 +369,7 @@ func TestAPIGetProviderDraftReturnsItem(t *testing.T) { SourceHostID: "remote43-current-host", }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/provider-drafts/draft_001", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -393,7 +393,7 @@ func TestAPIUpdateProviderDraftReturnsUpdatedItem(t *testing.T) { SourceHostID: req.SourceHostID, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPut, "/api/provider-drafts/draft_001", map[string]any{ "pack_id": "openai-cn-pack", "provider_id": "openai-zhongzhuan", @@ -414,7 +414,7 @@ func TestAPIDeleteProviderDraftReturnsNoContent(t *testing.T) { } return nil }, - }) + }, "") request := httptestRequest(t, http.MethodDelete, "/api/provider-drafts/draft_001", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusNoContent) @@ -439,7 +439,7 @@ func TestAPIPublishProviderDraftReturnsSummary(t *testing.T) { RepoRoot: "/srv/sub2api-cn-relay-manager", }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/provider-drafts/draft_001/publish", map[string]any{ "commit_message": "feat(pack): publish provider draft openai-zhongzhuan", }, "secret-token") @@ -463,7 +463,7 @@ func TestAPIImportProviderReturnsConflictWithBatchStatus(t *testing.T) { }, }, errors.New("strict import failed") }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "keys": []string{"k1"}, "mode": "strict", "access_mode": "self_service", "access_api_key": "user-key"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusConflict) @@ -479,7 +479,7 @@ func TestAPIBatchDetailReturnsSummary(t *testing.T) { Items: []sqlite.ImportBatchItem{{ID: 1, KeyFingerprint: "sha256:abc", AccountStatus: "passed"}}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/import-batches/7", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -510,7 +510,7 @@ func TestAPIProviderStatusReturnsSummary(t *testing.T) { ReconcileRuns: []sqlite.ReconcileRun{{}}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/status?pack_id=openai-cn-pack", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -534,7 +534,7 @@ func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) { AccessClosures: []sqlite.AccessClosureRecord{{ID: 2, ClosureType: provision.AccessModeSelfService, Status: provision.AccessStatusSelfServiceReady, DetailsJSON: `{"ok":true}`}}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/access/status", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -561,7 +561,7 @@ func TestAPIListPackProvidersReturnsProviderMetadata(t *testing.T) { SupportedModels: []string{"deepseek-chat", "deepseek-reasoner"}, }}, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/packs/openai-cn-pack/providers", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -605,7 +605,7 @@ func TestAPIProviderResourcesReturnsSummary(t *testing.T) { ReconcileRuns: []sqlite.ReconcileRun{{ID: 3, Status: "active", SummaryJSON: `{"missing_count":0}`}}, }, nil }, - }) + }, "") request := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/resources", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -627,7 +627,7 @@ func TestAPIRollbackProviderReturnsSummary(t *testing.T) { RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) { return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -643,7 +643,7 @@ func TestAPIReconcileProviderReturnsSummary(t *testing.T) { } return reconcile.Result{BatchID: 7, Status: "drifted", MissingCount: 1, ExtraCount: 2, StaleNoiseCount: 3, ProbeFailureCount: 1, AccessStatus: provision.AccessStatusBroken, Summary: map[string]any{"probe_failures": 1, "stale_noise_count": 3}}, nil }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/reconcile", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip", "access_api_key": "user-key"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusOK) @@ -925,7 +925,7 @@ func TestFindProvider(t *testing.T) { } func TestAPIRequiresConfiguredAdminToken(t *testing.T) { - handler := NewAPIHandler("", ActionSet{}) + handler := NewAPIHandler("", ActionSet{}, "") request := httptestRequest(t, http.MethodPost, "/api/packs/install", map[string]any{"host_base_url": "https://sub2api.example.com"}, "any-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusInternalServerError) @@ -936,7 +936,7 @@ func TestAPIBatchDetailRejectsInvalidBatchID(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{BatchDetail: func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error) { t.Fatal("BatchDetail should not be called for invalid batch id") return provision.BatchDetailResult{}, nil - }}) + }}, "") request := httptestRequest(t, http.MethodGet, "/api/import-batches/not-a-number", nil, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusBadRequest) @@ -948,7 +948,7 @@ func TestAPIInstallPackRejectsInvalidJSON(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{InstallPack: func(context.Context, InstallPackRequest) (provision.PackInstallResult, error) { t.Fatal("InstallPack should not be called for invalid JSON") return provision.PackInstallResult{}, nil - }}) + }}, "") request, err := http.NewRequest(http.MethodPost, "/api/packs/install", strings.NewReader(`{"host_base_url":"https://sub2api.example.com","unknown":true}`)) if err != nil { t.Fatalf("http.NewRequest() error = %v", err) @@ -965,7 +965,7 @@ func TestAPIImportProviderReturnsClassifiedErrorWithoutBatch(t *testing.T) { ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) { return provision.RuntimeImportResult{}, errors.New("pack path is required") }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusBadRequest) @@ -978,7 +978,7 @@ func TestAPIPreviewProviderReturnsUpstreamError(t *testing.T) { PreviewProvider: func(context.Context, PreviewProviderRequest) (provision.PreviewReport, error) { return provision.PreviewReport{}, &sub2api.HTTPError{Method: http.MethodPost, Path: "/preview", StatusCode: http.StatusTooManyRequests, Body: "rate limited"} }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/preview-import", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusBadGateway) @@ -991,7 +991,7 @@ func TestAPIRollbackProviderReturnsConfiguredError(t *testing.T) { RollbackProvider: func(context.Context, RollbackProviderRequest) (provision.RollbackReport, error) { return provision.RollbackReport{}, &httpError{StatusCode: http.StatusGone, Code: "rolled_back", Message: "already removed"} }, - }) + }, "") request := httptestRequest(t, http.MethodPost, "/api/providers/deepseek/rollback", map[string]any{"host_base_url": "https://sub2api.example.com", "pack_path": "/tmp/openai-pack.zip"}, "secret-token") response := httptestRecorder(handler, request) assertStatusCode(t, response, http.StatusGone) @@ -1002,7 +1002,7 @@ func TestAPIReconcileProviderRejectsTrailingNonObjectPayload(t *testing.T) { handler := NewAPIHandler("secret-token", ActionSet{ReconcileProvider: func(context.Context, ReconcileProviderRequest) (reconcile.Result, error) { t.Fatal("ReconcileProvider should not be called for invalid JSON") return reconcile.Result{}, nil - }}) + }}, "") request, err := http.NewRequest(http.MethodPost, "/api/providers/deepseek/reconcile", strings.NewReader(`{"host_base_url":"https://sub2api.example.com"}[]`)) if err != nil { t.Fatalf("http.NewRequest() error = %v", err) @@ -1024,7 +1024,7 @@ func TestHTTPErrorError(t *testing.T) { } func TestProviderStatusFnNil(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusInternalServerError) @@ -1032,7 +1032,7 @@ func TestProviderStatusFnNil(t *testing.T) { } func TestProviderAccessStatusFnNil(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req := httptestRequest(t, http.MethodGet, "/api/providers/x/access/status", nil, "t") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusInternalServerError) @@ -1040,7 +1040,7 @@ func TestProviderAccessStatusFnNil(t *testing.T) { } func TestProviderResourcesFnNil(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req := httptestRequest(t, http.MethodGet, "/api/providers/x/resources", nil, "t") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusInternalServerError) @@ -1052,7 +1052,7 @@ func TestProviderStatusReturnsError(t *testing.T) { GetProviderStatus: func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error) { return provision.ProviderSnapshot{}, errors.New(`provider "x" not found in pack "p"`) }, - }) + }, "") req := httptestRequest(t, http.MethodGet, "/api/providers/x/status", nil, "t") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusBadRequest) @@ -1074,7 +1074,7 @@ func TestPostHandlersFnNil(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req, _ := http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) req.Header.Set("Authorization", "Bearer t") req.Header.Set("Content-Type", "application/json") @@ -1095,7 +1095,7 @@ func TestGetHandlersFnNil(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req, _ := http.NewRequest(http.MethodGet, tt.path, nil) req.Header.Set("Authorization", "Bearer t") res := httptestRecorder(handler, req) @@ -1114,7 +1114,7 @@ func TestDeleteHandlersFnNil(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewAPIHandler("t", ActionSet{}) + handler := NewAPIHandler("t", ActionSet{}, "") req, _ := http.NewRequest(http.MethodDelete, tt.path, nil) req.Header.Set("Authorization", "Bearer t") res := httptestRecorder(handler, req) @@ -1248,7 +1248,7 @@ func TestHandlerErrorPaths(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - handler := NewAPIHandler("t", tt.actionSet) + handler := NewAPIHandler("t", tt.actionSet, "") var req *http.Request if tt.body != "" { req, _ = http.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) @@ -1323,7 +1323,7 @@ func TestProviderAccessStatusMultipleClosures(t *testing.T) { }, }, nil }, - }) + }, "") req := httptestRequest(t, http.MethodGet, "/api/providers/dp/access/status", nil, "t") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) @@ -1549,7 +1549,7 @@ func TestAPIProbeHostReturnsHostSnapshot(t *testing.T) { } return HostInfo{HostID: req.HostID, BaseURL: "https://sub2api.example.com", HostVersion: "0.1.126", Status: "supported"}, nil }, - }) + }, "") req := httptestRequest(t, http.MethodPost, "/api/hosts/prod-sub2api/probe", map[string]any{ "auth": map[string]any{"type": "bearer", "token": "probe-token"}, }, "secret-token") @@ -1568,7 +1568,7 @@ func TestAPIListProviderImportBatchesReturnsItems(t *testing.T) { } return []ImportBatchInfo{{BatchID: 7, BatchStatus: provision.BatchStatusSucceeded, AccessStatus: provision.AccessStatusSelfServiceReady}}, nil }, - }) + }, "") req := httptestRequest(t, http.MethodGet, "/api/providers/deepseek/import-batches", nil, "secret-token") res := httptestRecorder(handler, req) assertStatusCode(t, res, http.StatusOK) @@ -1589,7 +1589,7 @@ func TestAPIRollbackBatchReturnsSummary(t *testing.T) { } return provision.RollbackReport{AccountsDeleted: 2, PlansDeleted: 1, ChannelsDeleted: 1, GroupsDeleted: 1}, nil }, - }) + }, "") req := httptestRequest(t, http.MethodPost, "/api/import-batches/11/rollback", map[string]any{ "auth": map[string]any{"type": "apikey", "token": "admin-key"}, }, "secret-token") diff --git a/internal/app/batch_utils_test.go b/internal/app/batch_utils_test.go index 30cfa325..b43a7baa 100644 --- a/internal/app/batch_utils_test.go +++ b/internal/app/batch_utils_test.go @@ -51,7 +51,7 @@ func TestFirstNonEmptyString_First(t *testing.T) { } func TestFirstNonEmptyString_AllEmpty(t *testing.T) { - if firstNonEmptyString("", "", "") != "" { + if firstNonEmptyString("", "") != "" { t.Error("all empty should return empty") } } diff --git a/internal/app/http_api.go b/internal/app/http_api.go index 3b782b45..c8f9d0b8 100644 --- a/internal/app/http_api.go +++ b/internal/app/http_api.go @@ -330,11 +330,19 @@ func (e *httpError) Error() string { return e.Message } -func NewAPIHandler(adminToken string, actions ActionSet) http.Handler { - return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions) +func NewAPIHandler(adminToken string, actions ActionSet, dsn ...string) http.Handler { + dsnVal := "" + if len(dsn) > 0 { + dsnVal = dsn[0] + } + return NewAPIHandlerWithAuth(AdminAuthConfig{Token: adminToken}, actions, dsnVal) } -func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Handler { +func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet, dsn ...string) http.Handler { + sqliteDSN := "" + if len(dsn) > 0 { + sqliteDSN = dsn[0] + } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) mux.HandleFunc("GET /version", handleVersion) @@ -445,6 +453,15 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha mux.HandleFunc("POST /api/keys/{key_id}/pause", func(w http.ResponseWriter, r *http.Request) { handlePauseUserKey(w, r, ukh) }) mux.HandleFunc("POST /api/keys/{key_id}/resume", func(w http.ResponseWriter, r *http.Request) { handleResumeUserKey(w, r, ukh) }) mux.HandleFunc("DELETE /api/keys/{key_id}", func(w http.ResponseWriter, r *http.Request) { handleDeleteUserKey(w, r, ukh) }) + + // Public /v1/chat/completions — user-key auth + governance check + proxy to upstream + proxyChat := actions.ProxyRouteChatCompletions + if proxyChat != nil { + dsn := sqliteDSN + mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + handlePublicV1ChatCompletions(w, r, dsn, proxyChat) + }) + } } mux.Handle("POST /api/routing/resolve", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2666,3 +2683,156 @@ func subscriptionTargets(userIDs []string, durationDays int) []provision.Subscri } return targets } + +// handlePublicV1ChatCompletions handles POST /v1/chat/completions +// Authenticates user via Authorization: Bearer *** (portal user key), +// checks admin_status != "paused", then proxies to upstream via ProxyRouteChatCompletions. +func handlePublicV1ChatCompletions(w http.ResponseWriter, r *http.Request, dsn string, proxyChat func(context.Context, ProxyRouteChatCompletionsRequest) (ProxyRouteChatCompletionsResult, error)) { + // 1. Extract bearer key + auth := strings.TrimSpace(r.Header.Get("Authorization")) + if !strings.HasPrefix(auth, "Bearer ") { + writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "missing or invalid Authorization header"}) + return + } + keyToken := strings.TrimPrefix(auth, "Bearer ") + keyToken = strings.TrimSpace(keyToken) + if keyToken == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "empty bearer token"}) + return + } + + // 2. Validate key via store — find by fingerprint (= sha256(plaintext)) + keyFingerprint := "sha256:" + sha256Hex(keyToken) + + store, err := sqlite.Open(r.Context(), dsn) + if err != nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "db_error", Message: "database error"}) + return + } + defer store.Close() + + keys, err := store.UserKeys().ListByFingerprint(r.Context(), keyFingerprint) + if err != nil || len(keys) == 0 { + writeHTTPError(w, &httpError{StatusCode: http.StatusUnauthorized, Code: "unauthorized", Message: "invalid API key"}) + return + } + + key := keys[0] + + // 3. Governance check + if key.AdminStatus == "paused" { + writeHTTPError(w, &httpError{StatusCode: http.StatusForbidden, Code: "key_paused", Message: "API key is paused"}) + return + } + if key.AdminStatus == "retired" || key.AdminStatus == "deleted" { + writeHTTPError(w, &httpError{StatusCode: http.StatusForbidden, Code: "key_retired", Message: "API key is no longer active"}) + return + } + + // 4. Parse request body (OpenAI-compatible) + body, err := io.ReadAll(io.LimitReader(r.Body, maxJSONBodyBytes)) + if err != nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "cannot read request body"}) + return + } + var openAIReq struct { + Model string `json:"model"` + Messages []ChatCompletionMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + Stream bool `json:"stream,omitempty"` + } + if err := json.Unmarshal(body, &openAIReq); err != nil { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "invalid request body"}) + return + } + + model := strings.TrimSpace(openAIReq.Model) + if model == "" { + writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "model is required"}) + return + } + + // 5. Map to proxy request + proxyReq := ProxyRouteChatCompletionsRequest{ + LogicalGroupID: key.LogicalGroupID, + PublicModel: model, + Scope: "user", + SubjectID: key.OwnerSubjectID, + UserKey: keyToken, + Messages: openAIReq.Messages, + MaxTokens: openAIReq.MaxTokens, + Temperature: openAIReq.Temperature, + Sync: true, + } + + result, err := proxyChat(r.Context(), proxyReq) + if err != nil { + writeHTTPError(w, classifyError(err)) + return + } + + // 6. Transform proxy result to OpenAI-compatible response + // The proxy already forwarded to upstream; result.Forward.Response has the upstream body. + var upstreamResp map[string]any + switch v := result.Forward.Response.(type) { + case map[string]any: + upstreamResp = v + default: + // If the response was stored as raw string, try JSON decode + if s, ok := v.(string); ok { + _ = json.Unmarshal([]byte(s), &upstreamResp) + } + } + + if upstreamResp == nil { + // Fallback: construct a minimal response from proxy info + upstreamResp = map[string]any{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixMilli()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": fmt.Sprintf("proxy %s/%s (HTTP %d)", result.Forward.HostID, result.Forward.ShadowModel, result.Forward.UpstreamStatus), + }, + "finish_reason": "stop", + }}, + } + } + + // Ensure upstream HTTP code is reflected + if !result.Forward.OK && result.Forward.UpstreamStatus > 0 { + upstreamResp["upstream_http_code"] = result.Forward.UpstreamStatus + } + + // Wrap in OpenAI standard envelope if upstream didn't return one + respPayload := upstreamResp + if _, hasID := upstreamResp["id"]; !hasID { + respPayload = map[string]any{ + "id": fmt.Sprintf("chatcmpl-%d", time.Now().UnixMilli()), + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": func() string { + if b, err := json.Marshal(upstreamResp); err == nil { + return string(b) + } + return "upstream response" + }(), + }, + "finish_reason": "stop", + }}, + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(respPayload) +} diff --git a/internal/app/portal_api_test.go b/internal/app/portal_api_test.go index fa7a2b22..f593808a 100644 --- a/internal/app/portal_api_test.go +++ b/internal/app/portal_api_test.go @@ -194,7 +194,7 @@ func TestNewActionSetPortalLogicalGroups(t *testing.T) { } request := httptestRequest(t, "GET", "/api/portal/logical-groups/gpt-shared", nil, "") - response := httptestRecorder(NewAPIHandler("secret-token", actions), request) + response := httptestRecorder(NewAPIHandler("secret-token", actions, ""), request) assertStatusCode(t, response, 200) var payload map[string]PortalLogicalGroupInfo if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil { diff --git a/internal/app/version_test.go b/internal/app/version_test.go index bded372d..d6480302 100644 --- a/internal/app/version_test.go +++ b/internal/app/version_test.go @@ -51,7 +51,7 @@ func TestHandleVersion(t *testing.T) { } func TestVersionEndpoint_Integration(t *testing.T) { - handler := NewAPIHandler("", ActionSet{}) + handler := NewAPIHandler("", ActionSet{}, "") req := httptest.NewRequest("GET", "/version", nil) rr := httptest.NewRecorder() diff --git a/internal/store/sqlite/user_keys_repo.go b/internal/store/sqlite/user_keys_repo.go index 4bd6635d..41c74864 100644 --- a/internal/store/sqlite/user_keys_repo.go +++ b/internal/store/sqlite/user_keys_repo.go @@ -109,6 +109,19 @@ func (r *UserKeysRepo) ListByOwner(ctx context.Context, subjectID string) ([]Use return scanUserKeys(rows) } +func (r *UserKeysRepo) ListByFingerprint(ctx context.Context, fingerprint string) ([]UserKeyRecord, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT id, key_id, owner_subject_id, key_fingerprint, masked_preview, + display_name, logical_group_id, allowed_models, + admin_status, quota_status, last_used_at, created_at, expires_at, updated_at + FROM user_keys WHERE key_fingerprint = ? ORDER BY created_at DESC`, fingerprint) + if err != nil { + return nil, fmt.Errorf("list user_keys by fingerprint: %w", err) + } + defer rows.Close() + return scanUserKeys(rows) +} + func (r *UserKeysRepo) GetByID(ctx context.Context, keyID string) (*UserKeyRecord, error) { row := r.db.QueryRowContext(ctx, ` SELECT id, key_id, owner_subject_id, key_fingerprint, masked_preview,