- Add unit tests for OpenAI edge adapter (chat handler, stream reasoning, tool synthesis, auth routes, test support) - Add roadmap: agent-readable-repository-refactor milestone with subtask plans - Update PHASE.md, priority-queue.md, .gitignore - Delete old edge/node/openai.test files (replaced by proper test structure)
1070 lines
42 KiB
Go
1070 lines
42 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func TestChatCompletionsDispatchesConfiguredOllamaTarget(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: " world"}
|
|
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{
|
|
Adapter: "ollama",
|
|
Target: "llama-fixed",
|
|
SessionID: "cline",
|
|
TimeoutSec: 15,
|
|
}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"client-model",
|
|
"messages":[{"role":"system","content":"brief"},{"role":"user","content":"say hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Adapter != "ollama" || fake.req.Target != "llama-fixed" {
|
|
t.Fatalf("dispatch target mismatch: %+v", fake.req)
|
|
}
|
|
if fake.req.SessionID != "cline" || fake.req.TimeoutSec != 15 {
|
|
t.Fatalf("execution config mismatch: %+v", fake.req)
|
|
}
|
|
if fake.req.ModelGroupKey != "client-model" {
|
|
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
|
|
}
|
|
if !strings.Contains(fake.req.Prompt, "system: brief") || !strings.Contains(fake.req.Prompt, "user: say hello") {
|
|
t.Fatalf("prompt did not include messages: %q", fake.req.Prompt)
|
|
}
|
|
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Choices[0].Message.Content != "hello world" {
|
|
t.Fatalf("content: got %q", resp.Choices[0].Message.Content)
|
|
}
|
|
if resp.Usage == nil || resp.Usage.TotalTokens != 4 {
|
|
t.Fatalf("usage: %+v", resp.Usage)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsUsesRequestModelWhenNoConfiguredTarget(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"from-request",
|
|
"messages":[{"role":"user","content":"hi"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Target != "from-request" {
|
|
t.Fatalf("target: got %q", fake.req.Target)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPreservesProviderFinishReason(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "truncated"}
|
|
fake.events <- &iop.RunEvent{Type: "complete", Metadata: map[string]string{"finish_reason": "length"}}
|
|
|
|
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":"hi"}]
|
|
}`))
|
|
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 resp.Choices[0].FinishReason != "length" {
|
|
t.Fatalf("finish_reason: got %q, want length", resp.Choices[0].FinishReason)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassesStandardOptions(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"from-request",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"max_tokens":12,
|
|
"temperature":0.1,
|
|
"top_p":0.9,
|
|
"stop":["END"],
|
|
"response_format":{"type":"json_object"},
|
|
"tools":[{"type":"function","function":{"name":"lookup"}}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
options, ok := fake.req.Input["options"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("options not passed: %+v", fake.req.Input)
|
|
}
|
|
if options["temperature"].(float64) != 0.1 || options["top_p"].(float64) != 0.9 {
|
|
t.Fatalf("unexpected options: %+v", options)
|
|
}
|
|
if options["max_tokens"].(int) != 12 {
|
|
t.Fatalf("max_tokens not passed: %+v", options)
|
|
}
|
|
if _, ok := options["stop"]; !ok {
|
|
t.Fatalf("stop not passed: %+v", options)
|
|
}
|
|
if _, ok := options["response_format"]; !ok {
|
|
t.Fatalf("response_format not passed: %+v", options)
|
|
}
|
|
if tools, ok := fake.req.Input["tools"].([]any); !ok || len(tools) != 1 {
|
|
t.Fatalf("tools not passed: %+v", fake.req.Input["tools"])
|
|
}
|
|
if _, ok := fake.req.Input["tool_choice"]; ok {
|
|
t.Fatalf("tool_choice must be omitted when the client omits it: %+v", fake.req.Input["tool_choice"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsAcceptsStoreNoOp(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"from-request",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"store":false
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if _, ok := fake.req.Input["store"]; ok {
|
|
t.Fatalf("store must be accepted as a client compatibility no-op: %+v", fake.req.Input)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsOmitsProviderToolChoiceAuto(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
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":"hi"}],
|
|
"tools":[{"type":"function","function":{"name":"lookup"}}],
|
|
"tool_choice":"auto",
|
|
"parallel_tool_calls":true,
|
|
"stream_options":{"include_usage":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())
|
|
}
|
|
if _, ok := fake.req.Input["tool_choice"]; ok {
|
|
t.Fatalf("tool_choice auto should be omitted for provider defaults: %+v", fake.req.Input["tool_choice"])
|
|
}
|
|
if _, ok := fake.req.Input["parallel_tool_calls"]; ok {
|
|
t.Fatalf("parallel_tool_calls must not be forwarded: %+v", fake.req.Input)
|
|
}
|
|
if _, ok := fake.req.Input["stream_options"]; ok {
|
|
t.Fatalf("stream_options must not be forwarded: %+v", fake.req.Input)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassesProviderForcedToolChoice(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
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":"hi"}],
|
|
"tools":[{"type":"function","function":{"name":"lookup"}}],
|
|
"tool_choice":{"type":"function","function":{"name":"lookup"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
choice, ok := fake.req.Input["tool_choice"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool_choice not forwarded as object: %+v", fake.req.Input["tool_choice"])
|
|
}
|
|
if choice["type"] != "function" {
|
|
t.Fatalf("tool_choice type: %+v", choice)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsDowngradesCLIToolChoiceAutoToNone(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"codex-cli",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"tools":[{"type":"function","function":{"name":"lookup"}}],
|
|
"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())
|
|
}
|
|
if fake.req.Input["tool_choice"] != "none" {
|
|
t.Fatalf("tool_choice: got %+v, want none", fake.req.Input["tool_choice"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallsFromClineTextBlock(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
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"}}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
|
|
}
|
|
if choice.Message.Content != "먼저 현재 git 상태를 확인하겠습니다." {
|
|
t.Fatalf("content: got %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call, ok := choice.Message.ToolCalls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
|
|
}
|
|
if call["id"] != "call_iop_run-test_1" {
|
|
t.Fatalf("tool call id: got %+v", call["id"])
|
|
}
|
|
fn, ok := call["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function shape: %+v", call["function"])
|
|
}
|
|
if fn["name"] != "run_commands" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallsFromClineTemplateCall(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "사용자가 변경 내용을 푸시해달라고 요청하셨습니다. 먼저 현재 git 상태를 확인하여 변경된 파일과 커밋 가능한 내용을 파악하겠습니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': '현재 git 상태 확인', 'runInTerminal': True}]})}}"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
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":"push changes"}],
|
|
"tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "{{run_commands") {
|
|
t.Fatalf("content still contains raw template tool call: %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call, ok := choice.Message.ToolCalls[0].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("tool call shape: %+v", choice.Message.ToolCalls[0])
|
|
}
|
|
fn, ok := call["function"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("function shape: %+v", call["function"])
|
|
}
|
|
if fn["name"] != "run_commands" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
var args map[string][]map[string]any
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0]["command"] != "git" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
commandArgs := args["commands"][0]["args"].([]any)
|
|
if len(commandArgs) != 1 || commandArgs[0] != "status" {
|
|
t.Fatalf("command args: %+v", commandArgs)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"git status\"]\n</parameter>\n</function>\n</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"}}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 {
|
|
t.Fatalf("provider text should be synthesized: %+v", choice)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("provider raw content should not be preserved: %q", choice.Message.Content)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.\n\n" + editToolCallFixture()}
|
|
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":"edit"}],
|
|
"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"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())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("raw tool_call leaked in response: %s", body)
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("expected edit tool call, got %+v", choice)
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
if fn["name"] != "edit" {
|
|
t.Fatalf("function name: got %+v", fn["name"])
|
|
}
|
|
args := decodeEditToolArguments(t, fn["arguments"].(string))
|
|
if args["path"] != "apps/client/test/client_bootstrap_test.dart" {
|
|
t.Fatalf("path argument: %+v", args["path"])
|
|
}
|
|
edits := args["edits"].([]any)
|
|
edit := edits[0].(map[string]any)
|
|
if _, ok := edit["path"]; ok {
|
|
t.Fatalf("edit item must not contain path: %+v", edit)
|
|
}
|
|
if !strings.Contains(edit["newText"].(string), "http://<edge-host>:18081/v1/chat/completions") {
|
|
t.Fatalf("newText lost angle-bracket placeholder: %+v", edit["newText"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "수정합니다.\n\n" + editToolCallFixture()}
|
|
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",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"edit"}],
|
|
"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"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())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("raw tool_call leaked in stream: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("expected streamed edit tool call, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func editToolCallFixture() string {
|
|
edits, _ := json.Marshal([]map[string]string{{
|
|
"oldText": "final baseUrl = 'http://127.0.0.1:18083/v1/chat/completions';",
|
|
"newText": "```bash\ncurl -fsS http://<edge-host>:18081/v1/chat/completions \\\n -H \"Content-Type: application/json\"\n```",
|
|
}})
|
|
return "<tool_call>\n" +
|
|
"<function=edit>\n" +
|
|
"<parameter=path>\napps/client/test/client_bootstrap_test.dart\n</parameter>\n" +
|
|
"<parameter=edits>\n" + string(edits) + "\n</parameter>\n" +
|
|
"</function>\n" +
|
|
"</tool_call>"
|
|
}
|
|
|
|
func decodeEditToolArguments(t *testing.T, raw string) map[string]any {
|
|
t.Helper()
|
|
var args map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
edits, ok := args["edits"].([]any)
|
|
if !ok || len(edits) != 1 {
|
|
t.Fatalf("edits argument: %+v", args["edits"])
|
|
}
|
|
if _, ok := edits[0].(map[string]any); !ok {
|
|
t.Fatalf("edit item: %+v", edits[0])
|
|
}
|
|
return args
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
runtimeMetadataOpenAITextToolFallback: "true",
|
|
},
|
|
}
|
|
|
|
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"]}}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.FinishReason != "tool_calls" {
|
|
t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason)
|
|
}
|
|
if strings.Contains(choice.Message.Content, "<tool_call>") {
|
|
t.Fatalf("content still contains raw tool_call block: %q", choice.Message.Content)
|
|
}
|
|
if len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSanitizesKnownSentinelToken(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking <|mask_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"}
|
|
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":"hi"}]
|
|
}`))
|
|
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 strings.Contains(resp.Choices[0].Message.Content, "<|mask_end|>") || strings.Contains(resp.Choices[0].Message.ReasoningContent, "<|mask_end|>") {
|
|
t.Fatalf("sentinel leaked in response: %+v", resp.Choices[0].Message)
|
|
}
|
|
if resp.Choices[0].Message.Content != "answer" || resp.Choices[0].Message.ReasoningContent != "thinking" {
|
|
t.Fatalf("unexpected sanitized output: %+v", resp.Choices[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSynthesizesToolCallAndDropsTrailingSentinel(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<tool_call>\n<function=run_commands>\n<parameter=commands>[\"git status\"]</parameter>\n</function>\n</tool_call><|mask_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
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"}}},"required":["commands"]}}}]
|
|
}`))
|
|
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 strings.Contains(w.Body.String(), "<|mask_end|>") {
|
|
t.Fatalf("sentinel leaked in response body: %s", w.Body.String())
|
|
}
|
|
if resp.Choices[0].FinishReason != "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 1 {
|
|
t.Fatalf("expected structured tool call, got %+v", resp.Choices[0])
|
|
}
|
|
if resp.Choices[0].Message.Content != "" {
|
|
t.Fatalf("trailing sentinel should not become content: %q", resp.Choices[0].Message.Content)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
|
},
|
|
}
|
|
|
|
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"}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
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 len: got %d", len(choice.Message.ToolCalls))
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
if call["id"] != "call_1" {
|
|
t.Fatalf("tool call id: got %+v", call["id"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsNativeToolCallPreservesLeadingProse(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "선행 prose\n\n<tool_call><function=run_commands><parameter=commands>[\"git status\"]</parameter></function></tool_call>"}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
|
},
|
|
}
|
|
|
|
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"}}],
|
|
"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())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("raw tool_call leaked in response: %s", body)
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
choice := resp.Choices[0]
|
|
if choice.Message.Content != "선행 prose" {
|
|
t.Fatalf("content: got %q", choice.Message.Content)
|
|
}
|
|
if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) != 1 {
|
|
t.Fatalf("expected native tool call: %+v", choice)
|
|
}
|
|
call := choice.Message.ToolCalls[0].(map[string]any)
|
|
if call["id"] != "call_1" {
|
|
t.Fatalf("tool call id: got %+v", call["id"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsNormalizesNativeToolCallArgumentsForProviderRoute(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"[{\\\"command\\\":\\\"cd /config/workspace/iop && git status\\\"}]\"}"}}]`,
|
|
},
|
|
}
|
|
|
|
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"]}}}],
|
|
"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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
call := resp.Choices[0].Message.ToolCalls[0].(map[string]any)
|
|
fn := call["function"].(map[string]any)
|
|
var args map[string][]string
|
|
if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil {
|
|
t.Fatalf("arguments JSON: %v", err)
|
|
}
|
|
if len(args["commands"]) != 1 || args["commands"][0] != "cd /config/workspace/iop && git status" {
|
|
t.Fatalf("commands args: %+v", args["commands"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRetriesMalformedToolCallBeforeResponse(t *testing.T) {
|
|
fake := &fakeRunService{
|
|
eventRuns: []chan *iop.RunEvent{
|
|
bufferedRunEvents(&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\"}"}}]`,
|
|
},
|
|
}),
|
|
bufferedRunEvents(&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\"]}"}}]`,
|
|
},
|
|
}),
|
|
},
|
|
runIDs: []string{"run-bad", "run-good"},
|
|
}
|
|
|
|
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"],"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())
|
|
}
|
|
reqs := fake.reqsSnapshot()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
|
}
|
|
if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
|
|
t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
|
|
}
|
|
if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
|
|
t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
|
|
}
|
|
if !strings.Contains(reqs[1].Metadata[runtimeMetadataToolValidationFailure], "$.arguments.commands type string") {
|
|
t.Fatalf("retry failure reason: %+v", reqs[1].Metadata)
|
|
}
|
|
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.ID != "chatcmpl-run-good" {
|
|
t.Fatalf("response id: got %q", resp.ID)
|
|
}
|
|
call := resp.Choices[0].Message.ToolCalls[0].(map[string]any)
|
|
if call["id"] != "call_good" {
|
|
t.Fatalf("tool call id: got %+v", call["id"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
|
|
invalidComplete := func(id string) *iop.RunEvent {
|
|
return &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
|
|
},
|
|
}
|
|
}
|
|
fake := &fakeRunService{
|
|
eventRuns: []chan *iop.RunEvent{
|
|
bufferedRunEvents(invalidComplete("call_bad_1")),
|
|
bufferedRunEvents(invalidComplete("call_bad_2")),
|
|
},
|
|
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"],"additionalProperties":false}}}],
|
|
"tool_choice":{"type":"function","function":{"name":"run_commands"}}
|
|
}`))
|
|
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(), "$.arguments.commands is required") {
|
|
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 TestChatCompletionsToolChoiceNoneSkipsRuntimeToolValidation(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"unknown_tool","arguments":"not-json"}}]`,
|
|
},
|
|
}
|
|
|
|
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"]}}}],
|
|
"tool_choice":"none"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.reqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("submit attempts: got %d want 1", len(reqs))
|
|
}
|
|
if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok {
|
|
t.Fatalf("tool_choice none should not enable validation metadata: %+v", reqs[0].Metadata)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsToolsStreamRetriesMalformedToolCallBeforeChunk(t *testing.T) {
|
|
fake := &fakeRunService{
|
|
eventRuns: []chan *iop.RunEvent{
|
|
bufferedRunEvents(&iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_bad","type":"function","function":{"name":"edit","arguments":"{\"edits\":[{\"path\":\"apps/client/test/client_bootstrap_test.dart\",\"newText\":\"ok\"}]}"}}]`,
|
|
},
|
|
}),
|
|
bufferedRunEvents(&iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_good","type":"function","function":{"name":"edit","arguments":"{\"path\":\"apps/client/test/client_bootstrap_test.dart\",\"edits\":[{\"newText\":\"ok\"}]}"}}]`,
|
|
},
|
|
}),
|
|
},
|
|
runIDs: []string{"run-bad", "run-good"},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"edit"}],
|
|
"tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"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())
|
|
}
|
|
reqs := fake.reqsSnapshot()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
|
}
|
|
if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
|
|
t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
|
|
}
|
|
if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
|
|
t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
|
|
}
|
|
if !strings.Contains(reqs[1].Metadata[runtimeMetadataToolValidationFailure], "$.arguments.path is required") {
|
|
t.Fatalf("retry failure reason: %+v", reqs[1].Metadata)
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "call_bad") {
|
|
t.Fatalf("buffered stream leaked invalid tool call before retry:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "call_good") || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("buffered stream did not emit validated tool call:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStrictBufferedStreamRetriesMalformedToolCallBeforeChunk(t *testing.T) {
|
|
fake := &fakeRunService{
|
|
eventRuns: []chan *iop.RunEvent{
|
|
bufferedRunEvents(&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\"}"}}]`,
|
|
},
|
|
}),
|
|
bufferedRunEvents(&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\"]}"}}]`,
|
|
},
|
|
}),
|
|
},
|
|
runIDs: []string{"run-bad", "run-good"},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"stream":true,
|
|
"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())
|
|
}
|
|
reqs := fake.reqsSnapshot()
|
|
if len(reqs) != 2 {
|
|
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
|
}
|
|
if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" {
|
|
t.Fatalf("first attempt metadata: %+v", reqs[0].Metadata)
|
|
}
|
|
if reqs[1].Metadata[runtimeMetadataToolValidationAttempt] != "2" || reqs[1].Metadata[runtimeMetadataToolValidationRetryOf] != "run-bad" {
|
|
t.Fatalf("retry metadata: %+v", reqs[1].Metadata)
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "call_bad") {
|
|
t.Fatalf("buffered stream leaked invalid tool call before retry:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "call_good") || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("buffered stream did not emit validated tool call:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStrictBufferedStreamFailsMalformedToolCallAfterRetryLimit(t *testing.T) {
|
|
invalidComplete := func(id string) *iop.RunEvent {
|
|
return &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: fmt.Sprintf(`[{"id":%q,"type":"function","function":{"name":"run_commands","arguments":"{\"unexpected\":true}"}}]`, id),
|
|
},
|
|
}
|
|
}
|
|
fake := &fakeRunService{
|
|
eventRuns: []chan *iop.RunEvent{
|
|
bufferedRunEvents(invalidComplete("call_bad_1")),
|
|
bufferedRunEvents(invalidComplete("call_bad_2")),
|
|
},
|
|
runIDs: []string{"run-bad-1", "run-bad-2"},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"qwen3.6:35b",
|
|
"stream":true,
|
|
"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":{"type":"function","function":{"name":"run_commands"}}
|
|
}`))
|
|
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, `"type":"tool_validation_error"`) || !strings.Contains(body, "$.arguments.commands is required") {
|
|
t.Fatalf("expected tool_validation_error SSE body, got:\n%s", body)
|
|
}
|
|
if strings.Contains(body, "call_bad") {
|
|
t.Fatalf("buffered stream leaked invalid tool call chunk after retry limit:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("buffered stream error should terminate with [DONE]:\n%s", body)
|
|
}
|
|
if reqs := fake.reqsSnapshot(); len(reqs) != 2 {
|
|
t.Fatalf("submit attempts: got %d want 2", len(reqs))
|
|
}
|
|
}
|