iop/apps/edge/internal/openai/responses_handler_test.go

320 lines
12 KiB
Go

package openai
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"iop/packages/go/config"
iop "iop/proto/gen/iop"
)
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 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 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 TestResponsesSanitizesKnownSentinelToken(t *testing.T) {
fake := &fakeRunService{events: make(chan *iop.RunEvent, 3)}
fake.events <- &iop.RunEvent{Type: "reasoning_delta", Delta: "hidden <|mask_end|>"}
fake.events <- &iop.RunEvent{Type: "delta", Delta: "answer <|mask_end|>"}
fake.events <- &iop.RunEvent{Type: "complete", Usage: &iop.Usage{InputTokens: 1, OutputTokens: 1}}
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, 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.Contains(resp.OutputText, "<|mask_end|>") || strings.Contains(w.Body.String(), "<|mask_end|>") {
t.Fatalf("sentinel leaked in responses output: %s", w.Body.String())
}
if resp.OutputText != "answer" {
t.Fatalf("output_text: got %q, want answer", resp.OutputText)
}
}
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<attempt_completion>\n<result>done</result>\n</attempt_completion>\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 <attempt_completion> 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 := "<attempt_completion>\n<result>plain answer</result>\n</attempt_completion>"
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 TestResponsesStreamGateNormalizedHandlerUsesSingleTerminalBarrier(t *testing.T) {
fake := &fakeRunService{events: bufferedRunEvents(
&iop.RunEvent{Type: "reasoning_delta", Delta: "private reasoning"},
&iop.RunEvent{Type: "delta", Delta: "hello"},
&iop.RunEvent{Type: "complete", Metadata: map[string]string{
runtimeMetadataOpenAIToolCalls: `[{"id":"call-1","type":"function","function":{"name":"lookup","arguments":"{}"}}]`,
}},
)}
srv := NewServer(config.EdgeOpenAIConf{
Adapter: "ollama", Target: "served-model",
StreamEvidenceGate: config.StreamEvidenceGateConf{
Enabled: true,
Filters: []config.StreamGateFilterPolicyConf{
{Filter: config.StreamGateFilterRepeatGuard},
{Filter: config.StreamGateFilterSchemaGate},
{Filter: config.StreamGateFilterProviderError},
},
},
}, fake, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
"model":"client-model",
"input":"say hello",
"metadata":{"scheme":"object"}
}`))
w := newRecordingResponseWriter()
srv.routes().ServeHTTP(w, req)
if w.code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.code, w.body.String())
}
if w.headerCallCount() != 1 {
t.Fatalf("WriteHeader calls=%d, want one terminal commit", w.headerCallCount())
}
var response responsesResponse
if err := json.Unmarshal([]byte(w.body.String()), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.OutputText != "hello" {
t.Fatalf("output_text=%q", response.OutputText)
}
if len(response.Output) != 2 || response.Output[1].Type != "function_call" || response.Output[1].Name != "lookup" {
t.Fatalf("responses output shape=%+v", response.Output)
}
if strings.Count(w.body.String(), `"object":"response"`) != 1 {
t.Fatalf("response opening count body=%s", w.body.String())
}
}