package openai import ( "bytes" "context" "encoding/json" "fmt" "io" "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 // tunnelProviderURL, when set, makes SubmitProviderTunnel relay the tunnel // request to this httptest provider fixture and stream the raw provider // response back as ordered frames (emulating the Node relay). tunnelProviderURL string // tunnelServedTarget emulates provider-pool admission: BuildBody is called // with this target when non-empty. tunnelServedTarget string // tunnelFrames, when set, is handed to the tunnel handle as-is instead of // contacting tunnelProviderURL. tunnelFrames chan *iop.ProviderTunnelFrame tunnelErr error tunnelReqs []edgeservice.SubmitProviderTunnelRequest tunnelBodies [][]byte } type fakeTunnelHandle struct { dispatch edgeservice.RunDispatch frames chan *iop.ProviderTunnelFrame } func (h *fakeTunnelHandle) Dispatch() edgeservice.RunDispatch { return h.dispatch } func (h *fakeTunnelHandle) Stream() edgeservice.ProviderTunnelStream { return edgeservice.ProviderTunnelStream{Frames: h.frames} } func (h *fakeTunnelHandle) Close() {} func (h *fakeTunnelHandle) WaitTimeout() time.Duration { return 5 * time.Second } func (s *fakeRunService) SubmitProviderTunnel(_ context.Context, req edgeservice.SubmitProviderTunnelRequest) (edgeservice.ProviderTunnelResult, error) { s.submitMu.Lock() s.tunnelReqs = append(s.tunnelReqs, req) s.submitMu.Unlock() if s.tunnelErr != nil { return nil, s.tunnelErr } target := req.Target if s.tunnelServedTarget != "" { target = s.tunnelServedTarget } body := req.Body if req.BuildBody != nil { built, err := req.BuildBody(target) if err != nil { return nil, err } body = built } s.submitMu.Lock() s.tunnelBodies = append(s.tunnelBodies, body) s.submitMu.Unlock() frames := s.tunnelFrames if frames == nil && s.tunnelProviderURL != "" { relayed, err := relayProviderFixtureFrames(s.tunnelProviderURL, req.Method, req.Path, body) if err != nil { return nil, err } frames = relayed } return &fakeTunnelHandle{ dispatch: edgeservice.RunDispatch{ RunID: "run-tunnel", NodeID: "node-1", ModelGroupKey: req.ModelGroupKey, Adapter: req.Adapter, Target: target, SessionID: req.SessionID, TimeoutSec: 5, }, frames: frames, }, nil } func (s *fakeRunService) tunnelReqsSnapshot() []edgeservice.SubmitProviderTunnelRequest { s.submitMu.Lock() defer s.submitMu.Unlock() return append([]edgeservice.SubmitProviderTunnelRequest(nil), s.tunnelReqs...) } func (s *fakeRunService) tunnelBodiesSnapshot() [][]byte { s.submitMu.Lock() defer s.submitMu.Unlock() return append([][]byte(nil), s.tunnelBodies...) } // relayProviderFixtureFrames performs the provider HTTP request the way the // Node relay does and converts status/header/body bytes into ordered tunnel // frames, chunking the body at odd boundaries to exercise ordered writes. func relayProviderFixtureFrames(providerURL, method, path string, body []byte) (chan *iop.ProviderTunnelFrame, error) { httpReq, err := http.NewRequest(method, providerURL+path, bytes.NewReader(body)) if err != nil { return nil, err } httpReq.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(httpReq) if err != nil { return nil, err } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, err } headers := make(map[string]string) for k, values := range resp.Header { if len(values) > 0 { headers[k] = values[0] } } const chunkSize = 7 frames := make(chan *iop.ProviderTunnelFrame, len(respBody)/chunkSize+3) seq := int64(0) frames <- &iop.ProviderTunnelFrame{ RunId: "run-tunnel", TunnelId: "tunnel-1", Sequence: seq, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: int32(resp.StatusCode), Headers: headers, } seq++ for off := 0; off < len(respBody); off += chunkSize { end := off + chunkSize if end > len(respBody) { end = len(respBody) } frames <- &iop.ProviderTunnelFrame{ RunId: "run-tunnel", TunnelId: "tunnel-1", Sequence: seq, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: append([]byte(nil), respBody[off:end]...), } seq++ } frames <- &iop.ProviderTunnelFrame{ RunId: "run-tunnel", TunnelId: "tunnel-1", Sequence: seq, Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true, } close(frames) return frames, nil } 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(`{ "metadata":{"iop_response_mode":"transformed"}, "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(`{ "metadata":{"iop_response_mode":"transformed"}, "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(`{ "metadata":{"iop_response_mode":"transformed"}, "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 TestChatCompletionsSynthesizesTextToolCallsForProviderRoute(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(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object"}}}], "tool_choice":"auto" }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } choice := resp.Choices[0] if choice.FinishReason != "tool_calls" || len(choice.Message.ToolCalls) == 0 { t.Fatalf("provider text should be synthesized: %+v", choice) } if strings.Contains(choice.Message.Content, "") { t.Fatalf("provider raw content should not be preserved: %q", choice.Message.Content) } } func TestChatCompletionsSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "두 줄을 `18083` -> `18081`로 수정하고 검증을 진행한다.\n\n" + editToolCallFixture()} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "messages":[{"role":"user","content":"edit"}], "tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"additionalProperties":false}}}], "tool_choice":"auto" }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } body := w.Body.String() if strings.Contains(body, ":18081/v1/chat/completions") { t.Fatalf("newText lost angle-bracket placeholder: %+v", edit["newText"]) } } func TestChatCompletionsStreamSynthesizesEditToolCallWithMarkdownAndAngleBracketPlaceholder(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "수정합니다.\n\n" + editToolCallFixture()} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "stream":true, "messages":[{"role":"user","content":"edit"}], "tools":[{"type":"function","function":{"name":"edit","parameters":{"type":"object","properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"oldText":{"type":"string"},"newText":{"type":"string"}},"required":["newText"],"additionalProperties":false}}},"required":["path","edits"],"additionalProperties":false}}}], "tool_choice":"auto" }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } body := w.Body.String() if strings.Contains(body, ":18081/v1/chat/completions \\\n -H \"Content-Type: application/json\"\n```", }}) return "\n" + "\n" + "\napps/client/test/client_bootstrap_test.dart\n\n" + "\n" + string(edits) + "\n\n" + "\n" + "" } func decodeEditToolArguments(t *testing.T, raw string) map[string]any { t.Helper() var args map[string]any if err := json.Unmarshal([]byte(raw), &args); err != nil { t.Fatalf("arguments JSON: %v", err) } edits, ok := args["edits"].([]any) if !ok || len(edits) != 1 { t.Fatalf("edits argument: %+v", args["edits"]) } if _, ok := edits[0].(map[string]any); !ok { t.Fatalf("edit item: %+v", edits[0]) } return args } func TestChatCompletionsSynthesizesProviderTextToolCallsWhenFallbackMarked(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "먼저 현재 git 상태를 확인하겠습니다.\n\n\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(`{ "metadata":{"iop_response_mode":"transformed"}, "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 TestChatCompletionsSanitizesKnownSentinelToken(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)} fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking <|mask_end|>"} fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "messages":[{"role":"user","content":"hi"}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if strings.Contains(resp.Choices[0].Message.Content, "<|mask_end|>") || strings.Contains(resp.Choices[0].Message.ReasoningContent, "<|mask_end|>") { t.Fatalf("sentinel leaked in response: %+v", resp.Choices[0].Message) } if resp.Choices[0].Message.Content != "answer" || resp.Choices[0].Message.ReasoningContent != "thinking" { t.Fatalf("unexpected sanitized output: %+v", resp.Choices[0].Message) } } func TestChatCompletionsSynthesizesToolCallAndDropsTrailingSentinel(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "\n\n[\"git status\"]\n\n<|mask_end|>"} fake.events <- &iop.RunEvent{Type: "complete"} srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands","parameters":{"type":"object","properties":{"commands":{"type":"array","items":{"type":"string"}}},"required":["commands"]}}}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if strings.Contains(w.Body.String(), "<|mask_end|>") { t.Fatalf("sentinel leaked in response body: %s", w.Body.String()) } if resp.Choices[0].FinishReason != "tool_calls" || len(resp.Choices[0].Message.ToolCalls) != 1 { t.Fatalf("expected structured tool call, got %+v", resp.Choices[0]) } if resp.Choices[0].Message.Content != "" { t.Fatalf("trailing sentinel should not become content: %q", resp.Choices[0].Message.Content) } } func TestChatCompletionsPassesThroughNativeToolCallsFromProviderRoute(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)} fake.events <- &iop.RunEvent{ Type: "complete", Metadata: map[string]string{ "finish_reason": "tool_calls", runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`, }, } srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands"}}], "tool_choice":"auto" }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } var resp chatCompletionResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } choice := resp.Choices[0] if choice.FinishReason != "tool_calls" { t.Fatalf("finish_reason: got %q, want tool_calls", choice.FinishReason) } if len(choice.Message.ToolCalls) != 1 { t.Fatalf("tool_calls len: got %d", len(choice.Message.ToolCalls)) } call := choice.Message.ToolCalls[0].(map[string]any) if call["id"] != "call_1" { t.Fatalf("tool call id: got %+v", call["id"]) } } func TestChatCompletionsNativeToolCallPreservesLeadingProse(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: "선행 prose\n\n[\"git status\"]"} 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(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands"}}], "tool_choice":"auto" }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusOK { t.Fatalf("status: got %d body=%s", w.Code, w.Body.String()) } body := w.Body.String() if strings.Contains(body, "true"} run1Events <- &iop.RunEvent{Type: "complete"} run2Events := make(chan *iop.RunEvent, 2) run2Events <- &iop.RunEvent{Type: "delta", Delta: "true"} run2Events <- &iop.RunEvent{Type: "complete"} fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}} srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands"}}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String()) } var resp errorResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Error.Type != "tool_validation_error" { t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type) } if !strings.Contains(resp.Error.Message, "delete_everything") { t.Fatalf("unexpected error message: %q", resp.Error.Message) } } func TestChatCompletionsLeavesUnknownTemplateToolCallAsContent(t *testing.T) { run1Events := make(chan *iop.RunEvent, 2) run1Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"} run1Events <- &iop.RunEvent{Type: "complete"} run2Events := make(chan *iop.RunEvent, 2) run2Events <- &iop.RunEvent{Type: "delta", Delta: "{{delete_everything(confirm=True)}}"} run2Events <- &iop.RunEvent{Type: "complete"} fake := &fakeRunService{eventRuns: []chan *iop.RunEvent{run1Events, run2Events}} srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "model":"qwen3.6:35b", "messages":[{"role":"user","content":"status"}], "tools":[{"type":"function","function":{"name":"run_commands"}}] }`)) w := httptest.NewRecorder() srv.handleChatCompletions(w, req) if w.Code != http.StatusBadGateway { t.Fatalf("status: got %d, want 502; body=%s", w.Code, w.Body.String()) } var resp errorResponse if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp.Error.Type != "tool_validation_error" { t.Fatalf("unexpected error type: got %q, want tool_validation_error", resp.Error.Type) } if !strings.Contains(resp.Error.Message, "delete_everything") { t.Fatalf("unexpected error message: %q", resp.Error.Message) } } 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(`{ "metadata":{"iop_response_mode":"transformed"}, "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 TestChatCompletionsStreamWithToolsBuffersTextUntilComplete(t *testing.T) { fake := &fakeRunService{events: bufferedRunEvents( &iop.RunEvent{Type: "delta", Delta: "streaming before complete"}, &iop.RunEvent{Type: "complete"}, )} srv := NewServer(config.EdgeOpenAIConf{Adapter: "openai_compat"}, fake, nil) req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{ "metadata":{"iop_response_mode":"transformed"}, "model":"stream-model", "stream":true, "messages":[{"role":"user","content":"explain sudo"}], "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()) } reqs := fake.reqsSnapshot() if len(reqs) != 1 { t.Fatalf("submit attempts: got %d want 1", len(reqs)) } if reqs[0].Metadata[runtimeMetadataToolValidationAttempt] != "1" { t.Fatalf("tool stream should enable buffered validation metadata: %+v", reqs[0].Metadata) } body := w.Body.String() if !strings.Contains(body, `"content":"streaming before complete"`) || !strings.Contains(body, `"finish_reason":"stop"`) || !strings.Contains(body, "data: [DONE]") { t.Fatalf("buffered stream did not emit final content:\n%s", body) } } 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(`{ "metadata":{"iop_response_mode":"transformed"}, "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 TestChatCompletionsStreamDropsRawTextWhenNativeToolCallsArrive(t *testing.T) { fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)} fake.events <- &iop.RunEvent{Type: "delta", Delta: `["git status"]`} 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(`{ "metadata":{"iop_response_mode":"transformed"}, "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, "[\"git status\"]"} 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(`{ "metadata":{"iop_response_mode":"transformed"}, "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, `"content":"선행 prose"`) { t.Fatalf("stream dropped leading prose:\n%s", body) } 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, "