iop/apps/edge/internal/openai/provider_tool_validation_test.go
toki 01dc2ef78b refactor: readability baseline 및 테스트 구조 개선
- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동
- 새 테스트 파일 추가 (edge, node, client, config, readability)
- readability_audit 스크립트 및 baseline 추가
- roadmap/SDD 문서 갱신
- agent-client/pi/extensions/openai-sampling-parameters 추가
2026-07-17 16:02:12 +09:00

570 lines
21 KiB
Go

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: "<tool_call><function=delete_everything><parameter=confirm>true</parameter></function></tool_call>"}
run1Events <- &iop.RunEvent{Type: "complete"}
// Attempt 2: valid tool call (succeeds)
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=run_commands><parameter=commands>[\"ls\"]\n</parameter></function></tool_call>"}
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: "<tool_call><function=unknown><parameter=arg>val</parameter></function></tool_call>"}
run1Events <- &iop.RunEvent{Type: "complete"}
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=unknown><parameter=arg>val</parameter></function></tool_call>"}
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: "<tool_call><function=run_commands><parameter=commands>[\"echo 1\"]</parameter></function></tool_call>"}
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, `"<tool_call>"`) {
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: "<tool_call><function=unknown_tool><parameter=arg>val</parameter></function></tool_call>"},
&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: "<tool_call><function=unknown><parameter=arg>val</parameter></function></tool_call>"}
run1Events <- &iop.RunEvent{Type: "complete"}
run2Events := make(chan *iop.RunEvent, 2)
run2Events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call><function=unknown><parameter=arg>val</parameter></function></tool_call>"}
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: "여기 툴 콜입니다. <tool_call><function=run_commands><parameter=commands>"}
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: "<tool_call><function=run_commands>"},
&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, "<tool_call") {
t.Fatalf("body should not contain tool_call leak, got: %s", body)
}
if !strings.Contains(body, "tool_validation_error") {
t.Fatalf("expected tool_validation_error, got: %s", body)
}
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
t.Fatalf("submit attempts: got %d want 2", len(reqs))
}
}
func TestValidateToolCallResponseValidatesEveryToolCall(t *testing.T) {
contract := toolValidationContract{
enabled: true,
specs: map[string]textToolSpec{
"run_commands": {
parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"commands": map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
},
},
"required": []any{"commands"},
},
},
"read_file": {
parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
},
},
"required": []any{"path"},
},
},
},
}
invalidFirst := []any{
map[string]any{
"id": "call_1",
"type": "object",
"function": map[string]any{
"name": "run_commands",
"arguments": `{"not_commands":[]}`,
},
},
map[string]any{
"id": "call_2",
"type": "object",
"function": map[string]any{
"name": "read_file",
"arguments": `{"path":"/a/b"}`,
},
},
}
err := validateToolCallResponse(contract, invalidFirst, "native")
if err == nil {
t.Fatal("expected validation error because first tool call is invalid")
}
if !strings.Contains(err.Error(), "tool call 0") || !strings.Contains(err.Error(), "commands is required") {
t.Fatalf("unexpected error message: %v", err)
}
allValid := []any{
map[string]any{
"id": "call_1",
"type": "object",
"function": map[string]any{
"name": "run_commands",
"arguments": `{"commands":["ls"]}`,
},
},
map[string]any{
"id": "call_2",
"type": "object",
"function": map[string]any{
"name": "read_file",
"arguments": `{"path":"/a/b"}`,
},
},
}
if err := validateToolCallResponse(contract, allValid, "native"); err != nil {
t.Fatalf("expected no validation error, got: %v", err)
}
}
func TestChatCompletionsFailsWhenAnySynthesizedTextToolCallViolatesSchema(t *testing.T) {
invalidComplete := func() chan *iop.RunEvent {
ch := make(chan *iop.RunEvent, 2)
ch <- &iop.RunEvent{Type: "delta", Delta: `<tool_call><function=run_commands><parameter=not_commands>["ls"]</parameter></function></tool_call><tool_call><function=read_file><parameter=path>"/a/b"</parameter></function></tool_call>`}
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"])
}
}