first commit
This commit is contained in:
419
transports/bifrost-http/handlers/realtime_client_secrets.go
Normal file
419
transports/bifrost-http/handlers/realtime_client_secrets.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fasthttp/router"
|
||||
bifrost "github.com/maximhq/bifrost/core"
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
"github.com/maximhq/bifrost/plugins/governance"
|
||||
"github.com/maximhq/bifrost/transports/bifrost-http/integrations"
|
||||
"github.com/maximhq/bifrost/transports/bifrost-http/lib"
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
// RealtimeClientSecretsHandler exposes OpenAI-compatible HTTP routes for
|
||||
// minting short-lived Realtime client secrets.
|
||||
type RealtimeClientSecretsHandler struct {
|
||||
client *bifrost.Bifrost
|
||||
config *lib.Config
|
||||
handlerStore lib.HandlerStore
|
||||
routeSpecs map[string]schemas.RealtimeSessionRoute
|
||||
}
|
||||
|
||||
func NewRealtimeClientSecretsHandler(client *bifrost.Bifrost, config *lib.Config) *RealtimeClientSecretsHandler {
|
||||
return &RealtimeClientSecretsHandler{
|
||||
client: client,
|
||||
config: config,
|
||||
handlerStore: config,
|
||||
routeSpecs: make(map[string]schemas.RealtimeSessionRoute),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RealtimeClientSecretsHandler) RegisterRoutes(r *router.Router, middlewares ...schemas.BifrostHTTPMiddleware) {
|
||||
handler := lib.ChainMiddlewares(h.handleRequest, middlewares...)
|
||||
for _, route := range h.realtimeSessionRoutes() {
|
||||
h.routeSpecs[route.Path] = route
|
||||
r.POST(route.Path, handler)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RealtimeClientSecretsHandler) findGovernancePlugin() governance.BaseGovernancePlugin {
|
||||
basePlugins := h.config.BasePlugins.Load()
|
||||
if basePlugins == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, plugin := range *basePlugins {
|
||||
if governancePlugin, ok := plugin.(governance.BaseGovernancePlugin); ok {
|
||||
return governancePlugin
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RealtimeClientSecretsHandler) handleRequest(ctx *fasthttp.RequestCtx) {
|
||||
if !isJSONContentType(string(ctx.Request.Header.ContentType())) {
|
||||
SendBifrostError(ctx, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
"Content-Type must be application/json",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
body := append([]byte(nil), ctx.Request.Body()...)
|
||||
route, ok := h.routeSpecs[string(ctx.Path())]
|
||||
if !ok {
|
||||
SendBifrostError(ctx, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusNotFound,
|
||||
"invalid_request_error",
|
||||
"unsupported realtime client secret route",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
providerKey, model, normalizedBody, err := resolveRealtimeClientSecretTarget(route, body)
|
||||
if err != nil {
|
||||
SendBifrostError(ctx, err)
|
||||
return
|
||||
}
|
||||
|
||||
bifrostCtx, cancel := lib.ConvertToBifrostContext(
|
||||
ctx,
|
||||
h.handlerStore.ShouldAllowDirectKeys(),
|
||||
h.config.GetHeaderMatcher(),
|
||||
h.config.GetMCPHeaderCombinedAllowlist(),
|
||||
)
|
||||
defer cancel()
|
||||
bifrostCtx.SetValue(schemas.BifrostContextKeyHTTPRequestType, schemas.RealtimeRequest)
|
||||
if route.DefaultProvider == schemas.OpenAI {
|
||||
bifrostCtx.SetValue(schemas.BifrostContextKeyIntegrationType, "openai")
|
||||
}
|
||||
if governanceUserID, ok := ctx.UserValue(schemas.BifrostContextKeyUserID).(string); ok && governanceUserID != "" {
|
||||
bifrostCtx.SetValue(schemas.BifrostContextKeyUserID, governanceUserID)
|
||||
}
|
||||
if userName, ok := ctx.UserValue(schemas.BifrostContextKeyUserName).(string); ok && userName != "" {
|
||||
bifrostCtx.SetValue(schemas.BifrostContextKeyUserName, userName)
|
||||
}
|
||||
if bifrostErr := h.evaluateMintingGovernance(bifrostCtx, providerKey, model); bifrostErr != nil {
|
||||
SendBifrostError(ctx, bifrostErr)
|
||||
return
|
||||
}
|
||||
|
||||
provider := h.client.GetProviderByKey(providerKey)
|
||||
if provider == nil {
|
||||
SendBifrostError(ctx, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
"provider not found: "+string(providerKey),
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
key, keyErr := h.client.SelectKeyForProviderRequestType(bifrostCtx, schemas.RealtimeRequest, providerKey, model)
|
||||
if keyErr != nil {
|
||||
SendBifrostError(ctx, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
keyErr.Error(),
|
||||
keyErr,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve model aliases now that the key is selected so the forwarded body
|
||||
// carries the provider's canonical model, matching wsrealtime/webrtc flows.
|
||||
if resolved := key.Aliases.Resolve(model); resolved != "" && resolved != model {
|
||||
model = resolved
|
||||
reparsed, parseErr := schemas.ParseRealtimeClientSecretBody(normalizedBody)
|
||||
if parseErr != nil {
|
||||
SendBifrostError(ctx, parseErr)
|
||||
return
|
||||
}
|
||||
rewritten, normalizeErr := normalizeRealtimeClientSecretBody(reparsed, model)
|
||||
if normalizeErr != nil {
|
||||
SendBifrostError(ctx, normalizeErr)
|
||||
return
|
||||
}
|
||||
normalizedBody = rewritten
|
||||
}
|
||||
|
||||
sessionProvider, ok := provider.(schemas.RealtimeSessionProvider)
|
||||
if !ok {
|
||||
SendBifrostError(ctx, realtimeSessionNotSupportedError(providerKey, provider))
|
||||
return
|
||||
}
|
||||
|
||||
resp, bifrostErr := sessionProvider.CreateRealtimeClientSecret(bifrostCtx, key, route.EndpointType, normalizedBody)
|
||||
if bifrostErr != nil {
|
||||
SendBifrostError(ctx, bifrostErr)
|
||||
return
|
||||
}
|
||||
cacheRealtimeEphemeralKeyMapping(
|
||||
h.handlerStore.GetKVStore(),
|
||||
resp.Body,
|
||||
key.ID,
|
||||
bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyVirtualKey),
|
||||
)
|
||||
|
||||
writeRealtimeClientSecretResponse(ctx, resp)
|
||||
}
|
||||
|
||||
func (h *RealtimeClientSecretsHandler) evaluateMintingGovernance(
|
||||
bifrostCtx *schemas.BifrostContext,
|
||||
providerKey schemas.ModelProvider,
|
||||
model string,
|
||||
) *schemas.BifrostError {
|
||||
governancePlugin := h.findGovernancePlugin()
|
||||
if governancePlugin == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, bifrostErr := governancePlugin.EvaluateGovernanceRequest(bifrostCtx, &governance.EvaluationRequest{
|
||||
VirtualKey: bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyVirtualKey),
|
||||
Provider: providerKey,
|
||||
Model: model,
|
||||
UserID: bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyUserID),
|
||||
}, schemas.RealtimeRequest)
|
||||
return bifrostErr
|
||||
}
|
||||
|
||||
func (h *RealtimeClientSecretsHandler) realtimeSessionRoutes() []schemas.RealtimeSessionRoute {
|
||||
routes := []schemas.RealtimeSessionRoute{
|
||||
{
|
||||
Path: "/v1/realtime/client_secrets",
|
||||
EndpointType: schemas.RealtimeSessionEndpointClientSecrets,
|
||||
},
|
||||
{
|
||||
Path: "/v1/realtime/sessions",
|
||||
EndpointType: schemas.RealtimeSessionEndpointSessions,
|
||||
},
|
||||
}
|
||||
|
||||
for _, path := range integrations.OpenAIRealtimeClientSecretPaths("/openai") {
|
||||
endpointType := schemas.RealtimeSessionEndpointClientSecrets
|
||||
if strings.HasSuffix(path, "/realtime/sessions") {
|
||||
endpointType = schemas.RealtimeSessionEndpointSessions
|
||||
}
|
||||
routes = append(routes, schemas.RealtimeSessionRoute{
|
||||
Path: path,
|
||||
EndpointType: endpointType,
|
||||
DefaultProvider: schemas.OpenAI,
|
||||
})
|
||||
}
|
||||
return routes
|
||||
}
|
||||
|
||||
func resolveRealtimeClientSecretTarget(route schemas.RealtimeSessionRoute, body []byte) (schemas.ModelProvider, string, []byte, *schemas.BifrostError) {
|
||||
root, err := schemas.ParseRealtimeClientSecretBody(body)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
rawModel, err := schemas.ExtractRealtimeClientSecretModel(root)
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
defaultProvider := route.DefaultProvider
|
||||
providerKey, model := schemas.ParseModelString(rawModel, defaultProvider)
|
||||
if defaultProvider == "" && providerKey == "" {
|
||||
return "", "", nil, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
"session.model must use provider/model on /v1 realtime client secret routes",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
if providerKey == "" || model == "" {
|
||||
return "", "", nil, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
"session.model is required",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Normalize the forwarded body so the upstream provider sees the bare model
|
||||
// (strip provider prefix). Mirrors resolveRealtimeSDPTarget normalization.
|
||||
normalizedBody, normalizeErr := normalizeRealtimeClientSecretBody(root, model)
|
||||
if normalizeErr != nil {
|
||||
return "", "", nil, normalizeErr
|
||||
}
|
||||
|
||||
return providerKey, model, normalizedBody, nil
|
||||
}
|
||||
|
||||
func normalizeRealtimeClientSecretBody(root map[string]json.RawMessage, bareModel string) ([]byte, *schemas.BifrostError) {
|
||||
normalizedModel, marshalErr := json.Marshal(bareModel)
|
||||
if marshalErr != nil {
|
||||
return nil, newRealtimeClientSecretHandlerError(fasthttp.StatusInternalServerError, "server_error", "failed to encode normalized model", marshalErr)
|
||||
}
|
||||
|
||||
// Normalize session.model if present
|
||||
if sessionJSON, ok := root["session"]; ok && len(sessionJSON) > 0 {
|
||||
var session map[string]json.RawMessage
|
||||
if err := json.Unmarshal(sessionJSON, &session); err == nil {
|
||||
if _, hasModel := session["model"]; hasModel {
|
||||
session["model"] = normalizedModel
|
||||
rewritten, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return nil, newRealtimeClientSecretHandlerError(fasthttp.StatusInternalServerError, "server_error", "failed to re-encode session", err)
|
||||
}
|
||||
root["session"] = rewritten
|
||||
}
|
||||
}
|
||||
}
|
||||
// Normalize top-level model if present
|
||||
if _, ok := root["model"]; ok {
|
||||
root["model"] = normalizedModel
|
||||
}
|
||||
|
||||
normalized, marshalErr := json.Marshal(root)
|
||||
if marshalErr != nil {
|
||||
return nil, newRealtimeClientSecretHandlerError(fasthttp.StatusInternalServerError, "server_error", "failed to re-encode body", marshalErr)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
const realtimeEphemeralKeyMappingPrefix = "realtime:ephemeral-key:"
|
||||
|
||||
type realtimeEphemeralKeyMapping struct {
|
||||
KeyID string `json:"key_id,omitempty"`
|
||||
VirtualKey string `json:"virtual_key,omitempty"`
|
||||
}
|
||||
|
||||
func cacheRealtimeEphemeralKeyMapping(kv schemas.KVStore, body []byte, keyID string, virtualKey string) {
|
||||
if kv == nil || len(body) == 0 || strings.TrimSpace(keyID) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
token, ttl, ok := parseRealtimeEphemeralKeyMapping(body)
|
||||
if !ok || strings.TrimSpace(token) == "" || ttl <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(realtimeEphemeralKeyMapping{
|
||||
KeyID: strings.TrimSpace(keyID),
|
||||
VirtualKey: strings.TrimSpace(virtualKey),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to encode realtime ephemeral key mapping for key_id=%s: %v", keyID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := kv.SetWithTTL(buildRealtimeEphemeralKeyMappingKey(token), payload, ttl); err != nil {
|
||||
logger.Warn("failed to cache realtime ephemeral key mapping for key_id=%s: %v", keyID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseRealtimeEphemeralKeyMapping(body []byte) (string, time.Duration, bool) {
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &root); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
var clientSecret struct {
|
||||
Value string `json:"value"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
// OpenAI client_secrets responses expose the ephemeral token at the top level.
|
||||
// Keep accepting the nested shape too so the mapping logic stays compatible
|
||||
// with any provider/session endpoint variants that wrap the secret object.
|
||||
if err := json.Unmarshal(body, &clientSecret); err != nil || strings.TrimSpace(clientSecret.Value) == "" || clientSecret.ExpiresAt <= 0 {
|
||||
clientSecretRaw, ok := root["client_secret"]
|
||||
if !ok || len(clientSecretRaw) == 0 || string(clientSecretRaw) == "null" {
|
||||
return "", 0, false
|
||||
}
|
||||
if err := json.Unmarshal(clientSecretRaw, &clientSecret); err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(clientSecret.Value) == "" || clientSecret.ExpiresAt <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
ttl := time.Until(time.Unix(clientSecret.ExpiresAt, 0))
|
||||
if ttl <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
return clientSecret.Value, ttl, true
|
||||
}
|
||||
|
||||
func buildRealtimeEphemeralKeyMappingKey(token string) string {
|
||||
return realtimeEphemeralKeyMappingPrefix + strings.TrimSpace(token)
|
||||
}
|
||||
|
||||
func realtimeSessionNotSupportedError(providerKey schemas.ModelProvider, provider schemas.Provider) *schemas.BifrostError {
|
||||
if rtProvider, ok := provider.(schemas.RealtimeProvider); ok && rtProvider.SupportsRealtimeAPI() {
|
||||
return newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
fmt.Sprintf("provider %s supports realtime websocket connections but not realtime client secret creation", providerKey),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
return newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusBadRequest,
|
||||
"invalid_request_error",
|
||||
fmt.Sprintf("provider %s does not support realtime client secret creation", providerKey),
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
func newRealtimeClientSecretHandlerError(status int, errorType, message string, err error) *schemas.BifrostError {
|
||||
return &schemas.BifrostError{
|
||||
IsBifrostError: false,
|
||||
StatusCode: schemas.Ptr(status),
|
||||
Error: &schemas.ErrorField{
|
||||
Type: schemas.Ptr(errorType),
|
||||
Message: message,
|
||||
Error: err,
|
||||
},
|
||||
ExtraFields: schemas.BifrostErrorExtraFields{
|
||||
RequestType: schemas.RealtimeRequest,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeRealtimeClientSecretResponse(ctx *fasthttp.RequestCtx, resp *schemas.BifrostPassthroughResponse) {
|
||||
if resp == nil {
|
||||
SendBifrostError(ctx, newRealtimeClientSecretHandlerError(
|
||||
fasthttp.StatusInternalServerError,
|
||||
"server_error",
|
||||
"provider returned an empty realtime client secret response",
|
||||
nil,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
for key, value := range resp.Headers {
|
||||
ctx.Response.Header.Set(key, value)
|
||||
}
|
||||
if len(ctx.Response.Header.ContentType()) == 0 {
|
||||
ctx.SetContentType("application/json")
|
||||
}
|
||||
ctx.SetStatusCode(resp.StatusCode)
|
||||
ctx.SetBody(resp.Body)
|
||||
}
|
||||
|
||||
func isJSONContentType(contentType string) bool {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mediaType = strings.ToLower(mediaType)
|
||||
return mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")
|
||||
}
|
||||
Reference in New Issue
Block a user