Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions core/providers/azure/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func (provider *AzureProvider) RealtimeWebSocketURL(ctx *schemas.BifrostContext,
if intent != "" {
return "", newAzureRealtimeError(fasthttp.StatusBadRequest, "invalid_request_error", "azure voice live does not take a realtime intent", nil)
}
// Voice Live turns are priced at the model's _voice_live rates.
if ctx != nil {
ctx.SetValue(schemas.BifrostContextKeyRealtimePricingSurface, schemas.PricingSurfaceVoiceLive)
}
return fmt.Sprintf("%s/voice-live/realtime?api-version=%s&model=%s", endpoint, url.QueryEscape(resolveAPIVersion(ctx, azureVoiceLiveAPIVersion)), url.QueryEscape(model)), nil
}

Expand Down
25 changes: 25 additions & 0 deletions core/providers/azure/realtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,31 @@ func TestAzureVoiceLiveWebSocketURL(t *testing.T) {
}
}

// TestAzureVoiceLiveMarksPricingSurface pins that a session sent to Voice Live is marked for the
// model's _voice_live rates, and one sent to Azure OpenAI is not.
func TestAzureVoiceLiveMarksPricingSurface(t *testing.T) {
t.Parallel()
provider := &AzureProvider{}
for _, tc := range []struct {
name string
voiceLive bool
want schemas.PricingSurface
}{
{"voice live", true, schemas.PricingSurfaceVoiceLive},
{"azure openai", false, ""},
} {
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
key := schemas.Key{AzureKeyConfig: &schemas.AzureKeyConfig{Endpoint: *schemas.NewSecretVar("https://example.openai.azure.com/"), UseVoiceLiveEndpoints: tc.voiceLive}}
if _, err := provider.RealtimeWebSocketURL(ctx, key, "gpt-realtime", ""); err != nil {
t.Fatalf("%s: RealtimeWebSocketURL: %v", tc.name, err.Error.Message)
}
got, _ := ctx.Value(schemas.BifrostContextKeyRealtimePricingSurface).(schemas.PricingSurface)
if got != tc.want {
t.Errorf("%s: pricing surface = %q, want %q", tc.name, got, tc.want)
}
}
}

// TestAzureVoiceLiveRejectsWebRTCAndClientSecrets pins the 400 on the routes Voice Live does not
// serve the OpenAI way, before any upstream call.
func TestAzureVoiceLiveRejectsWebRTCAndClientSecrets(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions core/providers/openai/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -1094,3 +1094,16 @@ func ExtractNestedVoice(audioRaw json.RawMessage) string {
}
return ""
}

// ExtractNestedVoiceType returns the type of the session.audio.output.voice object, or empty.
func ExtractNestedVoiceType(audioRaw json.RawMessage) string {
var audio struct {
Output struct {
Voice schemas.RealtimeVoice `json:"voice"`
} `json:"output"`
}
if err := json.Unmarshal(audioRaw, &audio); err == nil {
return audio.Output.Voice.GetType()
}
return ""
}
10 changes: 10 additions & 0 deletions core/schemas/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,7 @@ const (
BifrostContextKeyA2ARequestOrigin BifrostContextKey = "bifrost-a2a-request-origin" // string (absolute HTTP origin used to reach the Agent Gateway)
BifrostContextKeyA2AUpstreamTransport BifrostContextKey = "bifrost-a2a-upstream-transport" // string (A2A binding selected for the upstream agent)
BifrostContextKeyRealtimeVoice BifrostContextKey = "bifrost-realtime-voice" // string
BifrostContextKeyRealtimePricingSurface BifrostContextKey = "bifrost-realtime-pricing-surface" // PricingSurface (set by the provider when it opens the realtime session)
BifrostIsAsyncRequest BifrostContextKey = "bifrost-is-async-request" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - whether the request is an async request (only used in gateway)
BifrostContextKeyRequestHeaders BifrostContextKey = "bifrost-request-headers" // map[string]string (all request headers with lowercased keys)
BifrostContextKeyRequestQuery BifrostContextKey = "bifrost-request-query" // map[string]string (request query params with lowercased keys; consumed by governance routing CEL rules)
Expand Down Expand Up @@ -2033,13 +2034,22 @@ func (r *BifrostMCPResponse) PopulateExtraFields(mcpRequestType MCPRequestType,
}
}

// PricingSurface names the product surface whose price list applies to a request.
type PricingSurface string

// PricingSurfaceVoiceLive prices an Azure Voice Live turn at the model's _voice_live rates.
const PricingSurfaceVoiceLive PricingSurface = "voice_live"

// BifrostResponseExtraFields contains additional fields in a response.
type BifrostResponseExtraFields struct {
RequestType RequestType `json:"request_type"`
// PricingRequestType selects a catalog mode without changing the request type
// exposed to plugins and logs.
PricingRequestType RequestType `json:"pricing_request_type,omitempty"`
RoutingInfo RoutingInfo `json:"routing_info"`
// PricingSurface selects a model's rates for the product that served the request, without
// changing the request type or service tier exposed to plugins and logs.
PricingSurface PricingSurface `json:"pricing_surface,omitempty"`
// Deprecated: use RoutingInfo.Provider. Still populated for backward
// compatibility; new consumers should read from RoutingInfo.
Provider ModelProvider `json:"provider,omitempty"`
Expand Down
25 changes: 25 additions & 0 deletions core/schemas/chatcompletions.go
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,10 @@ type ChatPromptTokensDetails struct {
CachedReadTokens int `json:"cached_read_tokens,omitempty"`
CachedWriteTokens int `json:"cached_write_tokens,omitempty"`
CachedWriteTokenDetails *ChatCachedWriteTokenDetails `json:"cached_write_token_details,omitempty"`

// Parts of AudioTokens that Azure Voice Live handled with Azure Speech, which it bills at their own rates.
StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}

type ChatCachedWriteTokenDetails struct {
Expand All @@ -2040,6 +2044,9 @@ func (d *ChatPromptTokensDetails) UnmarshalJSON(data []byte) error {
CachedWriteTokenDetails *ChatCachedWriteTokenDetails `json:"cached_write_token_details"`
CachedTokens *int `json:"cached_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`

StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens"`
}
if err := Unmarshal(data, &raw); err != nil {
return err
Expand All @@ -2050,6 +2057,8 @@ func (d *ChatPromptTokensDetails) UnmarshalJSON(data []byte) error {
d.CachedReadTokens = raw.CachedReadTokens
d.CachedWriteTokens = raw.CachedWriteTokens
d.CachedWriteTokenDetails = raw.CachedWriteTokenDetails
d.StandardSpeechAudioTokens = raw.StandardSpeechAudioTokens
d.CustomSpeechAudioTokens = raw.CustomSpeechAudioTokens
// OpenAI spec providers send just cached_tokens, not separate read and write tokens and we handle them as read tokens in pricing calculations.
if raw.CachedTokens != nil && raw.CachedReadTokens == 0 && raw.CachedWriteTokens == 0 {
d.CachedReadTokens = *raw.CachedTokens
Expand All @@ -2076,6 +2085,9 @@ func (d ChatPromptTokensDetails) MarshalJSON() ([]byte, error) {
// OpenAI's field name for cache writes (mirrors cached_tokens for reads) so the
// OpenAI SDK — which reads cache_write_tokens, not cached_write_tokens — finds it.
CacheWriteTokens int `json:"cache_write_tokens"`

StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}
return MarshalSorted(raw{
TextTokens: d.TextTokens,
Expand All @@ -2086,6 +2098,9 @@ func (d ChatPromptTokensDetails) MarshalJSON() ([]byte, error) {
CachedWriteTokenDetails: d.CachedWriteTokenDetails,
CachedTokens: d.CachedReadTokens,
CacheWriteTokens: d.CachedWriteTokens,

StandardSpeechAudioTokens: d.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: d.CustomSpeechAudioTokens,
})
}

Expand All @@ -2099,6 +2114,10 @@ type ChatCompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens,omitempty"`
ImageTokens *int `json:"image_tokens,omitempty"`
RejectedPredictionTokens int `json:"rejected_prediction_tokens,omitempty"`

// Parts of AudioTokens that Azure Voice Live handled with Azure Speech, which it bills at their own rates.
StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}

// BifrostCost splits a request's cost into an input side, an output side, and an
Expand Down Expand Up @@ -2151,6 +2170,9 @@ func MergeBifrostLLMUsage(base, add *BifrostLLMUsage) *BifrostLLMUsage {
ImageTokens: baseDetails.ImageTokens + addDetails.ImageTokens,
CachedReadTokens: baseDetails.CachedReadTokens + addDetails.CachedReadTokens,
CachedWriteTokens: baseDetails.CachedWriteTokens + addDetails.CachedWriteTokens,

StandardSpeechAudioTokens: baseDetails.StandardSpeechAudioTokens + addDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: baseDetails.CustomSpeechAudioTokens + addDetails.CustomSpeechAudioTokens,
}
if baseDetails.CachedWriteTokenDetails != nil || addDetails.CachedWriteTokenDetails != nil {
merged.PromptTokensDetails.CachedWriteTokenDetails = &ChatCachedWriteTokenDetails{
Expand All @@ -2175,6 +2197,9 @@ func MergeBifrostLLMUsage(base, add *BifrostLLMUsage) *BifrostLLMUsage {
AudioTokens: baseDetails.AudioTokens + addDetails.AudioTokens,
ReasoningTokens: baseDetails.ReasoningTokens + addDetails.ReasoningTokens,
RejectedPredictionTokens: baseDetails.RejectedPredictionTokens + addDetails.RejectedPredictionTokens,

StandardSpeechAudioTokens: baseDetails.StandardSpeechAudioTokens + addDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: baseDetails.CustomSpeechAudioTokens + addDetails.CustomSpeechAudioTokens,
}
merged.CompletionTokensDetails.CitationTokens = sumOptionalInts(baseDetails.CitationTokens, addDetails.CitationTokens)
merged.CompletionTokensDetails.NumSearchQueries = sumOptionalInts(baseDetails.NumSearchQueries, addDetails.NumSearchQueries)
Expand Down
12 changes: 12 additions & 0 deletions core/schemas/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -1135,6 +1135,9 @@ func (cu *BifrostLLMUsage) ToResponsesResponseUsage() *ResponsesResponseUsage {
CachedReadTokens: cu.PromptTokensDetails.CachedReadTokens,
CachedWriteTokens: cu.PromptTokensDetails.CachedWriteTokens,
CachedWriteTokenDetails: cu.PromptTokensDetails.CachedWriteTokenDetails,

StandardSpeechAudioTokens: cu.PromptTokensDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: cu.PromptTokensDetails.CustomSpeechAudioTokens,
}
}
if cu.CompletionTokensDetails != nil {
Expand All @@ -1146,6 +1149,9 @@ func (cu *BifrostLLMUsage) ToResponsesResponseUsage() *ResponsesResponseUsage {
RejectedPredictionTokens: cu.CompletionTokensDetails.RejectedPredictionTokens,
CitationTokens: cu.CompletionTokensDetails.CitationTokens,
NumSearchQueries: cu.CompletionTokensDetails.NumSearchQueries,

StandardSpeechAudioTokens: cu.CompletionTokensDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: cu.CompletionTokensDetails.CustomSpeechAudioTokens,
}
}
usage.ToolUsage = cu.ToolUsage.DeepCopy()
Expand Down Expand Up @@ -1175,6 +1181,9 @@ func (ru *ResponsesResponseUsage) ToBifrostLLMUsage() *BifrostLLMUsage {
CachedReadTokens: ru.InputTokensDetails.CachedReadTokens,
CachedWriteTokens: ru.InputTokensDetails.CachedWriteTokens,
CachedWriteTokenDetails: ru.InputTokensDetails.CachedWriteTokenDetails,

StandardSpeechAudioTokens: ru.InputTokensDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: ru.InputTokensDetails.CustomSpeechAudioTokens,
}
}
if ru.OutputTokensDetails != nil {
Expand All @@ -1187,6 +1196,9 @@ func (ru *ResponsesResponseUsage) ToBifrostLLMUsage() *BifrostLLMUsage {
RejectedPredictionTokens: ru.OutputTokensDetails.RejectedPredictionTokens,
CitationTokens: ru.OutputTokensDetails.CitationTokens,
NumSearchQueries: ru.OutputTokensDetails.NumSearchQueries,

StandardSpeechAudioTokens: ru.OutputTokensDetails.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: ru.OutputTokensDetails.CustomSpeechAudioTokens,
}
}
usage.ToolUsage = ru.ToolUsage.DeepCopy()
Expand Down
8 changes: 8 additions & 0 deletions core/schemas/realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,14 @@ func (v *RealtimeVoice) GetName() string {
return ""
}

// GetType returns the object voice's type, such as Azure Voice Live's "azure-standard", or empty.
func (v *RealtimeVoice) GetType() string {
if v == nil || v.VoiceObject == nil {
return ""
}
return v.VoiceObject.Type
}

// MarshalJSON writes id, name and type next to the preserved fields.
func (o RealtimeVoiceObject) MarshalJSON() ([]byte, error) {
out := make(map[string]json.RawMessage, len(o.ExtraParams)+3)
Expand Down
19 changes: 19 additions & 0 deletions core/schemas/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,10 @@ type ResponsesResponseInputTokens struct {
CachedReadTokens int `json:"cached_read_tokens"`
CachedWriteTokens int `json:"cached_write_tokens"`
CachedWriteTokenDetails *ChatCachedWriteTokenDetails `json:"cached_write_token_details,omitempty"`

// Parts of AudioTokens that Azure Voice Live handled with Azure Speech, which it bills at their own rates.
StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}

// UnmarshalJSON maps OpenAI's cached_tokens into CachedReadTokens for compatibility.
Expand All @@ -1402,6 +1406,9 @@ func (d *ResponsesResponseInputTokens) UnmarshalJSON(data []byte) error {
CachedWriteTokenDetails *ChatCachedWriteTokenDetails `json:"cached_write_token_details"`
CachedTokens *int `json:"cached_tokens"`
CacheWriteTokens *int `json:"cache_write_tokens"`

StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens"`
}
if err := Unmarshal(data, &raw); err != nil {
return err
Expand All @@ -1412,6 +1419,8 @@ func (d *ResponsesResponseInputTokens) UnmarshalJSON(data []byte) error {
d.CachedReadTokens = raw.CachedReadTokens
d.CachedWriteTokens = raw.CachedWriteTokens
d.CachedWriteTokenDetails = raw.CachedWriteTokenDetails
d.StandardSpeechAudioTokens = raw.StandardSpeechAudioTokens
d.CustomSpeechAudioTokens = raw.CustomSpeechAudioTokens
// OpenAI spec providers send just cached_tokens, not separate read and write tokens and we handle them as read tokens in pricing calculations.
if raw.CachedTokens != nil && raw.CachedReadTokens == 0 && raw.CachedWriteTokens == 0 {
d.CachedReadTokens = *raw.CachedTokens
Expand All @@ -1438,6 +1447,9 @@ func (d ResponsesResponseInputTokens) MarshalJSON() ([]byte, error) {
// OpenAI's field name for cache writes (mirrors cached_tokens for reads) so the
// OpenAI SDK — which reads cache_write_tokens, not cached_write_tokens — finds it.
CacheWriteTokens int `json:"cache_write_tokens"`

StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}
return MarshalSorted(raw{
TextTokens: d.TextTokens,
Expand All @@ -1448,6 +1460,9 @@ func (d ResponsesResponseInputTokens) MarshalJSON() ([]byte, error) {
CachedWriteTokenDetails: d.CachedWriteTokenDetails,
CachedTokens: d.CachedReadTokens,
CacheWriteTokens: d.CachedWriteTokens,

StandardSpeechAudioTokens: d.StandardSpeechAudioTokens,
CustomSpeechAudioTokens: d.CustomSpeechAudioTokens,
})
}

Expand All @@ -1461,6 +1476,10 @@ type ResponsesResponseOutputTokens struct {
CitationTokens *int `json:"citation_tokens,omitempty"`
// Deprecated: use ResponsesResponseUsage.ToolUsage.WebSearch. Populated, will be removed in 3.0.0.
NumSearchQueries *int `json:"num_search_queries,omitempty"`

// Parts of AudioTokens that Azure Voice Live handled with Azure Speech, which it bills at their own rates.
StandardSpeechAudioTokens int `json:"standard_speech_audio_tokens,omitempty"`
CustomSpeechAudioTokens int `json:"custom_speech_audio_tokens,omitempty"`
}

// =============================================================================
Expand Down
11 changes: 11 additions & 0 deletions docs/architecture/framework/model-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,17 @@ type PricingEntry struct {
OutputCostPerVideoPerSecond *float64 `json:"output_cost_per_video_per_second,omitempty"`
OutputCostPerSecond *float64 `json:"output_cost_per_second,omitempty"`

// Azure Voice Live rates, used on Voice Live turns in place of the matching rates above when set
InputCostPerTokenVoiceLive *float64 `json:"input_cost_per_token_voice_live,omitempty"`
OutputCostPerTokenVoiceLive *float64 `json:"output_cost_per_token_voice_live,omitempty"`
CacheReadInputTokenCostVoiceLive *float64 `json:"cache_read_input_token_cost_voice_live,omitempty"`
InputCostPerAudioTokenVoiceLive *float64 `json:"input_cost_per_audio_token_voice_live,omitempty"`
OutputCostPerAudioTokenVoiceLive *float64 `json:"output_cost_per_audio_token_voice_live,omitempty"`
InputCostPerStandardSpeechAudioTokenVoiceLive *float64 `json:"input_cost_per_standard_speech_audio_token_voice_live,omitempty"`
OutputCostPerStandardSpeechAudioTokenVoiceLive *float64 `json:"output_cost_per_standard_speech_audio_token_voice_live,omitempty"`
InputCostPerCustomSpeechAudioTokenVoiceLive *float64 `json:"input_cost_per_custom_speech_audio_token_voice_live,omitempty"`
OutputCostPerCustomSpeechAudioTokenVoiceLive *float64 `json:"output_cost_per_custom_speech_audio_token_voice_live,omitempty"`

OutputCostPerVideoPerSecond480p *float64 `json:"output_cost_per_video_per_second_480p,omitempty"`
OutputCostPerVideoPerSecond720p *float64 `json:"output_cost_per_video_per_second_720p,omitempty"`
OutputCostPerVideoPerSecond1024p *float64 `json:"output_cost_per_video_per_second_1024p,omitempty"`
Expand Down
34 changes: 34 additions & 0 deletions docs/openapi/schemas/management/governance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2494,6 +2494,40 @@ PricingPatch:
output_cost_per_second:
type: number
minimum: 0
input_cost_per_token_voice_live:
type: number
minimum: 0
description: >-
Azure Voice Live input rate per text token, used on Voice Live turns in place of
input_cost_per_token. Each _voice_live rate falls back to its normal rate when unset.
output_cost_per_token_voice_live:
type: number
minimum: 0
cache_read_input_token_cost_voice_live:
type: number
minimum: 0
input_cost_per_audio_token_voice_live:
type: number
minimum: 0
output_cost_per_audio_token_voice_live:
type: number
minimum: 0
input_cost_per_standard_speech_audio_token_voice_live:
type: number
minimum: 0
description: >-
Azure Voice Live rate per input audio token handled by Azure speech to text.
output_cost_per_standard_speech_audio_token_voice_live:
type: number
minimum: 0
description: >-
Azure Voice Live rate per output audio token from an Azure standard voice.
input_cost_per_custom_speech_audio_token_voice_live:
type: number
minimum: 0
output_cost_per_custom_speech_audio_token_voice_live:
type: number
minimum: 0
output_cost_per_video_per_second_480p:
type: number
minimum: 0
Expand Down
Loading
Loading