- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
570 lines
21 KiB
Go
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"])
|
|
}
|
|
}
|