feat(v3): add CRM gateway /v1/chat/completions with key auth + governance check
Some checks failed
CI / Build & Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Release (push) Has been cancelled

- POST /v1/chat/completions public route on CRM (not host pass-through)
- Bearer token → sha256 fingerprint → ListByFingerprint → governance check
- paused → 403 forbidden, retired/deleted → 403
- ProxyRouteChatCompletions to upstream
- NewAPIHandler/NewAPIHandlerWithAuth: optional dsn param for gateway SQLite access
- ListByFingerprint in user_keys_repo
This commit is contained in:
phamnazage-jpg
2026-06-07 12:19:24 +08:00
parent 6eec70d6a3
commit c86c8a17ca
8 changed files with 280 additions and 51 deletions

View File

@@ -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。

View File

@@ -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{

View File

@@ -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")

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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,