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

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