436 lines
16 KiB
Go
436 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/maximhq/bifrost/core/providers/openai"
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
bfws "github.com/maximhq/bifrost/transports/bifrost-http/websocket"
|
|
)
|
|
|
|
func TestShouldAccumulateRealtimeOutput(t *testing.T) {
|
|
provider := &openai.OpenAIProvider{}
|
|
if !provider.ShouldAccumulateRealtimeOutput(schemas.RTEventResponseTextDelta) {
|
|
t.Fatal("expected response.text.delta to accumulate output text")
|
|
}
|
|
if !provider.ShouldAccumulateRealtimeOutput(schemas.RTEventResponseAudioTransDelta) {
|
|
t.Fatal("expected response.audio_transcript.delta to accumulate output transcript")
|
|
}
|
|
if provider.ShouldAccumulateRealtimeOutput(schemas.RTEventInputAudioTransDelta) {
|
|
t.Fatal("did not expect input audio transcription delta to accumulate assistant output")
|
|
}
|
|
}
|
|
|
|
func TestExtractRealtimeTurnSummary(t *testing.T) {
|
|
event := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreate,
|
|
Item: &schemas.RealtimeItem{
|
|
Content: []byte(`[{"type":"input_text","text":"hello from realtime"}]`),
|
|
},
|
|
}
|
|
|
|
got := extractRealtimeTurnSummary(event, "")
|
|
if got != "hello from realtime" {
|
|
t.Fatalf("extractRealtimeTurnSummary() = %q, want %q", got, "hello from realtime")
|
|
}
|
|
}
|
|
|
|
func TestFinalizedRealtimeInputSummary(t *testing.T) {
|
|
userCreate := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreate,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Content: []byte(`[{"type":"input_text","text":"hello from browser"}]`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userCreate); got != "hello from browser" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user create) = %q, want %q", got, "hello from browser")
|
|
}
|
|
|
|
userRetrieved := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemRetrieved,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Content: []byte(`[{"type":"input_text","text":"hello from retrieved item"}]`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userRetrieved); got != "hello from retrieved item" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user retrieved) = %q, want %q", got, "hello from retrieved item")
|
|
}
|
|
|
|
userCreated := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreated,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Content: []byte(`[{"type":"input_text","text":"hello from provider created item"}]`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userCreated); got != "hello from provider created item" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user created) = %q, want %q", got, "hello from provider created item")
|
|
}
|
|
|
|
userAdded := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemAdded,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Content: []byte(`[{"type":"input_text","text":"hello from provider added item"}]`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userAdded); got != "hello from provider added item" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user added) = %q, want %q", got, "hello from provider added item")
|
|
}
|
|
|
|
userCreatedWithoutTranscript := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreated,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Type: "message",
|
|
Content: []byte(`[{"type":"input_audio","audio":null,"transcript":null}]`),
|
|
},
|
|
RawData: []byte(`{"type":"conversation.item.created","item":{"type":"message","role":"user","content":[{"type":"input_audio","audio":null,"transcript":null}]}}`),
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userCreatedWithoutTranscript); got != "" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user created without transcript) = %q, want empty", got)
|
|
}
|
|
|
|
userDoneWithoutTranscript := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemDone,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "user",
|
|
Type: "message",
|
|
Status: "completed",
|
|
Content: []byte(`[{"type":"input_audio","audio":null,"transcript":null}]`),
|
|
},
|
|
RawData: []byte(`{"type":"conversation.item.done","item":{"type":"message","role":"user","status":"completed","content":[{"type":"input_audio","audio":null,"transcript":null}]}}`),
|
|
}
|
|
if got := finalizedRealtimeInputSummary(userDoneWithoutTranscript); got != realtimeMissingTranscriptText {
|
|
t.Fatalf("finalizedRealtimeInputSummary(user done without transcript) = %q, want %q", got, realtimeMissingTranscriptText)
|
|
}
|
|
|
|
inputTranscript := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventInputAudioTransCompleted,
|
|
ExtraParams: map[string]json.RawMessage{
|
|
"transcript": json.RawMessage(`"spoken user turn"`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(inputTranscript); got != "spoken user turn" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(input transcript) = %q, want %q", got, "spoken user turn")
|
|
}
|
|
|
|
emptyInputTranscript := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventInputAudioTransCompleted,
|
|
ExtraParams: map[string]json.RawMessage{
|
|
"transcript": json.RawMessage(`""`),
|
|
},
|
|
RawData: []byte(`{"type":"conversation.item.input_audio_transcription.completed","transcript":"","usage":{"total_tokens":11}}`),
|
|
}
|
|
if got := finalizedRealtimeInputSummary(emptyInputTranscript); got != realtimeMissingTranscriptText {
|
|
t.Fatalf("finalizedRealtimeInputSummary(empty input transcript) = %q, want %q", got, realtimeMissingTranscriptText)
|
|
}
|
|
|
|
missingInputTranscript := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventInputAudioTransCompleted,
|
|
RawData: []byte(`{"type":"conversation.item.input_audio_transcription.completed","usage":{"total_tokens":11}}`),
|
|
}
|
|
if got := finalizedRealtimeInputSummary(missingInputTranscript); got != realtimeMissingTranscriptText {
|
|
t.Fatalf("finalizedRealtimeInputSummary(missing input transcript) = %q, want %q", got, realtimeMissingTranscriptText)
|
|
}
|
|
|
|
assistantCreate := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreate,
|
|
Item: &schemas.RealtimeItem{
|
|
Role: "assistant",
|
|
Content: []byte(`[{"type":"text","text":"assistant text"}]`),
|
|
},
|
|
}
|
|
if got := finalizedRealtimeInputSummary(assistantCreate); got != "" {
|
|
t.Fatalf("finalizedRealtimeInputSummary(assistant create) = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestFinalizedRealtimeToolOutputSummary(t *testing.T) {
|
|
event := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreate,
|
|
Item: &schemas.RealtimeItem{
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"tool result"}`,
|
|
},
|
|
}
|
|
if got := finalizedRealtimeToolOutputSummary(event); got != `{"nextResponse":"tool result"}` {
|
|
t.Fatalf("finalizedRealtimeToolOutputSummary() = %q, want %q", got, `{"nextResponse":"tool result"}`)
|
|
}
|
|
|
|
retrieved := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemRetrieved,
|
|
Item: &schemas.RealtimeItem{
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"tool result from retrieved"}`,
|
|
},
|
|
}
|
|
if got := finalizedRealtimeToolOutputSummary(retrieved); got != `{"nextResponse":"tool result from retrieved"}` {
|
|
t.Fatalf("finalizedRealtimeToolOutputSummary(retrieved) = %q, want %q", got, `{"nextResponse":"tool result from retrieved"}`)
|
|
}
|
|
|
|
created := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemCreated,
|
|
Item: &schemas.RealtimeItem{
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"tool result from created"}`,
|
|
},
|
|
}
|
|
if got := finalizedRealtimeToolOutputSummary(created); got != `{"nextResponse":"tool result from created"}` {
|
|
t.Fatalf("finalizedRealtimeToolOutputSummary(created) = %q, want %q", got, `{"nextResponse":"tool result from created"}`)
|
|
}
|
|
|
|
added := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemAdded,
|
|
Item: &schemas.RealtimeItem{
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"tool result from added"}`,
|
|
},
|
|
}
|
|
if got := finalizedRealtimeToolOutputSummary(added); got != `{"nextResponse":"tool result from added"}` {
|
|
t.Fatalf("finalizedRealtimeToolOutputSummary(added) = %q, want %q", got, `{"nextResponse":"tool result from added"}`)
|
|
}
|
|
}
|
|
|
|
func TestPendingRealtimeInputUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
transcriptEvent := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventInputAudioTransCompleted,
|
|
ExtraParams: map[string]json.RawMessage{
|
|
"item_id": json.RawMessage(`"item_123"`),
|
|
"transcript": json.RawMessage(`"Hello."`),
|
|
},
|
|
}
|
|
itemID, summary := pendingRealtimeInputUpdate(transcriptEvent)
|
|
if itemID != "item_123" || summary != "Hello." {
|
|
t.Fatalf("pendingRealtimeInputUpdate(transcript) = (%q, %q), want (%q, %q)", itemID, summary, "item_123", "Hello.")
|
|
}
|
|
|
|
retrievedEvent := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemRetrieved,
|
|
Item: &schemas.RealtimeItem{
|
|
ID: "item_123",
|
|
Role: "user",
|
|
Content: []byte(`[{"type":"input_text","text":"historical hello"}]`),
|
|
},
|
|
}
|
|
itemID, summary = pendingRealtimeInputUpdate(retrievedEvent)
|
|
if itemID != "" || summary != "" {
|
|
t.Fatalf("pendingRealtimeInputUpdate(retrieved) = (%q, %q), want empty", itemID, summary)
|
|
}
|
|
}
|
|
|
|
func TestPendingRealtimeToolOutputUpdate(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
toolOutputEvent := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemDone,
|
|
Item: &schemas.RealtimeItem{
|
|
ID: "item_tool_123",
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"tool result"}`,
|
|
},
|
|
}
|
|
itemID, summary := pendingRealtimeToolOutputUpdate(toolOutputEvent)
|
|
if itemID != "item_tool_123" || summary != `{"nextResponse":"tool result"}` {
|
|
t.Fatalf("pendingRealtimeToolOutputUpdate(done) = (%q, %q), want (%q, %q)", itemID, summary, "item_tool_123", `{"nextResponse":"tool result"}`)
|
|
}
|
|
|
|
retrievedToolOutputEvent := &schemas.BifrostRealtimeEvent{
|
|
Type: schemas.RTEventConversationItemRetrieved,
|
|
Item: &schemas.RealtimeItem{
|
|
ID: "item_tool_123",
|
|
Type: "function_call_output",
|
|
Output: `{"nextResponse":"historical tool result"}`,
|
|
},
|
|
}
|
|
itemID, summary = pendingRealtimeToolOutputUpdate(retrievedToolOutputEvent)
|
|
if itemID != "" || summary != "" {
|
|
t.Fatalf("pendingRealtimeToolOutputUpdate(retrieved) = (%q, %q), want empty", itemID, summary)
|
|
}
|
|
}
|
|
|
|
func TestBuildRealtimeTurnPostResponseUsesFullResponseDonePayload(t *testing.T) {
|
|
rawRequest := `{"type":"conversation.item.input_audio_transcription.completed","transcript":""}`
|
|
rawResponse := []byte(`{
|
|
"type":"response.done",
|
|
"response":{
|
|
"output":[
|
|
{
|
|
"id":"item_message_123",
|
|
"type":"message",
|
|
"content":[
|
|
{
|
|
"type":"audio",
|
|
"transcript":"assistant turn text"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"usage":{
|
|
"total_tokens":26,
|
|
"input_tokens":17,
|
|
"output_tokens":9,
|
|
"input_token_details":{
|
|
"text_tokens":12,
|
|
"audio_tokens":5,
|
|
"image_tokens":0,
|
|
"cached_tokens":4
|
|
},
|
|
"output_token_details":{
|
|
"text_tokens":7,
|
|
"audio_tokens":2
|
|
}
|
|
}
|
|
}
|
|
}`)
|
|
|
|
resp := buildRealtimeTurnPostResponse(&openai.OpenAIProvider{}, schemas.OpenAI, "gpt-4o-realtime-preview-2025-06-03", rawRequest, rawResponse, "", 4321)
|
|
if resp == nil || resp.ResponsesResponse == nil {
|
|
t.Fatal("expected realtime post response to be built")
|
|
}
|
|
if resp.ResponsesResponse.ExtraFields.Latency != 4321 {
|
|
t.Fatalf("Latency = %d, want %d", resp.ResponsesResponse.ExtraFields.Latency, 4321)
|
|
}
|
|
if resp.ResponsesResponse.Usage == nil || resp.ResponsesResponse.Usage.InputTokens != 17 || resp.ResponsesResponse.Usage.OutputTokens != 9 || resp.ResponsesResponse.Usage.TotalTokens != 26 {
|
|
t.Fatalf("Usage = %+v, want input=17 output=9 total=26", resp.ResponsesResponse.Usage)
|
|
}
|
|
if len(resp.ResponsesResponse.Output) != 1 {
|
|
t.Fatalf("len(Output) = %d, want 1", len(resp.ResponsesResponse.Output))
|
|
}
|
|
if resp.ResponsesResponse.Output[0].Content == nil || resp.ResponsesResponse.Output[0].Content.ContentStr == nil || *resp.ResponsesResponse.Output[0].Content.ContentStr != "assistant turn text" {
|
|
t.Fatalf("Output[0].Content = %+v, want assistant turn text", resp.ResponsesResponse.Output[0].Content)
|
|
}
|
|
if got, ok := resp.ResponsesResponse.ExtraFields.RawRequest.(string); !ok || got != rawRequest {
|
|
t.Fatalf("RawRequest = %#v, want %q", resp.ResponsesResponse.ExtraFields.RawRequest, rawRequest)
|
|
}
|
|
if got, ok := resp.ResponsesResponse.ExtraFields.RawResponse.(string); !ok || got == "" {
|
|
t.Fatalf("RawResponse = %#v, want raw response string", resp.ResponsesResponse.ExtraFields.RawResponse)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeRealtimeTurnHooksWithErrorCompletesActiveHooks(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
session := bfws.NewSession(nil)
|
|
session.SetProviderSessionID("sess_provider_123")
|
|
session.AddRealtimeInput("hello from user", `{"type":"conversation.item.added"}`)
|
|
session.AppendRealtimeOutputText("partial assistant output")
|
|
|
|
var (
|
|
capturedResp *schemas.BifrostResponse
|
|
capturedErr *schemas.BifrostError
|
|
cleanedUp bool
|
|
)
|
|
session.SetRealtimeTurnHooks(&bfws.RealtimeTurnPluginState{
|
|
RequestID: "req_realtime_123",
|
|
StartedAt: time.Now().Add(-time.Second),
|
|
PreHookValues: map[any]any{
|
|
schemas.BifrostContextKeyGovernanceVirtualKeyID: "vk_123",
|
|
},
|
|
PostHookRunner: func(ctx *schemas.BifrostContext, result *schemas.BifrostResponse, err *schemas.BifrostError) (*schemas.BifrostResponse, *schemas.BifrostError) {
|
|
capturedResp = result
|
|
capturedErr = err
|
|
return result, nil
|
|
},
|
|
Cleanup: func() {
|
|
cleanedUp = true
|
|
},
|
|
})
|
|
|
|
rawResponse := []byte(`{"type":"error","error":{"type":"server_error","message":"Virtual key is required."}}`)
|
|
postErr := finalizeRealtimeTurnHooksWithError(
|
|
nil,
|
|
nil,
|
|
session,
|
|
schemas.OpenAI,
|
|
"gpt-realtime",
|
|
nil,
|
|
schemas.RTEventError,
|
|
rawResponse,
|
|
newRealtimeWireBifrostError(401, "server_error", "Virtual key is required."),
|
|
)
|
|
if postErr != nil {
|
|
t.Fatalf("finalizeRealtimeTurnHooksWithError() post error = %v, want nil", postErr)
|
|
}
|
|
if capturedResp != nil {
|
|
t.Fatalf("captured response = %#v, want nil", capturedResp)
|
|
}
|
|
if capturedErr == nil {
|
|
t.Fatal("expected captured error")
|
|
}
|
|
if capturedErr.ExtraFields.RequestType != schemas.RealtimeRequest {
|
|
t.Fatalf("request type = %q, want %q", capturedErr.ExtraFields.RequestType, schemas.RealtimeRequest)
|
|
}
|
|
if capturedErr.ExtraFields.Provider != schemas.OpenAI {
|
|
t.Fatalf("provider = %q, want %q", capturedErr.ExtraFields.Provider, schemas.OpenAI)
|
|
}
|
|
if capturedErr.ExtraFields.OriginalModelRequested != "gpt-realtime" {
|
|
t.Fatalf("model requested = %q, want %q", capturedErr.ExtraFields.OriginalModelRequested, "gpt-realtime")
|
|
}
|
|
rawRequest, ok := capturedErr.ExtraFields.RawRequest.(string)
|
|
if !ok || rawRequest == "" {
|
|
t.Fatalf("raw request = %#v, want non-empty string", capturedErr.ExtraFields.RawRequest)
|
|
}
|
|
rawResp, ok := capturedErr.ExtraFields.RawResponse.(json.RawMessage)
|
|
if !ok || string(rawResp) != string(rawResponse) {
|
|
t.Fatalf("raw response = %#v, want %s", capturedErr.ExtraFields.RawResponse, string(rawResponse))
|
|
}
|
|
if session.PeekRealtimeTurnHooks() != nil {
|
|
t.Fatal("expected active hooks to be cleared")
|
|
}
|
|
if got := session.ConsumeRealtimeTurnInputs(); len(got) != 0 {
|
|
t.Fatalf("remaining turn inputs = %d, want 0", len(got))
|
|
}
|
|
if got := session.ConsumeRealtimeOutputText(); got != "" {
|
|
t.Fatalf("remaining output text = %q, want empty", got)
|
|
}
|
|
if !cleanedUp {
|
|
t.Fatal("expected realtime hook cleanup to run")
|
|
}
|
|
}
|
|
|
|
func TestNewBifrostErrorFromRealtimeErrorCarriesRealtimeMetadata(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rawResponse := []byte(`{"type":"error","error":{"type":"invalid_request_error","code":"invalid_request_error","message":"bad request","param":"session.type"}}`)
|
|
bifrostErr := newBifrostErrorFromRealtimeError(
|
|
schemas.OpenAI,
|
|
"gpt-realtime",
|
|
rawResponse,
|
|
&schemas.RealtimeError{
|
|
Type: "invalid_request_error",
|
|
Code: "invalid_request_error",
|
|
Message: "bad request",
|
|
Param: "session.type",
|
|
},
|
|
)
|
|
if bifrostErr == nil {
|
|
t.Fatal("expected bifrost error")
|
|
}
|
|
if bifrostErr.StatusCode == nil || *bifrostErr.StatusCode != 400 {
|
|
t.Fatalf("status code = %#v, want 400", bifrostErr.StatusCode)
|
|
}
|
|
if bifrostErr.ExtraFields.RequestType != schemas.RealtimeRequest {
|
|
t.Fatalf("request type = %q, want %q", bifrostErr.ExtraFields.RequestType, schemas.RealtimeRequest)
|
|
}
|
|
if bifrostErr.ExtraFields.Provider != schemas.OpenAI {
|
|
t.Fatalf("provider = %q, want %q", bifrostErr.ExtraFields.Provider, schemas.OpenAI)
|
|
}
|
|
if bifrostErr.ExtraFields.OriginalModelRequested != "gpt-realtime" {
|
|
t.Fatalf("model requested = %q, want %q", bifrostErr.ExtraFields.OriginalModelRequested, "gpt-realtime")
|
|
}
|
|
rawResp, ok := bifrostErr.ExtraFields.RawResponse.(json.RawMessage)
|
|
if !ok || string(rawResp) != string(rawResponse) {
|
|
t.Fatalf("raw response = %#v, want %s", bifrostErr.ExtraFields.RawResponse, string(rawResponse))
|
|
}
|
|
if bifrostErr.Error == nil || bifrostErr.Error.Param != "session.type" {
|
|
t.Fatalf("error param = %#v, want session.type", bifrostErr.Error)
|
|
}
|
|
}
|