feat(v3): add CRM gateway /v1/chat/completions with key auth + governance check
- 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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user