package openai
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
func TestChatCompletionsRetriesUnknownTextToolCallBeforeResponse(t *testing.T) {
// Attempt 1: unknown tool call (triggers retry)
run1Events := make(chan *iop.RunEvent, 2)
run1Events <- &iop.RunEvent{Type: "delta", Delta: "true"}
run1Events <- &iop.RunEvent{Type: "complete"}
// Attempt 2: valid tool call (succeeds)
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "[\"ls\"]\n"}
run2Events <- &iop.RunEvent{Type: "complete"}
fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}}}}}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Choices) == 0 {
t.Fatal("empty choices")
}
choice := resp.Choices[0]
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
t.Fatalf("expected synthesized tool calls: %+v", choice)
}
}
func TestChatCompletionsFailsUnknownTextToolCallAfterRetryLimit(t *testing.T) {
run1Events := make(chan *iop.RunEvent, 2)
run1Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
run1Events <- &iop.RunEvent{Type: "complete"}
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
run2Events <- &iop.RunEvent{Type: "complete"}
fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}]
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
}
var resp errorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Error.Type != "tool_validation_error" {
t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type)
}
}
func TestChatCompletionsProviderStreamSynthesizesTextToolCalls(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "thought text "}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "[\"echo 1\"]"}
fake.events <- &iop.RunEvent{Type: "complete"}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"metadata":{},
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
"stream": true
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, `"tool_calls"`) {
t.Fatalf("expected stream to contain tool_calls: %s", body)
}
if strings.Contains(body, `""`) {
t.Fatalf("stream should not contain raw tool_call block: %s", body)
}
}
func TestChatCompletionsProviderStreamBlocksUnknownTextToolCall(t *testing.T) {
invalidRun := func() chan *iop.RunEvent {
return bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "thought text "},
&iop.RunEvent{Type: "delta", Delta: "val"},
&iop.RunEvent{Type: "complete"},
)
}
fake := &fakeRunService{
eventRuns: []chan *iop.RunEvent{
invalidRun(),
invalidRun(),
},
runIDs: []string{"run-bad-1", "run-bad-2"},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"metadata":{},
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}],
"stream": true
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "tool_validation_error") {
t.Fatalf("expected stream to end with tool_validation_error: %s", body)
}
if strings.Contains(body, `\u003ctool_call\u003e`) {
t.Fatalf("unknown tool call leaked as content: %s", body)
}
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
t.Fatalf("submit attempts: got %d want 2", len(reqs))
}
}
func TestChatCompletionsStrictBufferedStreamFailsUnknownTextToolCallAfterRetryLimit(t *testing.T) {
run1Events := make(chan *iop.RunEvent, 2)
run1Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
run1Events <- &iop.RunEvent{Type: "complete"}
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "val"}
run2Events <- &iop.RunEvent{Type: "complete"}
fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands"}}],
"stream": true
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.Contains(body, "tool_validation_error") {
t.Fatalf("expected stream to end with tool_validation_error: %s", body)
}
}
func TestChatCompletionsFailsMalformedTextToolCallAfterRetryLimit(t *testing.T) {
invalidComplete := func() chan *iop.RunEvent {
ch := make(chan *iop.RunEvent, 2)
ch <- &iop.RunEvent{Type: "delta", Delta: "여기 툴 콜입니다. "}
ch <- &iop.RunEvent{Type: "complete"}
return ch
}
fake := &fakeRunService{
eventRuns: []chan *iop.RunEvent{
invalidComplete(),
invalidComplete(),
},
runIDs: []string{"run-bad-1", "run-bad-2"},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"metadata":{},
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"type":"tool_validation_error"`) || !strings.Contains(w.Body.String(), "malformed tool call: unclosed tool_call block") {
t.Fatalf("expected tool validation error body, got %s", w.Body.String())
}
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
t.Fatalf("submit attempts: got %d want 2", len(reqs))
}
}
func TestChatCompletionsProviderStreamBlocksMalformedTextToolCall(t *testing.T) {
invalidRun := func() chan *iop.RunEvent {
return bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "이것은 정상 텍스트 "},
&iop.RunEvent{Type: "delta", Delta: ""},
&iop.RunEvent{Type: "complete"},
)
}
fake := &fakeRunService{
eventRuns: []chan *iop.RunEvent{
invalidRun(),
invalidRun(),
},
runIDs: []string{"run-bad-1", "run-bad-2"},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"metadata":{},
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
"tool_choice":"auto",
"stream": true
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if strings.Contains(body, "["ls"]"/a/b"`}
ch <- &iop.RunEvent{Type: "complete"}
return ch
}
fake := &fakeRunService{
eventRuns: []chan *iop.RunEvent{
invalidComplete(),
invalidComplete(),
},
runIDs: []string{"run-bad-1", "run-bad-2"},
}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"metadata":{},
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[
{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}},
{"type":"function","function":{"name":"read_file","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}
],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusBadGateway {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"type":"tool_validation_error"`) {
t.Fatalf("expected tool validation error body, got %s", w.Body.String())
}
}
// TestChatCompletionsProviderPoolNormalizedToolValidationRetryPreservesPoolRequest
// verifies SDD S01: when a provider-pool normalized run fails tool validation,
// the retry calls SubmitProviderPool (not SubmitRun) to preserve ModelGroupKey,
// provider-pool metadata, and input. The retry metadata includes
// iop_tool_validation_attempt=2.
func TestChatCompletionsProviderPoolNormalizedToolValidationRetryPreservesPoolRequest(t *testing.T) {
badToolEvent := &iop.RunEvent{
Type: "complete",
Metadata: map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
},
}
goodToolEvent := &iop.RunEvent{
Type: "complete",
Metadata: map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
},
}
badRun := &fakeRunResult{
dispatch: edgeservice.RunDispatch{
RunID: "run-pool-bad",
NodeID: "node-pool",
ModelGroupKey: "pool-group",
Adapter: "ollama",
Target: "served",
},
events: bufferedRunEvents(badToolEvent),
}
goodRun := &fakeRunResult{
dispatch: edgeservice.RunDispatch{
RunID: "run-pool-good",
NodeID: "node-pool",
ModelGroupKey: "pool-group",
Adapter: "ollama",
Target: "served",
},
events: bufferedRunEvents(goodToolEvent),
}
fake := &providerFakeRunService{
poolSubmitResults: []edgeservice.ProviderPoolDispatchResult{
{Path: edgeservice.ProviderPoolPathNormalized, Run: badRun, DispatchInfo: edgeservice.RunDispatch{RunID: "run-pool-bad", NodeID: "node-pool", ModelGroupKey: "pool-group", Adapter: "ollama", Target: "served"}},
{Path: edgeservice.ProviderPoolPathNormalized, Run: goodRun, DispatchInfo: edgeservice.RunDispatch{RunID: "run-pool-good", NodeID: "node-pool", ModelGroupKey: "pool-group", Adapter: "ollama", Target: "served"}},
},
}
catalog := []config.ModelCatalogEntry{
{ID: "pool-ollama-model", Providers: map[string]string{"prov-ollama": "qwen3.6:35b"}},
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-ollama-model",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
// Two SubmitProviderPool calls: initial + 1 retry.
reqs := fake.reqsSnapshot()
if len(reqs) != 2 {
t.Fatalf("expected 2 SubmitRun (initial + retry), got %d", len(reqs))
}
// Both must be provider-pool dispatches.
if !reqs[0].ProviderPool {
t.Error("first req must have ProviderPool=true")
}
if !reqs[1].ProviderPool {
t.Error("retry req must have ProviderPool=true")
}
// The model field must be preserved (submitted via SubmitProviderPool, not SubmitRun).
if reqs[0].ModelGroupKey != "pool-ollama-model" {
t.Errorf("first req ModelGroupKey: got %q", reqs[0].ModelGroupKey)
}
if reqs[1].ModelGroupKey != "pool-ollama-model" {
t.Errorf("retry req ModelGroupKey: got %q", reqs[1].ModelGroupKey)
}
// Retry metadata must indicate attempt 2.
if got := reqs[1].Metadata[runtimeMetadataToolValidationAttempt]; got != "2" {
t.Errorf("retry attempt metadata: got %q", got)
}
if got := reqs[1].Metadata[runtimeMetadataToolValidationRetryOf]; got != "run-pool-bad" {
t.Errorf("retry retry-of metadata: got %q", got)
}
}
// TestChatCompletionsProviderPoolToolValidationRetryRejectsTunnelResult verifies
// that when a provider-pool retry returns a tunnel-path result (which would
// cause a nil Run panic downstream), the handler closes the tunnel handle and
// returns a tool_validation_retry_error instead of panicking.
func TestChatCompletionsProviderPoolToolValidationRetryRejectsTunnelResult(t *testing.T) {
// First run: produces a tool validation failure.
badToolEvent := &iop.RunEvent{
Type: "complete",
Metadata: map[string]string{
"finish_reason": "tool_calls",
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`,
},
}
badRun := &fakeRunResult{
dispatch: edgeservice.RunDispatch{
RunID: "run-pool-bad",
NodeID: "node-pool",
ModelGroupKey: "pool-group",
Adapter: "ollama",
Target: "served",
},
events: bufferedRunEvents(badToolEvent),
}
// The retry result is tunnel-path: this would set Run=nil and cause a panic
// without the guard. We create a fake tunnel handle so the guard can close it.
tunnelFrames := make(chan *iop.ProviderTunnelFrame, 4)
tunnelFrames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
tunnelFrames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(`{"ok":true}`)}
tunnelFrames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
close(tunnelFrames)
fakeTunnel := &fakeTunnelHandle{
dispatch: edgeservice.RunDispatch{
RunID: "run-pool-tunnel-retry",
NodeID: "node-pool",
ModelGroupKey: "pool-group",
Adapter: "vllm",
Target: "served",
},
headers: map[string]string{},
frames: tunnelFrames,
waitTimeout: 5 * time.Second,
}
fake := &providerFakeRunService{
poolSubmitResults: []edgeservice.ProviderPoolDispatchResult{
{Path: edgeservice.ProviderPoolPathNormalized, Run: badRun, DispatchInfo: edgeservice.RunDispatch{RunID: "run-pool-bad", NodeID: "node-pool", ModelGroupKey: "pool-group", Adapter: "ollama", Target: "served"}},
{Path: edgeservice.ProviderPoolPathTunnel, Tunnel: fakeTunnel, DispatchInfo: edgeservice.RunDispatch{RunID: "run-pool-tunnel-retry", NodeID: "node-pool", ModelGroupKey: "pool-group", Adapter: "vllm", Target: "served"}},
},
}
catalog := []config.ModelCatalogEntry{
{ID: "pool-ollama-model", Providers: map[string]string{"prov-ollama": "qwen3.6:35b"}},
}
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
srv.SetModelCatalog(catalog)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model":"pool-ollama-model",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"],"additionalProperties":false}}}],
"tool_choice":"auto"
}`))
w := httptest.NewRecorder()
srv.handleChatCompletions(w, req)
// The handler must reject the tunnel-path retry result with 502.
if w.Code != http.StatusBadGateway {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
errMap, ok := resp["error"].(map[string]any)
if !ok {
t.Fatalf("expected error map in response: %s", w.Body.String())
}
if errMap["type"] != "tool_validation_retry_error" {
t.Errorf("error type: got %q", errMap["type"])
}
if errMap["message"] != "provider-pool retry selected tunnel path" {
t.Errorf("error message: got %q", errMap["message"])
}
}
// --- runtime-enabled tool validation: Core is the single recovery owner ------
// streamGateToolValidationServer builds a runtime-enabled server whose Core
// recovery budget is the only retry budget in the request.
func streamGateToolValidationServer(t *testing.T, enabled bool, maxRequestFaultRecovery int, runs ...chan *iop.RunEvent) (*Server, *fakeRunService) {
t.Helper()
fault := maxRequestFaultRecovery
fake := &fakeRunService{eventRuns: runs}
srv := NewServer(config.EdgeOpenAIConf{
Adapter: "cli",
Target: "codex",
StreamEvidenceGate: config.StreamEvidenceGateConf{
Enabled: enabled,
MaxRequestFaultRecovery: &fault,
},
}, fake, nil)
return srv, fake
}
const streamGateToolValidationBody = `{
"model":"qwen3.6:35b",
"messages":[{"role":"user","content":"status"}],
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}}}}}]
}`
func invalidToolCallRun() chan *iop.RunEvent {
return bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "true"},
&iop.RunEvent{Type: "complete"},
)
}
func validToolCallRun() chan *iop.RunEvent {
return bufferedRunEvents(
&iop.RunEvent{Type: "delta", Delta: "planning. [\"ls\"]\n"},
&iop.RunEvent{Type: "complete"},
)
}
// TestStreamGateEnabledToolValidationHasSingleRecoveryOwner proves the Core
// request runtime is the only retry owner on the runtime-enabled path: the
// number of provider dispatches follows the Core recovery budget exactly, so a
// budget of 0 must fail after one dispatch even though the legacy loop would
// have retried once and succeeded on the same fixture.
func TestStreamGateEnabledToolValidationHasSingleRecoveryOwner(t *testing.T) {
t.Run("core budget zero blocks every retry", func(t *testing.T) {
srv, fake := streamGateToolValidationServer(t, true, 0, invalidToolCallRun(), validToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(streamGateToolValidationBody)))
if got := len(fake.reqsSnapshot()); got != 1 {
t.Fatalf("provider dispatches: got %d, want 1 (Core budget 0 forbids recovery; a second dispatch means a legacy retry owner survives)", got)
}
if w.Code != http.StatusBadGateway {
t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String())
}
var resp errorResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Error.Type != "tool_validation_error" {
t.Fatalf("error type: got %q, want tool_validation_error", resp.Error.Type)
}
})
t.Run("core budget one re-admits exactly once", func(t *testing.T) {
srv, fake := streamGateToolValidationServer(t, true, 1, invalidToolCallRun(), validToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(streamGateToolValidationBody)))
reqs := fake.reqsSnapshot()
if len(reqs) != 2 {
t.Fatalf("provider dispatches: got %d, want 2 (initial admission + exactly one Core re-admission)", len(reqs))
}
if got := reqs[1].Metadata[runtimeMetadataToolValidationAttempt]; got != "2" {
t.Fatalf("recovery attempt metadata: got %q, want \"2\" (legacy provider-visible retry annotation must survive)", got)
}
if got := reqs[1].Metadata[runtimeMetadataToolValidationRetryOf]; got == "" {
t.Fatalf("recovery retry_of metadata is missing: %v", reqs[1].Metadata)
}
if got := reqs[1].Metadata[runtimeMetadataToolValidationFailure]; got == "" {
t.Fatalf("recovery failure metadata is missing: %v", reqs[1].Metadata)
}
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
})
t.Run("runtime disabled keeps the legacy retry loop", func(t *testing.T) {
// The same fixture with the runtime disabled must still be served by the
// legacy bounded retry loop, which is not governed by the Core budget.
srv, fake := streamGateToolValidationServer(t, false, 0, invalidToolCallRun(), validToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(streamGateToolValidationBody)))
if got := len(fake.reqsSnapshot()); got != 2 {
t.Fatalf("provider dispatches: got %d, want 2 (legacy compatibility retry)", got)
}
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
})
}
// TestStreamGateEnabledToolValidationPreservesStructuredOutput verifies the
// runtime-enabled buffered renderers still emit the existing OpenAI response
// shape: structured tool_calls, the cleaned content, the tool_calls finish
// reason, and — for the buffered stream — the [DONE] sentinel.
func TestStreamGateEnabledToolValidationPreservesStructuredOutput(t *testing.T) {
t.Run("non-stream json", func(t *testing.T) {
srv, fake := streamGateToolValidationServer(t, true, 1, validToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(streamGateToolValidationBody)))
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := len(fake.reqsSnapshot()); got != 1 {
t.Fatalf("provider dispatches: got %d, want 1 (a passing attempt must not recover)", got)
}
var resp chatCompletionResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Choices) == 0 {
t.Fatal("empty choices")
}
choice := resp.Choices[0]
if choice.FinishReason != "tool_calls" {
t.Fatalf("finish reason: got %q, want tool_calls", choice.FinishReason)
}
if len(choice.Message.ToolCalls) != 1 {
t.Fatalf("tool calls: got %d, want 1 structured call: %+v", len(choice.Message.ToolCalls), choice.Message)
}
if strings.Contains(choice.Message.Content, "") {
t.Fatalf("raw tool_call block leaked into content: %q", choice.Message.Content)
}
if resp.Object != "chat.completion" {
t.Fatalf("object: got %q, want chat.completion", resp.Object)
}
})
t.Run("buffered stream", func(t *testing.T) {
body := strings.TrimSuffix(strings.TrimSpace(streamGateToolValidationBody), "}") + `,"stream":true}`
srv, fake := streamGateToolValidationServer(t, true, 1, validToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)))
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
if got := len(fake.reqsSnapshot()); got != 1 {
t.Fatalf("provider dispatches: got %d, want 1", got)
}
out := w.Body.String()
if !strings.Contains(out, `"tool_calls"`) {
t.Fatalf("buffered stream is missing structured tool_calls: %s", out)
}
if strings.Contains(out, "") {
t.Fatalf("buffered stream leaked the raw tool_call block: %s", out)
}
if !strings.Contains(out, "data: [DONE]") {
t.Fatalf("buffered stream is missing the [DONE] sentinel: %s", out)
}
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
t.Fatalf("content type: got %q, want text/event-stream", got)
}
})
t.Run("buffered stream exhaustion keeps the tool_validation_error envelope", func(t *testing.T) {
body := strings.TrimSuffix(strings.TrimSpace(streamGateToolValidationBody), "}") + `,"stream":true}`
srv, _ := streamGateToolValidationServer(t, true, 0, invalidToolCallRun())
w := httptest.NewRecorder()
srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)))
if w.Code != http.StatusOK {
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
}
out := w.Body.String()
if !strings.Contains(out, "tool_validation_error") {
t.Fatalf("buffered stream must report tool_validation_error: %s", out)
}
// The legacy envelope carries the validation reason (which names the
// rejected tool), but the raw provider tool_call block must never reach
// the caller as content.
if strings.Contains(out, "") || strings.Contains(out, `"tool_calls"`) {
t.Fatalf("the rejected tool call leaked into the response: %s", out)
}
})
}