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"]) } }