- Add unit tests for OpenAI edge adapter (chat handler, stream reasoning, tool synthesis, auth routes, test support) - Add roadmap: agent-readable-repository-refactor milestone with subtask plans - Update PHASE.md, priority-queue.md, .gitignore - Delete old edge/node/openai.test files (replaced by proper test structure)
848 lines
33 KiB
Go
848 lines
33 KiB
Go
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
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: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"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: "<tool_call>\n<function=run_commands>\n<parameter=commands>\n[\"cd /config/workspace/iop && git status\"]\n</parameter>\n</function>\n</tool_call>"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "cli", Target: "codex"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"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, "<tool_call>") || 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: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"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: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"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: `<tool_call><function=run_commands><parameter=commands>["git status"]</parameter></function></tool_call>`}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
|
},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"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, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("stream leaked raw text tool call:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsToolsStreamNativeToolCallPreservesLeadingProse(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "선행 prose\n\n<tool_call><function=run_commands><parameter=commands>[\"git status\"]</parameter></function></tool_call>"}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
|
},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"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, "<tool_call") || strings.Contains(body, `\u003ctool_call`) {
|
|
t.Fatalf("stream leaked raw text tool call:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamDropsPartialTextToolCallSuffixWhenNativeToolCallsArrive(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
delta string
|
|
wantContent string
|
|
leakMarkers []string
|
|
}{
|
|
{
|
|
name: "xml partial candidate suffix",
|
|
delta: "checking status <tool_ca",
|
|
wantContent: `"content":"checking status"`,
|
|
leakMarkers: []string{`\u003ctool_ca`},
|
|
},
|
|
{
|
|
name: "mustache partial candidate suffix",
|
|
delta: "checking status {",
|
|
wantContent: `"content":"checking status"`,
|
|
leakMarkers: []string{`"content":"{"`},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: tc.delta}
|
|
fake.events <- &iop.RunEvent{
|
|
Type: "complete",
|
|
Metadata: map[string]string{
|
|
"finish_reason": "tool_calls",
|
|
runtimeMetadataOpenAIToolCalls: `[{"id":"call_1","type":"function","function":{"name":"run_commands","arguments":"{\"commands\":[\"git status\"]}"}}]`,
|
|
},
|
|
}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"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)
|
|
}
|
|
if !strings.Contains(body, tc.wantContent) {
|
|
t.Fatalf("stream dropped safe prefix content %s:\n%s", tc.wantContent, body)
|
|
}
|
|
for _, marker := range tc.leakMarkers {
|
|
if strings.Contains(body, marker) {
|
|
t.Fatalf("stream leaked partial text tool call marker %q:\n%s", marker, 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 TestChatCompletionsFallsBackToReasoningWhenContentEmpty(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "only"}
|
|
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":"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)
|
|
}
|
|
content := resp.Choices[0].Message.Content
|
|
if !strings.Contains(content, "thinking only") || !strings.Contains(content, "finish_reason=length") {
|
|
t.Fatalf("content fallback did not preserve reasoning and finish reason: %q", content)
|
|
}
|
|
if resp.Choices[0].Message.ReasoningContent != "thinking only" {
|
|
t.Fatalf("reasoning_content: got %q, expected \"thinking only\"", resp.Choices[0].Message.ReasoningContent)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsSuppressesReasoningContentWhenExcluded(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"}],
|
|
"include_reasoning": 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())
|
|
}
|
|
|
|
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 != "" {
|
|
t.Fatalf("reasoning_content: got %q, expected empty string due to include_reasoning=false", resp.Choices[0].Message.ReasoningContent)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsReportsHiddenReasoningWhenExcludedAndContentEmpty(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "thinking "}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "only"}
|
|
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":"client-model",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"include_reasoning": 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())
|
|
}
|
|
var resp chatCompletionResponse
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
content := resp.Choices[0].Message.Content
|
|
if content == "" {
|
|
t.Fatalf("content should report hidden reasoning instead of returning empty success")
|
|
}
|
|
if strings.Contains(content, "thinking only") {
|
|
t.Fatalf("hidden reasoning fallback leaked reasoning content: %q", content)
|
|
}
|
|
if !strings.Contains(content, "reasoning was hidden by include_reasoning=false") || !strings.Contains(content, "finish_reason=length") {
|
|
t.Fatalf("hidden reasoning fallback did not report cause and finish reason: %q", content)
|
|
}
|
|
if resp.Choices[0].Message.ReasoningContent != "" {
|
|
t.Fatalf("reasoning_content: got %q, expected empty string due to include_reasoning=false", 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 TestChatCompletionsStrictOutputStreamsExplicitReasoningSSE(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: "<attempt_completion><result>hi</result></attempt_completion>"}
|
|
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":"stream-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"include_reasoning": true
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, `"reasoning_content":"think"`) ||
|
|
!strings.Contains(body, `"content":"\u003cattempt_completion\u003e\u003cresult\u003ehi\u003c/result\u003e\u003c/attempt_completion\u003e"`) ||
|
|
!strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("unexpected SSE body:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamFallsBackToReasoningWhenContentEmpty(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"}
|
|
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, `"reasoning_content":"think only"`) ||
|
|
!strings.Contains(body, `"content":"think only`) ||
|
|
!strings.Contains(body, "finish_reason=length") ||
|
|
!strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("unexpected SSE body:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamSuppressesReasoningWhenExcluded(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"}],
|
|
"include_reasoning": 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())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "reasoning_content") || strings.Contains(body, "think") {
|
|
t.Fatalf("unexpected reasoning_content in SSE body when excluded:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `"content":"hi"`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("unexpected SSE body, missing content or DONE:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamSanitizesKnownSentinelTokenAcrossDeltas(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 5)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think <|mask"}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "_end|>"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "hello <|mask"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "_end|> world"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"metadata":{},
|
|
"model":"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, "<|mask") || strings.Contains(body, "mask_end") {
|
|
t.Fatalf("stream leaked sentinel token:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `"content":"hello "`) || !strings.Contains(body, `"content":"world"`) {
|
|
t.Fatalf("stream did not preserve sanitized content chunks:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, `"reasoning_content":"think "`) || !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("stream did not preserve sanitized reasoning or done marker:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStreamReportsHiddenReasoningWhenExcludedAndContentEmpty(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "think only"}
|
|
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"}],
|
|
"include_reasoning": 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())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, "reasoning_content") || strings.Contains(body, "think only") {
|
|
t.Fatalf("unexpected reasoning fallback in SSE body when excluded:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "reasoning was hidden by include_reasoning=false") ||
|
|
!strings.Contains(body, "finish_reason=length") ||
|
|
!strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("unexpected SSE body, missing hidden reasoning report or DONE:\n%s", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsStrictOutputNormalizesXMLStyleAgentResponse(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "---\n<thinking>hidden</thinking>\n\n<attempt_completion>\n<result>ok</result>\n</attempt completion>\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 := "<attempt_completion>\n<result>ok</result>\n</attempt_completion>"
|
|
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 TestChatCompletionsStrictOutputPreservesExplicitReasoningContent(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 4)}
|
|
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden thought"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<attempt_completion>\n<result>ok</result>\n</attempt_completion>"}
|
|
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"}],
|
|
"include_reasoning": 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())
|
|
}
|
|
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 != "<attempt_completion>\n<result>ok</result>\n</attempt_completion>" {
|
|
t.Fatalf("content: %q", resp.Choices[0].Message.Content)
|
|
}
|
|
if resp.Choices[0].Message.ReasoningContent != "hidden thought" {
|
|
t.Fatalf("reasoning_content: got %q, want hidden thought", 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<attempt_completion>\n<result>done</result>\n</attempt_completion>"},
|
|
{"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 <attempt_completion> 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 := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
|
|
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: "---\n<think>noise</think><read_file><path>a.go</path></read file>"}
|
|
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, "<think>") || strings.Contains(body, "</read file>") {
|
|
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: "<attempt_completion>\n"}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "<result>ok</result>\n</attempt_completion>"}
|
|
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)
|
|
}
|
|
}
|