diff --git a/sdk/cliproxy/auth/empty_completion.go b/sdk/cliproxy/auth/empty_completion.go index 05138c8f8..2e286de18 100644 --- a/sdk/cliproxy/auth/empty_completion.go +++ b/sdk/cliproxy/auth/empty_completion.go @@ -426,21 +426,145 @@ var openAIResponseEventTypes = map[string]bool{ "error": true, } +var interactionsEventTypes = map[string]bool{ + "interaction.created": true, + "interaction.status_update": true, + "interaction.completed": true, + "interaction.failed": true, + "finish": true, + "step.start": true, + "step.delta": true, + "step.stop": true, +} + +type interactionsChunk struct { + Object string `json:"object"` + EventType string `json:"event_type"` + Type string `json:"type"` + Status string `json:"status"` + InteractionID string `json:"interaction_id"` + Steps []interactionsStep `json:"steps"` + Step *interactionsStep `json:"step"` + Delta *interactionsDelta `json:"delta"` + Usage *interactionsUsage `json:"usage"` + Metadata *interactionsMeta `json:"metadata"` + Interaction *struct { + ID string `json:"id"` + Status string `json:"status"` + Object string `json:"object"` + Steps []interactionsStep `json:"steps"` + Usage *interactionsUsage `json:"usage"` + } `json:"interaction"` +} + +type interactionsMeta struct { + TotalUsage *interactionsUsage `json:"total_usage"` + Usage *interactionsUsage `json:"usage"` +} + +type interactionsUsage struct { + OutputTokens *tokenCount `json:"output_tokens"` + TotalOutputTokens *tokenCount `json:"total_output_tokens"` + CompletionTokens *tokenCount `json:"completion_tokens"` +} + +type interactionsStep struct { + ID string `json:"id"` + CallID string `json:"call_id"` + Type string `json:"type"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + Content []interactionsContent `json:"content"` + Result json.RawMessage `json:"result"` + Signature string `json:"signature"` + ThoughtSignature string `json:"thought_signature"` + ThoughtSignatureCamel string `json:"thoughtSignature"` + EncryptedContent string `json:"encrypted_content"` + ExtraContent *interactionsExtraContent `json:"extra_content"` +} + +// interactionsExtraContent carries the vendor-specific envelope Gemini uses to +// ship a thought signature alongside a step. +type interactionsExtraContent struct { + Google *struct { + ThoughtSignature string `json:"thought_signature"` + } `json:"google"` +} + +type interactionsContent struct { + Type string `json:"type"` + Text string `json:"text"` + Data string `json:"data"` + FileURI string `json:"file_uri"` + FileUri string `json:"fileUri"` + URL string `json:"url"` + MimeType string `json:"mime_type"` + Mime_Type string `json:"mimeType"` + Signature string `json:"signature"` + ThoughtSignature string `json:"thought_signature"` + ThoughtSignatureCamel string `json:"thoughtSignature"` +} + +func (c *interactionsContent) hasMeaningfulContent() bool { + if c == nil { + return false + } + if strings.TrimSpace(c.Text) != "" || + strings.TrimSpace(c.Data) != "" || + strings.TrimSpace(c.FileURI) != "" || + strings.TrimSpace(c.FileUri) != "" || + strings.TrimSpace(c.URL) != "" { + return true + } + // thoughtSignature / signature / encrypted_content alone is replay + // metadata, not a usable completion. + return false +} + +type interactionsDelta struct { + Type string `json:"type"` + Text string `json:"text"` + Data string `json:"data"` + FileURI string `json:"file_uri"` + FileUri string `json:"fileUri"` + URL string `json:"url"` + Arguments json.RawMessage `json:"arguments"` + Signature string `json:"signature"` + ThoughtSignature string `json:"thought_signature"` + ThoughtSignatureCamel string `json:"thoughtSignature"` + Name string `json:"name"` + Content *interactionsContent `json:"content"` + Result json.RawMessage `json:"result"` +} + +func hasMeaningfulInteractionsArguments(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { + return false + } + var str string + if err := json.Unmarshal(trimmed, &str); err == nil { + return hasMeaningfulJSONArguments(str) + } + return nonEmptyJSONPayload(raw) +} + // emptyCompletionAccum accumulates the properties relevant to deciding whether // an OpenAI-, Claude-, or Gemini-style completion is empty. type emptyCompletionAccum struct { - recognized bool - sawUnknownData bool - terminal bool - hasContent bool - hasToolCalls bool - completionTokens int - sawUsage bool - blocked bool - sawMetadataOnly bool - sawMessageData bool - geminiTerminal bool - claudeTerminal bool + recognized bool + sawUnknownData bool + terminal bool + hasContent bool + hasToolCalls bool + completionTokens int + sawUsage bool + blocked bool + sawMetadataOnly bool + sawMessageData bool + geminiTerminal bool + claudeTerminal bool + interactionsTerminal bool } func (a *emptyCompletionAccum) evalJSON(data []byte) bool { @@ -450,7 +574,7 @@ func (a *emptyCompletionAccum) evalJSON(data []byte) bool { } recognized := false for _, v := range values { - if a.evalOpenAI(v) || a.evalClaude(v) || a.evalOpenAIResponse(v) || a.evalGemini(v) { + if a.evalOpenAI(v) || a.evalClaude(v) || a.evalOpenAIResponse(v) || a.evalGemini(v) || a.evalInteractions(v) { recognized = true } else { a.sawUnknownData = true @@ -985,6 +1109,172 @@ func (a *emptyCompletionAccum) evalGemini(data []byte) bool { return true } +func (a *emptyCompletionAccum) evalInteractions(data []byte) bool { + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + return false + } + + var evType string + if raw := probe["event_type"]; raw != nil { + _ = json.Unmarshal(raw, &evType) + } + if evType == "" { + if raw := probe["type"]; raw != nil { + _ = json.Unmarshal(raw, &evType) + } + } + + var objName string + if raw := probe["object"]; raw != nil { + _ = json.Unmarshal(raw, &objName) + } + + isInteractions := objName == "interaction" || + interactionsEventTypes[evType] || + hasJSONKey(data, "interaction") || + (hasJSONKey(data, "steps") && (hasJSONKey(data, "status") || hasJSONKey(data, "interaction_id"))) + + if !isInteractions { + return false + } + + a.recognized = true + a.sawMessageData = true + + var chunk interactionsChunk + if err := json.Unmarshal(data, &chunk); err != nil { + return true + } + + status := chunk.Status + if chunk.Interaction != nil && chunk.Interaction.Status != "" { + status = chunk.Interaction.Status + } + + switch strings.ToLower(strings.TrimSpace(status)) { + case "completed": + a.terminal = true + a.interactionsTerminal = true + case "failed", "cancelled", "error", "blocked", "incomplete": + a.terminal = true + a.blocked = true + case "requires_action": + a.terminal = true + a.blocked = true + } + + if evType == "interaction.completed" { + a.terminal = true + if !a.blocked { + a.interactionsTerminal = true + } + } else if evType == "finish" { + // The Interactions protocol ends a turn with a bare "finish" event whose + // usage lives under metadata.total_usage instead of the top-level usage + // field the other terminal events carry. + a.terminal = true + if !a.blocked { + a.interactionsTerminal = true + } + if chunk.Metadata != nil { + if chunk.Metadata.TotalUsage != nil { + a.evalInteractionsUsage(chunk.Metadata.TotalUsage) + } else { + a.evalInteractionsUsage(chunk.Metadata.Usage) + } + } + } else if evType == "interaction.failed" { + a.terminal = true + a.blocked = true + } + + a.evalInteractionsUsage(chunk.Usage) + if chunk.Interaction != nil { + a.evalInteractionsUsage(chunk.Interaction.Usage) + } + + if len(chunk.Steps) == 0 && chunk.Interaction != nil { + a.evalInteractionsSteps(chunk.Interaction.Steps) + } else { + a.evalInteractionsSteps(chunk.Steps) + } + if chunk.Step != nil { + a.evalInteractionsSteps([]interactionsStep{*chunk.Step}) + } + + if chunk.Delta != nil { + if strings.TrimSpace(chunk.Delta.Text) != "" || + strings.TrimSpace(chunk.Delta.Data) != "" || + strings.TrimSpace(chunk.Delta.FileURI) != "" || + strings.TrimSpace(chunk.Delta.FileUri) != "" || + strings.TrimSpace(chunk.Delta.URL) != "" { + a.hasContent = true + } + if chunk.Delta.Content != nil && chunk.Delta.Content.hasMeaningfulContent() { + a.hasContent = true + } + if strings.TrimSpace(chunk.Delta.Name) != "" || hasMeaningfulInteractionsArguments(chunk.Delta.Arguments) { + a.hasToolCalls = true + } + if nonEmptyJSONPayload(chunk.Delta.Result) { + a.hasContent = true + } + } + + return true +} + +func (a *emptyCompletionAccum) evalInteractionsUsage(usage *interactionsUsage) { + if usage == nil { + return + } + if usage.OutputTokens != nil { + a.sawUsage = true + a.addUsage(*usage.OutputTokens) + } + if usage.TotalOutputTokens != nil { + a.sawUsage = true + a.addUsage(*usage.TotalOutputTokens) + } + if usage.CompletionTokens != nil { + a.sawUsage = true + a.addUsage(*usage.CompletionTokens) + } +} + +func (a *emptyCompletionAccum) evalInteractionsSteps(steps []interactionsStep) { + for _, step := range steps { + stepType := strings.ToLower(strings.TrimSpace(step.Type)) + switch stepType { + case "function_call": + if strings.TrimSpace(step.Name) != "" || hasMeaningfulInteractionsArguments(step.Arguments) { + a.hasToolCalls = true + } + case "function_result": + if strings.TrimSpace(step.Name) != "" || nonEmptyJSONPayload(step.Result) { + a.hasContent = true + } + default: + if strings.TrimSpace(step.Name) != "" || hasMeaningfulInteractionsArguments(step.Arguments) { + a.hasToolCalls = true + } + if nonEmptyJSONPayload(step.Result) { + a.hasContent = true + } + } + // A signature-only step is not a usable completion: thoughtSignature + // / encrypted_content can be replay metadata on an upstream that + // returned nothing. Visible text, a tool call, or positive token + // usage still keep the completion from being classified as empty. + for _, content := range step.Content { + if content.hasMeaningfulContent() { + a.hasContent = true + } + } + } +} + // empty reports whether the accumulated stream is an empty completion. func (a *emptyCompletionAccum) empty() bool { if a.sawUnknownData || a.blocked || a.hasContent || a.hasToolCalls || (a.sawUsage && a.completionTokens > 0) { @@ -1205,7 +1495,7 @@ func (s *streamBootstrapState) isEmptyCompletion() bool { } func (s *streamBootstrapState) isTerminalEmpty() bool { - return (s.sawDone || s.acc.geminiTerminal || s.acc.claudeTerminal) && s.acc.empty() + return (s.sawDone || s.acc.geminiTerminal || s.acc.claudeTerminal || s.acc.interactionsTerminal) && s.acc.empty() } func (s *streamBootstrapState) hasMeaningfulOutput() bool { diff --git a/sdk/cliproxy/auth/empty_completion_test.go b/sdk/cliproxy/auth/empty_completion_test.go index 895524e21..0a829df4c 100644 --- a/sdk/cliproxy/auth/empty_completion_test.go +++ b/sdk/cliproxy/auth/empty_completion_test.go @@ -31,6 +31,11 @@ type emptyCompletionTestExecutor struct { // firstStream records the first auth picked for a stream execution. firstStream string + // emptyPayload/contentPayload override the default first-auth empty payload + // and subsequent-auth content payload (used to exercise non-OpenAI formats). + emptyPayload []byte + contentPayload []byte + // emptyStreamPayload/contentStreamPayload override the default first-auth // empty stream and subsequent-auth content stream (used to exercise // non-OpenAI stream formats). @@ -59,12 +64,20 @@ func (e *emptyCompletionTestExecutor) Execute(ctx context.Context, auth *Auth, _ // returns real content. This guarantees the rotation test exercises the // empty-completion failure path regardless of global selector state. if len(e.executePayloads) == 0 && e.firstExecute == auth.ID { - return cliproxyexecutor.Response{Payload: []byte(`{"choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"completion_tokens":0}}`)}, nil + empty := e.emptyPayload + if len(empty) == 0 { + empty = []byte(`{"choices":[{"message":{"content":""},"finish_reason":"stop"}],"usage":{"completion_tokens":0}}`) + } + return cliproxyexecutor.Response{Payload: empty}, nil } if p, ok := e.executePayloads[auth.ID]; ok { return cliproxyexecutor.Response{Payload: p}, nil } - return cliproxyexecutor.Response{Payload: []byte(`{"choices":[{"message":{"content":"real"},"finish_reason":"stop"}]}`)}, nil + content := e.contentPayload + if len(content) == 0 { + content = []byte(`{"choices":[{"message":{"content":"real"},"finish_reason":"stop"}]}`) + } + return cliproxyexecutor.Response{Payload: content}, nil } func (e *emptyCompletionTestExecutor) CountTokens(ctx context.Context, auth *Auth, _ cliproxyexecutor.Request, _ cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { @@ -674,6 +687,265 @@ func TestEmptyCompletionPredicate(t *testing.T) { }) } } + +func TestEmptyCompletionPredicateInteractions(t *testing.T) { + cases := []struct { + name string + payload []byte + expected bool + }{ + { + name: "interactions json empty steps is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[]}`), + expected: true, + }, + { + name: "interactions json empty steps zero usage is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[],"usage":{"output_tokens":0,"total_output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json with model_output text is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"hello"}]}]}`), + expected: false, + }, + { + name: "interactions json with function_call is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"function_call","name":"get_weather","arguments":{"location":"Paris"}}]}`), + expected: false, + }, + { + name: "interactions json with output tokens is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[],"usage":{"output_tokens":5}}`), + expected: false, + }, + { + name: "interactions sse stream scaffold and empty completion is empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\nevent: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_1\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":0}}}\n\n"), + expected: true, + }, + { + name: "interactions sse stream with step delta is not empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"text\",\"text\":\"hello\"}}\n\nevent: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_1\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":1}}}\n\n"), + expected: false, + }, + { + name: "interactions json with media data is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"type":"image","mime_type":"image/png","data":"iVBORw0KGgo="}]}]}`), + expected: false, + }, + { + name: "interactions json with file_uri is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"type":"document","file_uri":"files/123"}]}]}`), + expected: false, + }, + { + name: "interactions sse stream with media delta is not empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"content\",\"content\":{\"type\":\"image\",\"data\":\"iVBORw0KGgo=\"}}}\n\nevent: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_1\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":0}}}\n\n"), + expected: false, + }, + { + name: "interactions sse stream ending in bare finish with zero output is empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\nevent: finish\ndata: {\"event_type\":\"finish\",\"metadata\":{\"total_usage\":{\"total_output_tokens\":0}}}\n\n"), + expected: true, + }, + { + name: "interactions sse stream ending in bare finish with output is not empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\nevent: finish\ndata: {\"event_type\":\"finish\",\"metadata\":{\"total_usage\":{\"total_output_tokens\":7}}}\n\n"), + expected: false, + }, + { + name: "interactions json thoughtSignature only zero usage is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","thoughtSignature":"opaque-dead-upstream"}],"usage":{"output_tokens":0,"total_output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json thought_signature only zero usage is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","thought_signature":"opaque-dead-upstream"}],"usage":{"output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json encrypted_content only zero usage is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","encrypted_content":"gAAAA_dead"}],"usage":{"output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json extra_content google thought_signature only is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","extra_content":{"google":{"thought_signature":"opaque-dead-upstream"}}}],"usage":{"output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json content thoughtSignature only is empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"thoughtSignature":"opaque-dead-upstream"}]}],"usage":{"output_tokens":0}}`), + expected: true, + }, + { + name: "interactions json thoughtSignature with text is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","thoughtSignature":"opaque","content":[{"type":"text","text":"hello"}]}]}`), + expected: false, + }, + { + name: "interactions json thoughtSignature with positive usage is not empty", + payload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","thoughtSignature":"opaque"}],"usage":{"output_tokens":3}}`), + expected: false, + }, + { + name: "interactions sse thoughtSignature delta only zero usage is empty", + payload: []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\nevent: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"thoughtSignature\":\"opaque-dead-upstream\"}}\n\nevent: finish\ndata: {\"event_type\":\"finish\",\"metadata\":{\"total_usage\":{\"total_output_tokens\":0}}}\n\n"), + expected: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isEmptyCompletionPayload(tc.payload); got != tc.expected { + t.Fatalf("isEmptyCompletionPayload() = %v, want %v", got, tc.expected) + } + }) + } +} + +func TestExecuteInteractionsMeaningfulOutputNotRotated(t *testing.T) { + t.Run("step with text content", func(t *testing.T) { + executor := &emptyCompletionTestExecutor{ + executePayloads: map[string][]byte{}, + executeCalls: map[string]int{}, + emptyPayload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"meaningful text"}]}]}`), + } + manager, ids, model, capture := newEmptyCompletionTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(string(resp.Payload), "meaningful text") { + t.Fatalf("resp.Payload = %s, want meaningful text", string(resp.Payload)) + } + if len(capture.Results()) != 1 || !capture.Results()[0].Success || capture.Results()[0].AuthID != executor.firstExecute { + t.Fatalf("first auth should succeed without rotation, results: %+v, first: %s, ids: %v", capture.Results(), executor.firstExecute, ids) + } + }) + + t.Run("step with function_call", func(t *testing.T) { + executor := &emptyCompletionTestExecutor{ + executePayloads: map[string][]byte{}, + executeCalls: map[string]int{}, + emptyPayload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"function_call","name":"get_weather","arguments":{"location":"Tokyo"}}]}`), + } + manager, _, model, capture := newEmptyCompletionTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if !strings.Contains(string(resp.Payload), "get_weather") { + t.Fatalf("resp.Payload = %s, want get_weather", string(resp.Payload)) + } + if len(capture.Results()) != 1 || !capture.Results()[0].Success || capture.Results()[0].AuthID != executor.firstExecute { + t.Fatalf("first auth should succeed without rotation, results: %+v", capture.Results()) + } + }) + + t.Run("non-zero usage with empty steps", func(t *testing.T) { + executor := &emptyCompletionTestExecutor{ + executePayloads: map[string][]byte{}, + executeCalls: map[string]int{}, + emptyPayload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[],"usage":{"output_tokens":3,"total_output_tokens":3}}`), + } + manager, _, model, capture := newEmptyCompletionTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + _ = resp + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if len(capture.Results()) != 1 || !capture.Results()[0].Success || capture.Results()[0].AuthID != executor.firstExecute { + t.Fatalf("first auth should succeed without rotation, results: %+v", capture.Results()) + } + }) +} + +func TestExecuteInteractionsSignatureOnlyRotatesAuth(t *testing.T) { + executor := &emptyCompletionTestExecutor{ + executePayloads: map[string][]byte{}, + executeCalls: map[string]int{}, + emptyPayload: []byte(`{"id":"interaction_1","object":"interaction","status":"completed","steps":[{"type":"model_output","thoughtSignature":"opaque-dead-upstream"}],"usage":{"output_tokens":0,"total_output_tokens":0}}`), + contentPayload: []byte(`{"id":"interaction_2","object":"interaction","status":"completed","steps":[{"type":"model_output","content":[{"type":"text","text":"rotated-live"}]}]}`), + } + manager, ids, model, capture := newEmptyCompletionTestManager(t, executor) + + resp, err := manager.Execute(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{}) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertRotatesToContent(t, ids, executor.firstExecute, string(resp.Payload), "rotated-live", capture) +} + +func TestStreamBootstrapDetectorInteractionsScaffoldAllowsFailover(t *testing.T) { + t.Run("scaffold events do not commit stream and classify as empty on completion", func(t *testing.T) { + var detector StreamBootstrapDetector + chunk1 := []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\n") + chunk2 := []byte("event: interaction.status_update\ndata: {\"event_type\":\"interaction.status_update\",\"interaction_id\":\"int_1\",\"status\":\"in_progress\"}\n\n") + chunk3 := []byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\n") + chunk4 := []byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n") + chunk5 := []byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_1\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":0}}}\n\n") + + if detector.Observe(chunk1) { + t.Fatal("Observe(interaction.created) = true, want false") + } + if detector.Observe(chunk2) { + t.Fatal("Observe(interaction.status_update) = true, want false") + } + if detector.Observe(chunk3) { + t.Fatal("Observe(step.start) = true, want false") + } + if detector.Observe(chunk4) { + t.Fatal("Observe(step.stop) = true, want false") + } + if detector.Observe(chunk5) { + t.Fatal("Observe(interaction.completed empty) = true, want false") + } + if !detector.Finish() { + t.Fatal("detector.Finish() = false, want true (empty completion)") + } + }) + + t.Run("scaffold stream rotates auth when upstream fails over", func(t *testing.T) { + executor := &emptyCompletionTestExecutor{ + streamPayloads: map[string][][]byte{}, + streamCalls: map[string]int{}, + emptyStreamPayload: [][]byte{ + []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_1\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\n"), + []byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\n"), + []byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_1\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":0}}}\n\n"), + }, + contentStreamPayload: [][]byte{ + []byte("event: interaction.created\ndata: {\"event_type\":\"interaction.created\",\"interaction\":{\"id\":\"int_2\",\"object\":\"interaction\",\"status\":\"in_progress\"}}\n\n"), + []byte("event: step.start\ndata: {\"event_type\":\"step.start\",\"index\":0,\"step\":{\"type\":\"model_output\"}}\n\n"), + []byte("event: step.delta\ndata: {\"event_type\":\"step.delta\",\"index\":0,\"delta\":{\"type\":\"text\",\"text\":\"hello from stream\"}}\n\n"), + []byte("event: step.stop\ndata: {\"event_type\":\"step.stop\",\"index\":0}\n\n"), + []byte("event: interaction.completed\ndata: {\"event_type\":\"interaction.completed\",\"interaction\":{\"id\":\"int_2\",\"status\":\"completed\",\"steps\":[],\"usage\":{\"output_tokens\":5}}}\n\n"), + }, + } + manager, ids, model, capture := newEmptyCompletionTestManager(t, executor) + + stream, err := manager.ExecuteStream(context.Background(), []string{"claude"}, cliproxyexecutor.Request{Model: model}, cliproxyexecutor.Options{Stream: true}) + if err != nil { + t.Fatalf("ExecuteStream() error = %v", err) + } + if stream == nil { + t.Fatal("ExecuteStream() returned nil stream") + } + var got strings.Builder + for chunk := range stream.Chunks { + got.Write(chunk.Payload) + } + assertRotatesToContent(t, ids, executor.firstStream, got.String(), "hello from stream", capture) + }) +} + func TestEmptyCompletionTolerantUsage(t *testing.T) { cases := []struct { name string