420 lines
13 KiB
Go
420 lines
13 KiB
Go
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")
|
|
}
|