package openai import ( "bufio" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" edgeservice "iop/apps/edge/internal/service" "iop/packages/go/config" iop "iop/proto/gen/iop" ) type fakeRunService struct { req edgeservice.SubmitRunRequest reqs []edgeservice.SubmitRunRequest ollamaReq edgeservice.OllamaAPIRequest ollamaResp edgeservice.OllamaAPIView events chan *iop.RunEvent eventRuns []chan *iop.RunEvent runIDs []string submitMu sync.Mutex cancelMu sync.Mutex cancelCalls []edgeservice.CancelRunRequest cancelErr error } func (s *fakeRunService) CancelRun(_ context.Context, req edgeservice.CancelRunRequest) (edgeservice.CommandResult, error) { s.cancelMu.Lock() s.cancelCalls = append(s.cancelCalls, req) s.cancelMu.Unlock() if s.cancelErr != nil { return edgeservice.CommandResult{}, s.cancelErr } return edgeservice.CommandResult{NodeID: req.NodeRef, SessionID: req.SessionID}, nil } func (s *fakeRunService) cancelCallsSnapshot() []edgeservice.CancelRunRequest { s.cancelMu.Lock() defer s.cancelMu.Unlock() return append([]edgeservice.CancelRunRequest(nil), s.cancelCalls...) } func (s *fakeRunService) reqsSnapshot() []edgeservice.SubmitRunRequest { s.submitMu.Lock() defer s.submitMu.Unlock() return append([]edgeservice.SubmitRunRequest(nil), s.reqs...) } func TestRoutesRequireBearerTokenWhenConfigured(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ BearerToken: "secret-token", Models: []string{"model-a"}, }, &fakeRunService{}, nil) for _, tc := range []struct { name string auth string wantStatus int }{ {name: "missing", wantStatus: http.StatusUnauthorized}, {name: "wrong", auth: "Bearer wrong", wantStatus: http.StatusUnauthorized}, {name: "valid", auth: "Bearer secret-token", wantStatus: http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) if tc.auth != "" { req.Header.Set("Authorization", tc.auth) } w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != tc.wantStatus { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } }) } } func TestHealthzDoesNotRequireBearerToken(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{BearerToken: "secret-token"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } } func (s *fakeRunService) SubmitRun(_ context.Context, req edgeservice.SubmitRunRequest) (edgeservice.RunResult, error) { s.submitMu.Lock() s.req = req s.reqs = append(s.reqs, req) attempt := len(s.reqs) events := s.events if attempt-1 < len(s.eventRuns) { events = s.eventRuns[attempt-1] } runID := "run-test" if attempt-1 < len(s.runIDs) { runID = s.runIDs[attempt-1] } else if attempt > 1 { runID = fmt.Sprintf("run-test-%d", attempt) } s.submitMu.Unlock() return &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{ RunID: runID, ModelGroupKey: req.ModelGroupKey, Adapter: req.Adapter, Target: req.Target, SessionID: req.SessionID, TimeoutSec: 5, }, RunStream: edgeservice.RunStream{ Events: events, NodeEvents: make(chan *iop.EdgeNodeEvent), }, }, nil } func (s *fakeRunService) OllamaAPI(_ context.Context, req edgeservice.OllamaAPIRequest) (edgeservice.OllamaAPIView, error) { s.ollamaReq = req if s.ollamaResp.StatusCode == 0 { s.ollamaResp.StatusCode = http.StatusOK } return s.ollamaResp, nil } func bufferedRunEvents(events ...*iop.RunEvent) chan *iop.RunEvent { ch := make(chan *iop.RunEvent, len(events)) for _, event := range events { ch <- event } return ch } 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: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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\n\n\n[\"cd /config/workspace/iop && git status\"]\n\n\n"} 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, "") { 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 TestChatCompletionsDoesNotSynthesizeTextToolCallsForProviderRoute(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n\n\n\n[\"git status\"]\n\n\n"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, 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" || len(choice.Message.ToolCalls) != 0 { t.Fatalf("provider text must not be synthesized: %+v", choice) } if !strings.Contains(choice.Message.Content, "") { t.Fatalf("provider raw content should be preserved: %q", choice.Message.Content) } } func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n\n\n\n[\"cd /config/workspace/iop && git status\"]\n\n\n"} fake.events <- &iop.RunEvent{ Type: "complete", Metadata: map[string]string{ runtimeMetadataOpenAITextToolFallback: "true", }, } srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, 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"]}}}], "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, "") { 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 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: "openai_compat"}, 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"}}], "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 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: "openai_compat"}, 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"]}}}], "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: "openai_compat"}, 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"],"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: "openai_compat"}, 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"],"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: "openai_compat"}, 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"]}}}], "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 TestChatCompletionsLiveStreamSkipsRuntimeToolValidation(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_bad","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":\"git status\"}"}}]`, }, } srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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) != 1 { t.Fatalf("submit attempts: got %d want 1 (live stream must not retry)", len(reqs)) } if _, ok := reqs[0].Metadata[runtimeMetadataToolValidationAttempt]; ok { t.Fatalf("live stream should not attach validation attempt metadata: %+v", reqs[0].Metadata) } body := w.Body.String() if !strings.Contains(body, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) || !strings.Contains(body, "data: [DONE]") { t.Fatalf("live stream did not preserve existing tool_calls semantics:\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: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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: "openai_compat", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "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)) } } func TestValidateJSONSchemaSubsetCoversObjectArrayEnumAdditionalProperties(t *testing.T) { schema := map[string]any{ "type": "object", "properties": map[string]any{ "mode": map[string]any{ "type": "string", "enum": []any{"read", "write"}, }, "commands": map[string]any{ "type": "array", "items": map[string]any{"type": "string"}, }, "options": map[string]any{ "type": "object", "properties": map[string]any{ "dry_run": map[string]any{"type": "boolean"}, }, "additionalProperties": false, }, }, "required": []any{"mode", "commands"}, "additionalProperties": false, } valid := map[string]any{ "mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true}, } if err := validateJSONSchemaSubset(valid, schema, "$.arguments"); err != nil { t.Fatalf("valid schema rejected: %v", err) } cases := []struct { name string args map[string]any want string }{ { name: "enum", args: map[string]any{"mode": "delete", "commands": []any{"git status"}}, want: "$.arguments.mode value", }, { name: "array item type", args: map[string]any{"mode": "read", "commands": []any{1}}, want: "$.arguments.commands[0] type integer does not match schema type string", }, { name: "additional properties", args: map[string]any{"mode": "read", "commands": []any{"git status"}, "extra": true}, want: "$.arguments.extra is not allowed", }, { name: "nested additional properties", args: map[string]any{"mode": "read", "commands": []any{"git status"}, "options": map[string]any{"dry_run": true, "trace": true}}, want: "$.arguments.options.trace is not allowed", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { err := validateJSONSchemaSubset(tc.args, schema, "$.arguments") if err == nil || !strings.Contains(err.Error(), tc.want) { t.Fatalf("error got %v, want substring %q", err, tc.want) } }) } } func TestSynthesizesTemplateToolCallConvertsCommandObjectsToShellStrings(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'command -v git && echo \"found\" || echo \"not found\"', 'runInTerminal': True}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if cleaned != "확인합니다." { t.Fatalf("content: got %q", cleaned) } if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := toolCalls[0].(map[string]any) fn := call["function"].(map[string]any) var args map[string]any if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) if commands[0] != `command -v git && echo "found" || echo "not found"` { t.Fatalf("commands args: %+v", commands) } if _, ok := args["runInTerminal"]; ok { t.Fatalf("runInTerminal should not be emitted: %+v", args) } } func TestSynthesizesTemplateToolCallStripsTopLevelExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status'}], runInTerminal=True, description='status')}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := toolCalls[0].(map[string]any) fn := call["function"].(map[string]any) var args map[string]any if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) command := commands[0].(map[string]any) if command["command"] != "git" { t.Fatalf("commands args: %+v", commands) } commandArgs := command["args"].([]any) if len(commandArgs) != 1 || commandArgs[0] != "status" { t.Fatalf("command args: %+v", commandArgs) } if _, ok := args["runInTerminal"]; ok { t.Fatalf("runInTerminal should not be emitted: %+v", args) } if _, ok := args["description"]; ok { t.Fatalf("description should not be emitted: %+v", args) } } func TestSynthesizesTemplateToolCallKeepsStructuredCommandArgs(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['status', '--short'], 'description': 'status'}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := toolCalls[0].(map[string]any) fn := call["function"].(map[string]any) var args map[string]any if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) command := commands[0].(map[string]any) if command["command"] != "git" { t.Fatalf("command: %+v", command) } commandArgs := command["args"].([]any) if len(commandArgs) != 2 || commandArgs[0] != "status" || commandArgs[1] != "--short" { t.Fatalf("command args: %+v", commandArgs) } if _, ok := command["description"]; ok { t.Fatalf("description should not be emitted: %+v", command) } } func TestSynthesizesTemplateToolCallFollowsClineUnionSchemaWithShellStrings(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git', 'args': ['-C', '/config/workspace/iop', 'status']}, {'command': 'pwd && ls -la /config/workspace/'}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", "parameters": map[string]any{ "type": "object", "properties": map[string]any{ "commands": map[string]any{ "type": "array", "items": map[string]any{ "anyOf": []any{ map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{"type": "string"}, "args": map[string]any{ "type": "array", "items": map[string]any{"type": "string"}, }, }, }, map[string]any{"type": "string"}, }, }, }, }, }, }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := 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"]) != 2 { t.Fatalf("commands len: %+v", args["commands"]) } if args["commands"][0] != "git -C /config/workspace/iop status" { t.Fatalf("first command: %+v", args["commands"][0]) } if args["commands"][1] != "pwd && ls -la /config/workspace/" { t.Fatalf("second command: %+v", args["commands"][1]) } } func TestSynthesizesTemplateToolCallConvertsStructuredShellOperatorsToString(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status', '&&', 'git', 'diff', '--stat']}, {'command': 'ls', 'args': ['-la', '/config/workspace/iop', '&&', 'pwd']}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := toolCalls[0].(map[string]any) fn := call["function"].(map[string]any) var args map[string]any if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) if commands[0] != "cd /config/workspace/iop && git status && git diff --stat" { t.Fatalf("first command: %+v", commands[0]) } if commands[1] != "ls -la /config/workspace/iop && pwd" { t.Fatalf("second command: %+v", commands[1]) } } func TestSynthesizesTemplateToolCallFollowsStringArrayCommandSchema(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': 'git status', 'description': 'status'}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", "parameters": map[string]any{ "type": "object", "properties": map[string]any{ "commands": map[string]any{ "type": "array", "items": map[string]any{ "type": "string", }, }, }, }, }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := 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] != "git status" { t.Fatalf("commands args: %+v", args["commands"]) } } func TestSynthesizesTemplateToolCallFollowsSingleCommandSchema(t *testing.T) { content := "확인합니다.\n\n{{bash(commands=[{'command': 'cd', 'args': ['/config/workspace/iop', '&&', 'git', 'status']}], runInTerminal=True)}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "bash", "parameters": map[string]any{ "type": "object", "properties": map[string]any{ "command": map[string]any{ "type": "string", }, }, }, }, }} _, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := 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 args["command"] != "cd /config/workspace/iop && git status" { t.Fatalf("command arg: %+v", args) } if _, ok := args["commands"]; ok { t.Fatalf("commands should not be emitted: %+v", args) } if _, ok := args["runInTerminal"]; ok { t.Fatalf("runInTerminal should not be emitted: %+v", args) } } func TestSynthesizesShellCommandStringWithoutExtraExecutionHints(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=['command -v git && echo \"found\" || echo \"not found\"'])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if cleaned != "확인합니다." { t.Fatalf("content: got %q", cleaned) } if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call := toolCalls[0].(map[string]any) fn := call["function"].(map[string]any) var args map[string]any if err := json.Unmarshal([]byte(fn["arguments"].(string)), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } commands := args["commands"].([]any) if commands[0] != `command -v git && echo "found" || echo "not found"` { t.Fatalf("commands args: %+v", commands) } if _, ok := args["runInTerminal"]; ok { t.Fatalf("runInTerminal should not be emitted: %+v", args) } } func TestSynthesizesTemplateToolCallWithClosingMarkerInString(t *testing.T) { content := "확인합니다.\n\n{{run_commands(commands=[{'command': \"printf ')}}'\", 'description': 'marker test', 'runInTerminal': False}])}}" tools := []any{map[string]any{ "type": "function", "function": map[string]any{ "name": "run_commands", }, }} cleaned, toolCalls := synthesizeToolCallsFromText(content, tools, "run-test") if cleaned != "확인합니다." { t.Fatalf("content: got %q", cleaned) } if len(toolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(toolCalls)) } call, ok := toolCalls[0].(map[string]any) if !ok { t.Fatalf("tool call shape: %+v", toolCalls[0]) } fn, ok := call["function"].(map[string]any) if !ok { t.Fatalf("function shape: %+v", call["function"]) } 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] != "printf ')}}'" { t.Fatalf("commands args: %+v", args["commands"]) } } func TestChatCompletionsLeavesUnknownTextToolCallAsContent(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "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":"status"}], "tools":[{"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()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 { t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0]) } if !strings.Contains(resp.Choices[0].Message.Content, "") { t.Fatalf("raw unknown tool call should remain content: %q", resp.Choices[0].Message.Content) } } func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=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":"status"}], "tools":[{"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()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Choices[0].FinishReason == "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 0 { t.Fatalf("unexpected tool call conversion: %+v", resp.Choices[0]) } if !strings.Contains(resp.Choices[0].Message.Content, "{{delete_everything") { t.Fatalf("raw unknown template tool call should remain content: %q", resp.Choices[0].Message.Content) } } func TestChatCompletionsMapsMaxCompletionTokens(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: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"from-request", "messages":[{"role":"user","content":"hi"}], "max_completion_tokens":7 }`)) 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["max_tokens"].(int) != 7 { t.Fatalf("max_completion_tokens not mapped to provider max_tokens: %+v", options) } } func TestChatCompletionsStreamsSSE(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} 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(`{ "model":"stream-model", "stream":true, "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()) } body := w.Body.String() if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") { t.Fatalf("unexpected SSE body:\n%s", body) } if !strings.Contains(body, `"finish_reason":"length"`) { t.Fatalf("streaming finish_reason did not preserve provider value:\n%s", body) } } func TestChatCompletionsStreamSynthesizesToolCallsFromClineTextBlock(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "\n\n\n[\"cd /config/workspace/iop && git status\"]\n\n\n"} 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":"stream-model", "stream":true, "messages":[{"role":"user","content":"status"}], "tools":[{"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, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) { t.Fatalf("stream did not synthesize tool_calls:\n%s", body) } if strings.Contains(body, "") || strings.Contains(body, `\u003ctool_call`) { t.Fatalf("stream leaked raw tool_call block:\n%s", body) } if !strings.Contains(body, `"index":0`) { t.Fatalf("stream tool_call delta should include index:\n%s", body) } if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) { t.Fatalf("stream should preserve text before tool call:\n%s", body) } } func TestChatCompletionsStreamWithToolsFlushesTextBeforeComplete(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) httpSrv := httptest.NewServer(srv.routes()) defer httpSrv.Close() resp, err := http.Post(httpSrv.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{ "model":"stream-model", "stream":true, "messages":[{"role":"user","content":"explain sudo"}], "tools":[{"type":"function","function":{"name":"run_commands"}}], "tool_choice":"auto" }`)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status: got %d", resp.StatusCode) } lines := make(chan string, 16) scanDone := make(chan struct{}) go func() { defer close(scanDone) scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { lines <- scanner.Text() } }() fake.events <- &iop.RunEvent{Type: "delta", Delta: "streaming before complete"} deadline := time.After(2 * time.Second) for { select { case line := <-lines: if strings.Contains(line, `"finish_reason"`) { t.Fatalf("saw finish before streamed content: %s", line) } if strings.Contains(line, `"content":"streaming before complete"`) { fake.events <- &iop.RunEvent{Type: "complete"} select { case <-scanDone: case <-time.After(2 * time.Second): t.Fatal("stream did not close after complete") } return } case <-deadline: t.Fatal("tools request did not stream content before complete") } } } func TestChatCompletionsStreamSynthesizesToolCallsFromClineTemplateCall(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "{{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":"stream-model", "stream":true, "messages":[{"role":"user","content":"status"}], "tools":[{"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, `"tool_calls"`) || !strings.Contains(body, `"finish_reason":"tool_calls"`) { t.Fatalf("stream did not synthesize tool_calls:\n%s", body) } if strings.Contains(body, "{{run_commands") { t.Fatalf("stream leaked raw template tool call:\n%s", body) } if !strings.Contains(body, `"index":0`) { t.Fatalf("stream tool_call delta should include index:\n%s", body) } if !strings.Contains(body, `\"commands\":[{\"args\":[\"status\"],\"command\":\"git\"}]`) && !strings.Contains(body, `\"commands\":[{\"command\":\"git\",\"args\":[\"status\"]}]`) { t.Fatalf("stream tool_call arguments should include command:\n%s", body) } if strings.Contains(body, "runInTerminal") || strings.Contains(body, "description") { t.Fatalf("stream tool_call arguments should strip execution metadata:\n%s", body) } if !strings.Contains(body, `"content":"먼저 현재 git 상태를 확인하겠습니다."`) { t.Fatalf("stream should preserve text before tool call:\n%s", body) } } func TestChatCompletionsStreamPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "checking"} 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: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"stream-model", "stream":true, "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_calls"`) || !strings.Contains(body, `"call_1"`) { t.Fatalf("stream did not pass through native tool_calls:\n%s", body) } if !strings.Contains(body, `"finish_reason":"tool_calls"`) { t.Fatalf("stream finish_reason:\n%s", body) } } func TestChatCompletionsStreamingReportsClosedRunStream(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} close(fake.events) srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"stream-model", "stream":true, "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()) } body := w.Body.String() if !strings.Contains(body, `"message":"run stream closed"`) || !strings.Contains(body, "data: [DONE]") { t.Fatalf("unexpected SSE error body:\n%s", body) } } func TestChatCompletionsReturnsReasoningContentSeparately(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "more"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 2, OutputTokens: 2}} srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama-fixed", }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"client-model", "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].Message.Content != "answer" { t.Fatalf("content: got %q, expected \"answer\"", resp.Choices[0].Message.Content) } if resp.Choices[0].Message.ReasoningContent != "thinking more" { t.Fatalf("reasoning_content: got %q, expected \"thinking more\"", resp.Choices[0].Message.ReasoningContent) } } func TestChatCompletionsStreamsReasoningSSE(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} 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":"stream-model", "stream":true, "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()) } body := w.Body.String() if !strings.Contains(body, `"reasoning_content":"think"`) || !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") { t.Fatalf("unexpected SSE body:\n%s", body) } } func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\nhidden\n\n\nok\n\n```\ntrailing noise"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"agent-model", "messages":[{"role":"user","content":"finish"}] }`)) 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["think"] != false { t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } want := "\nok\n" if resp.Choices[0].Message.Content != want { t.Fatalf("content:\ngot %q\nwant %q", resp.Choices[0].Message.Content, want) } if resp.Choices[0].Message.ReasoningContent != "" { t.Fatalf("strict output should drop reasoning content: %q", resp.Choices[0].Message.ReasoningContent) } } func TestChatCompletionsStrictOutputWrapsPlainTextWhenPromptDeclaresCompletionTool(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"agent-model", "messages":[ {"role":"system","content":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n\ndone\n"}, {"role":"user","content":"finish"} ] }`)) 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 !strings.Contains(fake.req.Prompt, "output exactly one block") || !strings.Contains(fake.req.Prompt, "actual user-facing response") { t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } want := "\nplain answer\n" if resp.Choices[0].Message.Content != want { t.Fatalf("content:\ngot %q\nwant %q", resp.Choices[0].Message.Content, want) } } func TestChatCompletionsStrictOutputBuffersStreamingResponse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\nnoisea.go"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true, StrictStreamBuffer: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"agent-model", "stream":true, "messages":[{"role":"user","content":"read"}] }`)) 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, "reasoning_content") || strings.Contains(body, "") || strings.Contains(body, "") { t.Fatalf("strict stream leaked unstable content:\n%s", body) } if !strings.Contains(body, "read_file") || !strings.Contains(body, "a.go") || !strings.Contains(body, "data: [DONE]") { t.Fatalf("unexpected strict SSE body:\n%s", body) } } func TestChatCompletionsStrictOutputStreamsLiveContent(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "\n"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok\n"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", StrictOutput: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"agent-model", "stream":true, "messages":[{"role":"user","content":"finish"}] }`)) 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, "reasoning_content") || strings.Contains(body, "hidden") { t.Fatalf("strict stream leaked reasoning content:\n%s", body) } if strings.Count(body, `"content":`) != 2 || !strings.Contains(body, "attempt_completion") || !strings.Contains(body, "data: [DONE]") { t.Fatalf("strict stream should pass live content deltas:\n%s", body) } } func TestModelsUsesConfiguredModelsOrTarget(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Target: "fallback-model"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d", w.Code) } if !strings.Contains(w.Body.String(), "fallback-model") { t.Fatalf("expected target model, got %s", w.Body.String()) } srv = NewServer(config.EdgeOpenAIConf{Models: []string{"a", "b"}}, &fakeRunService{}, nil) w = httptest.NewRecorder() srv.handleModels(w, req) if !strings.Contains(w.Body.String(), `"id":"a"`) || !strings.Contains(w.Body.String(), `"id":"b"`) { t.Fatalf("expected configured models, got %s", w.Body.String()) } } // TestOpenAIModelsUsesRefreshedCatalog verifies that a provider-pool catalog // applied via SetModelCatalog (config refresh apply) is reflected in /v1/models // on the next request, replacing the previous catalog snapshot. func TestOpenAIModelsUsesRefreshedCatalog(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil) srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-old"}}) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(w, req) if body := w.Body.String(); !strings.Contains(body, `"id":"model-old"`) { t.Fatalf("expected initial catalog model-old, got %s", body) } // Refresh the catalog: the next request must show the new model and not the old. srv.SetModelCatalog([]config.ModelCatalogEntry{{ID: "model-new"}}) w = httptest.NewRecorder() srv.handleModels(w, req) body := w.Body.String() if !strings.Contains(body, `"id":"model-new"`) { t.Fatalf("expected refreshed catalog model-new, got %s", body) } if strings.Contains(body, `"id":"model-old"`) { t.Fatalf("stale catalog model-old still present after refresh, got %s", body) } } // TestOpenAIProviderPoolRouteUsesRefreshedCatalog verifies that provider-pool // route resolution reads the refreshed catalog snapshot, so a model added by a // config refresh is routed via the provider pool. func TestOpenAIProviderPoolRouteUsesRefreshedCatalog(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{}, &fakeRunService{}, nil) if srv.findProviderPoolEntry("model-new") != nil { t.Fatal("expected no provider-pool entry before catalog is set") } srv.SetModelCatalog([]config.ModelCatalogEntry{{ ID: "model-new", Providers: map[string]string{"prov-a": "served-a"}, }}) dispatch, ok := srv.resolveRouteDispatch("model-new") if !ok || !dispatch.ProviderPool { t.Fatalf("expected provider-pool dispatch for refreshed model, got ok=%v dispatch=%+v", ok, dispatch) } } func TestOllamaAPIPassthrough(t *testing.T) { fake := &fakeRunService{ ollamaResp: edgeservice.OllamaAPIView{ StatusCode: http.StatusAccepted, ContentType: "application/json", Body: `{"ok":true}`, }, } srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", TimeoutSec: 15}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/api/show", strings.NewReader(`{"model":"gemma4:26b"}`)) w := httptest.NewRecorder() srv.handleOllamaAPI(w, req) if w.Code != http.StatusAccepted { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.ollamaReq.Adapter != "ollama" || fake.ollamaReq.Method != http.MethodPost || fake.ollamaReq.Path != "/api/show" { t.Fatalf("passthrough req mismatch: %+v", fake.ollamaReq) } if fake.ollamaReq.Body != `{"model":"gemma4:26b"}` || fake.ollamaReq.TimeoutSec != 15 { t.Fatalf("passthrough body/timeout mismatch: %+v", fake.ollamaReq) } if w.Body.String() != `{"ok":true}` { t.Fatalf("body: got %s", w.Body.String()) } } func TestOllamaAPIPassthroughPreservesConfiguredTarget(t *testing.T) { fake := &fakeRunService{ ollamaResp: edgeservice.OllamaAPIView{ StatusCode: http.StatusOK, ContentType: "application/json", Body: `{"models":[]}`, }, } srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "gemma4:26b", TimeoutSec: 15}, fake, nil) req := httptest.NewRequest(http.MethodGet, "/api/tags", nil) w := httptest.NewRecorder() srv.handleOllamaAPI(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.ollamaReq.Target != "gemma4:26b" { t.Fatalf("passthrough target: got %q, want gemma4:26b", fake.ollamaReq.Target) } } func TestResponsesDispatchesNonStreamingRequest(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: 1, OutputTokens: 1}} srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama-fixed", SessionID: "configured-session", TimeoutSec: 42, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"say hello", "instructions":"Use short answers.", "max_output_tokens":16, "temperature":0.2, "top_p":0.9, "background":false }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Target != "llama-fixed" { t.Fatalf("target: got %q, want llama-fixed", fake.req.Target) } if fake.req.Prompt != "Use short answers.\n\nsay hello" { t.Fatalf("prompt: got %q", fake.req.Prompt) } options, ok := fake.req.Input["options"].(map[string]any) if !ok { t.Fatalf("options not passed: %+v", fake.req.Input) } if options["max_tokens"].(int) != 16 || options["temperature"].(float64) != 0.2 || options["top_p"].(float64) != 0.9 { t.Fatalf("unexpected response options: %+v", options) } if fake.req.TimeoutSec != 42 { t.Fatalf("timeout: got %d, want 42", fake.req.TimeoutSec) } if fake.req.SessionID != "configured-session" { t.Fatalf("session_id: got %q, want configured-session", fake.req.SessionID) } if fake.req.Background { t.Fatal("background should remain false for responses") } var resp responsesResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.OutputText != "hello world" { t.Fatalf("output_text: got %q", resp.OutputText) } if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "hello world" { t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) } } func TestResponsesRejectsUnsupportedRequests(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) cases := []struct { name string method string body string want int }{ {"wrong method", http.MethodGet, "", http.StatusMethodNotAllowed}, {"stream=true", http.MethodPost, `{"model":"m","input":"hi","stream":true}`, http.StatusBadRequest}, {"empty input", http.MethodPost, `{"model":"m","input":""}`, http.StatusBadRequest}, {"non-string input", http.MethodPost, `{"model":"m","input":["a","b"]}`, http.StatusBadRequest}, {"options wrapper unsupported", http.MethodPost, `{"model":"m","input":"hi","options":{"max_output_tokens":16}}`, http.StatusBadRequest}, {"background unsupported", http.MethodPost, `{"model":"m","input":"hi","background":true}`, http.StatusBadRequest}, {"bad max_output_tokens", http.MethodPost, `{"model":"m","input":"hi","max_output_tokens":0}`, http.StatusBadRequest}, {"bad top_p", http.MethodPost, `{"model":"m","input":"hi","top_p":1.5}`, http.StatusBadRequest}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(tc.method, "/v1/responses", strings.NewReader(tc.body)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != tc.want { t.Fatalf("got %d want %d body=%s", w.Code, tc.want, w.Body.String()) } }) } } func TestResponsesGenericMetadataContract(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/responses", strings.NewReader(`{ "model":"client-model", "input":"test", "metadata":{ "request_id":"req-001", "workspace":"/config/workspace/iop", "task_id":"task-123", "custom":"value" } }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Target != "client-model" { t.Fatalf("target: got %q, want client-model", fake.req.Target) } if fake.req.Workspace != "/config/workspace/iop" { t.Fatalf("workspace: got %q", fake.req.Workspace) } if fake.req.Metadata["request_id"] != "req-001" { t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"]) } if _, ok := fake.req.Metadata["workspace"]; ok { t.Fatal("workspace should not be copied into run metadata") } if fake.req.Metadata["task_id"] != "task-123" { t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"]) } if fake.req.Metadata["custom"] != "value" { t.Fatalf("custom: got %q", fake.req.Metadata["custom"]) } if fake.req.Metadata["openai_model"] != "client-model" { t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"]) } if fake.req.ModelGroupKey != "client-model" { t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey) } if fake.req.Metadata["openai_stream"] != "false" { t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"]) } if fake.req.Metadata["strict_output"] != "false" { t.Fatalf("strict_output: got %q", fake.req.Metadata["strict_output"]) } } func TestChatCompletionsMetadataContractAndWorkspace(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":"client-model", "messages":[{"role":"user","content":"hi"}], "metadata":{ "request_id":"req-chat-001", "workspace":"/config/workspace/iop", "task_id":"task-123" } }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Target != "client-model" { t.Fatalf("target: got %q, want client-model", fake.req.Target) } if fake.req.Workspace != "/config/workspace/iop" { t.Fatalf("workspace: got %q", fake.req.Workspace) } if fake.req.Metadata["request_id"] != "req-chat-001" { t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"]) } if fake.req.Metadata["task_id"] != "task-123" { t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"]) } if fake.req.ModelGroupKey != "client-model" { t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey) } if _, ok := fake.req.Metadata["workspace"]; ok { t.Fatal("workspace should not be copied into run metadata") } } func TestChatCompletionsRejectsObjectMetadata(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"m", "messages":[{"role":"user","content":"hi"}], "metadata":{"cli":{"flag":"x"}} }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } } func TestChatCompletionsRejectsUnsupportedFields(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) cases := []struct { name string body string }{ {"options wrapper", `{"model":"m","messages":[{"role":"user","content":"hi"}],"options":{"num_ctx":8192}}`}, {"think", `{"model":"m","messages":[{"role":"user","content":"hi"}],"think":false}`}, {"format", `{"model":"m","messages":[{"role":"user","content":"hi"}],"format":"json"}`}, {"keep_alive", `{"model":"m","messages":[{"role":"user","content":"hi"}],"keep_alive":"10m"}`}, {"bad max_tokens", `{"model":"m","messages":[{"role":"user","content":"hi"}],"max_tokens":0}`}, {"bad top_p", `{"model":"m","messages":[{"role":"user","content":"hi"}],"top_p":2}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(tc.body)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } }) } } func TestChatCompletionsRejectsSourceMetadata(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"m", "messages":[{"role":"user","content":"hi"}], "metadata":{"source":"manual"} }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "metadata.source is not supported") { t.Fatalf("expected source unsupported error, got %s", w.Body.String()) } } func TestChatCompletionsRejectsNonStringMetadataValue(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"m", "messages":[{"role":"user","content":"hi"}], "metadata":{"attempt":2} }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "metadata.attempt must be a string") { t.Fatalf("expected string metadata error, got %s", w.Body.String()) } } func TestChatCompletionsRejectsObjectWorkspaceMetadata(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"m", "messages":[{"role":"user","content":"hi"}], "metadata":{ "workspace": { "path": "/home/user/workspace", "source_branch": "develop" } } }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") { t.Fatalf("expected workspace string error, got %s", w.Body.String()) } } func TestResponsesPreservesGenericTaskMetadata(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/responses", strings.NewReader(`{ "model":"client-model", "input":"test", "metadata":{ "request_id":"req-flat-001", "task_id":"task-flat" } }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Metadata["task_id"] != "task-flat" { t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"]) } } func TestResponsesConfiguredTargetWinsOverRequestModel(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", Target: "config-target"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"test" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Target != "config-target" { t.Fatalf("target: got %q, want config-target", fake.req.Target) } var resp responsesResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Model != "client-model" { t.Fatalf("model: got %q, want client-model", resp.Model) } } func TestResponsesRejectsNonStringMetadata(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) cases := []struct { name string body string }{ {"cli only", `{"model":"m","input":"hi","metadata":{"cli":{"flag":"x"}}}`}, {"inference target", `{"model":"m","input":"hi","metadata":{"inference":{"target":"t"}}}`}, {"nomadcode metadata", `{"model":"m","input":"hi","metadata":{"nomadcode":{"task_id":"t"}}}`}, {"source metadata", `{"model":"m","input":"hi","metadata":{"source":"manual"}}`}, {"number metadata", `{"model":"m","input":"hi","metadata":{"attempt":2}}`}, {"boolean metadata", `{"model":"m","input":"hi","metadata":{"urgent":true}}`}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(tc.body)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } }) } } func TestResponsesRejectsObjectWorkspaceMetadata(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"m", "input":"hi", "metadata":{ "workspace": { "path": "/home/user/workspace", "source_branch": "develop" } } }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") { t.Fatalf("expected workspace string error, got %s", w.Body.String()) } } func TestResponsesReturnsOpenAICompatibleShape(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer"} fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"say hello" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var resp responsesResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if !strings.HasPrefix(resp.ID, "resp-") { t.Fatalf("id: got %q, want resp- prefix", resp.ID) } if resp.Model != "client-model" { t.Fatalf("model: got %q", resp.Model) } if resp.OutputText != "answer" { t.Fatalf("output_text: got %q", resp.OutputText) } if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != "answer" { t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) } if resp.Usage.TotalTokens != 2 { t.Fatalf("usage.total_tokens: got %d", resp.Usage.TotalTokens) } } func TestResponsesUsageDefaultsToZeroObject(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", Target: "llama"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"m", "input":"hi" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var body map[string]json.RawMessage if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if _, ok := body["usage"]; !ok { t.Fatal("usage field missing from response") } var usage openAIUsage if err := json.Unmarshal(body["usage"], &usage); err != nil { t.Fatalf("usage is not an object: %s", body["usage"]) } } func TestResponsesStrictOutputNormalizesAgentResponse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "plain answer"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama", StrictOutput: true}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"Once you've completed the user's task, you must use the attempt_completion tool to present the result.\n\n\ndone\n\n\nfinish" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Metadata["strict_output"] != "true" { t.Fatalf("strict_output metadata: got %q", fake.req.Metadata["strict_output"]) } if fake.req.Input["think"] != false { t.Fatalf("strict output should disable thinking by default: %+v", fake.req.Input) } if !strings.Contains(fake.req.Prompt, "output exactly one block") { t.Fatalf("strict contract instruction not injected:\n%s", fake.req.Prompt) } var resp responsesResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } want := "\nplain answer\n" if resp.OutputText != want { t.Fatalf("output_text:\ngot %q\nwant %q", resp.OutputText, want) } if len(resp.Output) == 0 || len(resp.Output[0].Content) == 0 || resp.Output[0].Content[0].Text != want { t.Fatalf("output[0].content[0].text wrong: %+v", resp.Output) } } func TestCollectRunResultTimesOut(t *testing.T) { handle := &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{TimeoutSec: 1}, RunStream: edgeservice.RunStream{ Events: make(chan *iop.RunEvent), NodeEvents: make(chan *iop.EdgeNodeEvent), }, } ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond) defer cancel() _, _, _, _, _, _, err := collectRunResult(ctx, handle.Stream(), handle.WaitTimeout()) if err == nil { t.Fatal("expected timeout error") } } // fakeRunResultWithTimeout implements edgeservice.RunResult with a directly // configurable WaitTimeout, so tests can exercise the run-timeout cancel path // without waiting out the real DefaultTimeoutSec-derived duration. type fakeRunResultWithTimeout struct { dispatch edgeservice.RunDispatch stream edgeservice.RunStream waitTimeout time.Duration } func (f *fakeRunResultWithTimeout) Dispatch() edgeservice.RunDispatch { return f.dispatch } func (f *fakeRunResultWithTimeout) Stream() edgeservice.RunStream { return f.stream } func (f *fakeRunResultWithTimeout) Close() {} func (f *fakeRunResultWithTimeout) WaitTimeout() time.Duration { return f.waitTimeout } func TestChatCompletionContextCancelSendsCancelRun(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama-fixed", SessionID: "cline", TimeoutSec: 15, }, fake, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"client-model", "messages":[{"role":"user","content":"hi"}] }`)).WithContext(ctx) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusRequestTimeout { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } calls := fake.cancelCallsSnapshot() if len(calls) != 1 { t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls) } if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" { t.Fatalf("unexpected cancel request: %+v", calls[0]) } } func TestResponsesContextCancelSendsCancelRun(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama-fixed", SessionID: "cline", TimeoutSec: 15, }, fake, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"client-model", "input":"say hello" }`)).WithContext(ctx) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusRequestTimeout { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } calls := fake.cancelCallsSnapshot() if len(calls) != 1 { t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls) } if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" { t.Fatalf("unexpected cancel request: %+v", calls[0]) } } func TestStreamChatCompletionContextCancelSendsCancelRun(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama-fixed", SessionID: "cline", TimeoutSec: 15, }, fake, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"client-model", "stream":true, "messages":[{"role":"user","content":"hi"}] }`)).WithContext(ctx) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) calls := fake.cancelCallsSnapshot() if len(calls) != 1 { t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls) } if calls[0].RunID != "run-test" || calls[0].Adapter != "ollama" || calls[0].Target != "llama-fixed" || calls[0].SessionID != "cline" { t.Fatalf("unexpected cancel request: %+v", calls[0]) } } func TestStreamChatCompletionTimeoutSendsCancelRun(t *testing.T) { fake := &fakeRunService{} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil) handle := &fakeRunResultWithTimeout{ dispatch: edgeservice.RunDispatch{ RunID: "run-timeout", NodeID: "node-1", Adapter: "ollama", Target: "llama3", SessionID: "cline", }, stream: edgeservice.RunStream{ Events: make(chan *iop.RunEvent), NodeEvents: make(chan *iop.EdgeNodeEvent), }, waitTimeout: 50 * time.Millisecond, } req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) w := httptest.NewRecorder() srv.streamChatCompletion(w, req, chatCompletionRequest{Model: "llama3"}, edgeservice.SubmitRunRequest{}, handle, strictOutputPolicy{}, toolValidationContract{}) calls := fake.cancelCallsSnapshot() if len(calls) != 1 { t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls) } if calls[0].RunID != "run-timeout" || calls[0].NodeRef != "node-1" { t.Fatalf("unexpected cancel request: %+v", calls[0]) } if !strings.Contains(w.Body.String(), "run timed out") { t.Fatalf("expected timeout SSE error, got %s", w.Body.String()) } } func TestChatCompletionCompleteDoesNotSendCancelRun(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama-fixed"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"client-model", "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 calls := fake.cancelCallsSnapshot(); len(calls) != 0 { t.Fatalf("expected no CancelRun calls for a normal completion, got %d: %+v", len(calls), calls) } } func TestModelsExposesCatalogRouteModels(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "llama3"}, {Model: "model-b", Adapter: "vllm", Target: "qwen"}, }, }, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d", w.Code) } body := w.Body.String() if !strings.Contains(body, `"id":"model-a"`) || !strings.Contains(body, `"id":"model-b"`) { t.Fatalf("expected catalog model IDs, got %s", body) } } func TestModelsSkipsRoutesWithEmptyTarget(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-with-target", Adapter: "ollama", Target: "llama3"}, {Model: "model-no-target", Adapter: "ollama", Target: ""}, }, }, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(w, req) body := w.Body.String() if !strings.Contains(body, `"id":"model-with-target"`) { t.Fatalf("expected model-with-target in response, got %s", body) } if strings.Contains(body, "model-no-target") { t.Fatalf("model-no-target should be skipped (no target), got %s", body) } } func TestModelsRouteCatalogWinsOverModelsField(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ Models: []string{"legacy-model"}, ModelRoutes: []config.OpenAIRouteEntry{ {Model: "catalog-model", Adapter: "ollama", Target: "llama3"}, }, }, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(w, req) body := w.Body.String() if !strings.Contains(body, `"id":"catalog-model"`) { t.Fatalf("expected catalog model in response, got %s", body) } if strings.Contains(body, "legacy-model") { t.Fatalf("legacy model should be hidden when catalog is set, got %s", body) } } func TestChatCompletionsRouteCatalogDispatches(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{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "llama3"}, {Model: "model-b", Adapter: "vllm", Target: "qwen"}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"model-a", "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.Adapter != "ollama" || fake.req.Target != "llama3" { t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) } } func TestChatCompletionsRouteCatalogDispatchesModelB(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{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "llama3"}, {Model: "model-b", Adapter: "vllm", Target: "qwen", MaxQueue: 5, QueueTimeoutMS: 2000}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"model-b", "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.Adapter != "vllm" || fake.req.Target != "qwen" { t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) } if fake.req.ModelGroupKey != "model-b" { t.Fatalf("model group key: got %q, want model-b", fake.req.ModelGroupKey) } if fake.req.MaxQueue != 5 || fake.req.QueueTimeoutMS != 2000 { t.Fatalf("queue policy mismatch: MaxQueue=%d, QueueTimeoutMS=%d", fake.req.MaxQueue, fake.req.QueueTimeoutMS) } } func TestChatCompletionsCatalogMissFallsToLegacyTarget(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", Target: "legacy-target", ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "llama3"}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"unknown-model", "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 != "legacy-target" { t.Fatalf("expected legacy-target fallback, got %q", fake.req.Target) } } func TestChatCompletionsEmptyModelReturns400(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"", "messages":[{"role":"user","content":"hi"}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("expected 400 for empty model, got %d body=%s", w.Code, w.Body.String()) } } func TestResponsesRouteCatalogDispatchesRoute(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{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "vllm", Target: "qwen"}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"model-a", "input":"say hello" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Adapter != "vllm" || fake.req.Target != "qwen" { t.Fatalf("dispatch mismatch: adapter=%q target=%q", fake.req.Adapter, fake.req.Target) } if fake.req.ModelGroupKey != "model-a" { t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey) } } func TestResponsesRouteCatalogDispatchesQueuePolicy(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{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "model-a", Adapter: "ollama", Target: "route-target", MaxQueue: 3, QueueTimeoutMS: 1500}, }, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"model-a", "input":"test" }`)) w := httptest.NewRecorder() srv.routes().ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if fake.req.Target != "route-target" { t.Fatalf("expected route-target, got %q", fake.req.Target) } if fake.req.ModelGroupKey != "model-a" { t.Fatalf("model group key: got %q, want model-a", fake.req.ModelGroupKey) } if fake.req.MaxQueue != 3 || fake.req.QueueTimeoutMS != 1500 { t.Fatalf("queue policy mismatch: MaxQueue=%d, QueueTimeoutMS=%d", fake.req.MaxQueue, fake.req.QueueTimeoutMS) } } func TestChatCompletionsRejectsNumCtxOptionsWrapper(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"from-request", "messages":[{"role":"user","content":"hi"}], "options":{"num_ctx":8192} }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String()) } } func TestResolveRouteDispatchPreservesWorkspaceRequired(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true}, {Model: "llama3", Adapter: "ollama", Target: "llama3:8b"}, }, }, &fakeRunService{}, nil) dispatch, ok := srv.resolveRouteDispatch("codex") if !ok { t.Fatal("expected dispatch to succeed for codex route") } if !dispatch.WorkspaceRequired { t.Fatalf("expected workspace_required=true for codex route, got false") } dispatch, ok = srv.resolveRouteDispatch("llama3") if !ok { t.Fatal("expected dispatch to succeed for llama3 route") } if dispatch.WorkspaceRequired { t.Fatalf("expected workspace_required=false for llama3 route, got true") } } func TestResolveRouteDispatchFallbackWorkspaceRequiredFalse(t *testing.T) { srv := NewServer(config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama3", ModelRoutes: []config.OpenAIRouteEntry{ {Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true}, }, }, &fakeRunService{}, nil) dispatch, ok := srv.resolveRouteDispatch("unknown-model") if !ok { t.Fatal("expected fallback dispatch to succeed") } if dispatch.WorkspaceRequired { t.Fatalf("expected workspace_required=false for fallback route, got true") } } func TestCollectRunResultFailsWhenEventStreamCloses(t *testing.T) { events := make(chan *iop.RunEvent) close(events) handle := &edgeservice.RunHandle{ RunDispatch: edgeservice.RunDispatch{TimeoutSec: 60}, RunStream: edgeservice.RunStream{ Events: events, NodeEvents: make(chan *iop.EdgeNodeEvent), }, } _, _, _, _, _, _, err := collectRunResult(context.Background(), handle.Stream(), handle.WaitTimeout()) if err == nil { t.Fatal("expected closed stream error") } if !strings.Contains(err.Error(), "run stream closed") { t.Fatalf("expected run stream closed error, got %v", err) } } // workspaceBoundCfg returns a server config with a workspace-required codex route and a // non-required llama3 route for workspace validation tests. func workspaceBoundCfg() config.EdgeOpenAIConf { return config.EdgeOpenAIConf{ Adapter: "ollama", Target: "llama3", ModelRoutes: []config.OpenAIRouteEntry{ {Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true}, {Model: "llama3", Adapter: "ollama", Target: "llama3"}, }, } } func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","input":"hello"}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "workspace is required") { t.Fatalf("expected workspace error, got %s", w.Body.String()) } } func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "absolute path") { t.Fatalf("expected absolute path error, got %s", w.Body.String()) } } func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(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(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusOK { t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String()) } if fake.req.Workspace != "/abs/path" { t.Fatalf("workspace not preserved: got %q", fake.req.Workspace) } } func TestResponsesNonRequiredRouteNoWorkspaceOK(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(workspaceBoundCfg(), fake, nil) body := `{"model":"llama3","input":"hello"}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusOK { t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String()) } } func TestResponsesSurfacesDistinctRunFailures(t *testing.T) { cases := []struct { name string runError string want []string forbidAny []string }{ { name: "missing workspace path from node", runError: "cli adapter: workspace not found: /abs/missing", want: []string{`"type":"run_error"`, "cli adapter: workspace not found: /abs/missing"}, }, { name: "inaccessible workspace path from node", runError: "cli adapter: workspace inaccessible: /abs/private: permission denied", want: []string{`"type":"run_error"`, "cli adapter: workspace inaccessible: /abs/private"}, }, { name: "agent process exit failure", runError: "command failed: exit status 7", want: []string{`"type":"run_error"`, "command failed: exit status 7"}, forbidAny: []string{"workspace not found", "workspace inaccessible", "workspace is not a directory"}, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} fake.events <- &iop.RunEvent{Type: "error", Error: tc.runError} srv := NewServer(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/workspace"}}` req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("run failure: want 502, got %d body=%s", w.Code, w.Body.String()) } for _, want := range tc.want { if !strings.Contains(w.Body.String(), want) { t.Fatalf("expected body to contain %q, got %s", want, w.Body.String()) } } for _, forbidden := range tc.forbidAny { if strings.Contains(w.Body.String(), forbidden) { t.Fatalf("body should not contain %q, got %s", forbidden, w.Body.String()) } } }) } } func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "workspace is required") { t.Fatalf("expected workspace error, got %s", w.Body.String()) } } func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent)} srv := NewServer(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "absolute path") { t.Fatalf("expected absolute path error, got %s", w.Body.String()) } } func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(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(workspaceBoundCfg(), fake, nil) body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String()) } if fake.req.Workspace != "/abs/workspace" { t.Fatalf("workspace not preserved: got %q", fake.req.Workspace) } } func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(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(workspaceBoundCfg(), fake, nil) body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}` req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String()) } } // TestHandleModelsProviderPoolCatalog verifies that /v1/models returns the // provider-pool catalog IDs when a catalog is set, ignoring legacy model_routes. func TestHandleModelsProviderPoolCatalog(t *testing.T) { catalog := []config.ModelCatalogEntry{ {ID: "qwen3.6:35b", Providers: map[string]string{"prov-1": "Qwen3-35B-A22B"}}, {ID: "llama3.3:70b", Providers: map[string]string{"prov-2": "llama-3.3-70b"}}, } srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "legacy-model", Target: "legacy-target"}, }, }, &fakeRunService{}, nil) srv.SetModelCatalog(catalog) req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) w := httptest.NewRecorder() srv.handleModels(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, "qwen3.6:35b") || !strings.Contains(body, "llama3.3:70b") { t.Fatalf("catalog models not listed: %s", body) } if strings.Contains(body, "legacy-model") { t.Fatalf("legacy model_routes should be suppressed when catalog is set: %s", body) } } // TestChatCompletionsProviderPoolDispatch verifies that when a request model // matches the provider-pool catalog, ProviderPool=true is set on the service // request and Adapter/Target are left empty for service-layer resolution. func TestChatCompletionsProviderPoolDispatch(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"} catalog := []config.ModelCatalogEntry{ {ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}}, } srv := NewServer(config.EdgeOpenAIConf{}, fake, nil) srv.SetModelCatalog(catalog) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"qwen3.6:35b", "messages":[{"role":"user","content":"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.ProviderPool { t.Error("ProviderPool should be true for catalog-matched model") } if fake.req.ModelGroupKey != "qwen3.6:35b" { t.Errorf("ModelGroupKey: got %q, want qwen3.6:35b", fake.req.ModelGroupKey) } if fake.req.Adapter != "" || fake.req.Target != "" { t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target) } } // TestChatCompletionsLegacyRouteSetsProviderPoolFalse verifies that when the // server is configured with Adapter + Target (no catalog), the dispatched // SubmitRunRequest has ProviderPool=false so the service uses direct dispatch // (not the queue admission gate that requires ProviderPool=true). func TestChatCompletionsLegacyRouteSetsProviderPoolFalse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "hi"} fake.events <- &iop.RunEvent{Type: "complete"} // Server with adapter+target only, no catalog — classic legacy route. srv := NewServer(config.EdgeOpenAIConf{ Adapter: "cli", Target: "codex", TimeoutSec: 10, }, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"any-model", "messages":[{"role":"user","content":"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.ProviderPool { t.Error("ProviderPool must be false for a legacy adapter+target route with no catalog") } if fake.req.Adapter != "cli" || fake.req.Target != "codex" { t.Errorf("Adapter/Target: got %q/%q, want cli/codex", fake.req.Adapter, fake.req.Target) } } // TestResponsesProviderPoolDispatch verifies that the /v1/responses endpoint // sets ProviderPool=true and leaves Adapter/Target empty when the request model // matches a catalog entry, mirroring chat/completions provider-pool semantics. func TestResponsesProviderPoolDispatch(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", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}} catalog := []config.ModelCatalogEntry{ {ID: "prov-vllm:model-a", Providers: map[string]string{"prov-vllm": "model-a"}}, } srv := NewServer(config.EdgeOpenAIConf{TimeoutSec: 5}, fake, nil) srv.SetModelCatalog(catalog) req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{ "model":"prov-vllm:model-a", "input":"hello" }`)) w := httptest.NewRecorder() srv.handleResponses(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } if !fake.req.ProviderPool { t.Error("ProviderPool should be true for catalog-matched model via /v1/responses") } if fake.req.ModelGroupKey != "prov-vllm:model-a" { t.Errorf("ModelGroupKey: got %q, want prov-vllm:model-a", fake.req.ModelGroupKey) } if fake.req.Adapter != "" || fake.req.Target != "" { t.Errorf("Adapter/Target should be empty for provider-pool dispatch, got %q/%q", fake.req.Adapter, fake.req.Target) } } // TestChatCompletionsProviderPoolFallsBackToLegacyRoute verifies that when the // request model does not match the catalog, the legacy model_routes path is used. func TestChatCompletionsProviderPoolFallsBackToLegacyRoute(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"} catalog := []config.ModelCatalogEntry{ {ID: "qwen3.6:35b", Providers: map[string]string{"prov-vllm": "Qwen3-35B-A22B"}}, } srv := NewServer(config.EdgeOpenAIConf{ ModelRoutes: []config.OpenAIRouteEntry{ {Model: "ollama-model", Adapter: "ollama", Target: "llama3"}, }, }, fake, nil) srv.SetModelCatalog(catalog) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"ollama-model", "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.ProviderPool { t.Error("ProviderPool should be false for non-catalog model") } if fake.req.Target != "llama3" { t.Errorf("Target: got %q, want llama3", fake.req.Target) } }