- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
273 lines
9.8 KiB
Go
273 lines
9.8 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)
|
|
}
|
|
}
|