- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package openai
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
func workspaceBoundCfg() config.EdgeOpenAIConf {
|
|
return config.EdgeOpenAIConf{
|
|
Adapter: "ollama",
|
|
Target: "llama3",
|
|
ModelRoutes: []config.OpenAIRouteEntry{
|
|
{Model: "codex", Adapter: "cli", Target: "codex", WorkspaceRequired: true},
|
|
{Model: "llama3", Adapter: "ollama", Target: "llama3"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestResponsesGenericMetadataContract(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
|
"model":"client-model",
|
|
"input":"test",
|
|
"metadata":{
|
|
"request_id":"req-001",
|
|
"workspace":"/config/workspace/iop",
|
|
"task_id":"task-123",
|
|
"custom":"value"
|
|
}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Target != "client-model" {
|
|
t.Fatalf("target: got %q, want client-model", fake.req.Target)
|
|
}
|
|
if fake.req.Workspace != "/config/workspace/iop" {
|
|
t.Fatalf("workspace: got %q", fake.req.Workspace)
|
|
}
|
|
if fake.req.Metadata["request_id"] != "req-001" {
|
|
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
|
|
}
|
|
if _, ok := fake.req.Metadata["workspace"]; ok {
|
|
t.Fatal("workspace should not be copied into run metadata")
|
|
}
|
|
if fake.req.Metadata["task_id"] != "task-123" {
|
|
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
|
|
}
|
|
if fake.req.Metadata["custom"] != "value" {
|
|
t.Fatalf("custom: got %q", fake.req.Metadata["custom"])
|
|
}
|
|
if fake.req.Metadata["openai_model"] != "client-model" {
|
|
t.Fatalf("openai_model: got %q", fake.req.Metadata["openai_model"])
|
|
}
|
|
if fake.req.ModelGroupKey != "client-model" {
|
|
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
|
|
}
|
|
if fake.req.Metadata["openai_stream"] != "false" {
|
|
t.Fatalf("openai_stream: got %q", fake.req.Metadata["openai_stream"])
|
|
}
|
|
if fake.req.Metadata["strict_output"] != "false" {
|
|
t.Fatalf("strict_output: got %q", fake.req.Metadata["strict_output"])
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsMetadataContractAndWorkspace(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"client-model",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"metadata":{
|
|
"request_id":"req-chat-001",
|
|
"workspace":"/config/workspace/iop",
|
|
"task_id":"task-123"
|
|
}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Target != "client-model" {
|
|
t.Fatalf("target: got %q, want client-model", fake.req.Target)
|
|
}
|
|
if fake.req.Workspace != "/config/workspace/iop" {
|
|
t.Fatalf("workspace: got %q", fake.req.Workspace)
|
|
}
|
|
if fake.req.Metadata["request_id"] != "req-chat-001" {
|
|
t.Fatalf("request_id: got %q", fake.req.Metadata["request_id"])
|
|
}
|
|
if fake.req.Metadata["task_id"] != "task-123" {
|
|
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
|
|
}
|
|
if fake.req.ModelGroupKey != "client-model" {
|
|
t.Fatalf("model group key: got %q, want client-model", fake.req.ModelGroupKey)
|
|
}
|
|
if _, ok := fake.req.Metadata["workspace"]; ok {
|
|
t.Fatal("workspace should not be copied into run metadata")
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRejectsObjectMetadata(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"m",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"metadata":{"cli":{"flag":"x"}}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRejectsSourceMetadata(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"m",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"metadata":{"source":"manual"}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "metadata.source is not supported") {
|
|
t.Fatalf("expected source unsupported error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRejectsNonStringMetadataValue(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"m",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"metadata":{"attempt":2}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "metadata.attempt must be a string") {
|
|
t.Fatalf("expected string metadata error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsRejectsObjectWorkspaceMetadata(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"m",
|
|
"messages":[{"role":"user","content":"hi"}],
|
|
"metadata":{
|
|
"workspace": {
|
|
"path": "/home/user/workspace",
|
|
"source_branch": "develop"
|
|
}
|
|
}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
|
|
t.Fatalf("expected workspace string error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesPreservesGenericTaskMetadata(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama"}, fake, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
|
"model":"client-model",
|
|
"input":"test",
|
|
"metadata":{
|
|
"request_id":"req-flat-001",
|
|
"task_id":"task-flat"
|
|
}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Metadata["task_id"] != "task-flat" {
|
|
t.Fatalf("task_id: got %q", fake.req.Metadata["task_id"])
|
|
}
|
|
}
|
|
|
|
func TestResponsesRejectsNonStringMetadata(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
|
|
cases := []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"cli only", `{"model":"m","input":"hi","metadata":{"cli":{"flag":"x"}}}`},
|
|
{"inference target", `{"model":"m","input":"hi","metadata":{"inference":{"target":"t"}}}`},
|
|
{"nomadcode metadata", `{"model":"m","input":"hi","metadata":{"nomadcode":{"task_id":"t"}}}`},
|
|
{"source metadata", `{"model":"m","input":"hi","metadata":{"source":"manual"}}`},
|
|
{"number metadata", `{"model":"m","input":"hi","metadata":{"attempt":2}}`},
|
|
{"boolean metadata", `{"model":"m","input":"hi","metadata":{"urgent":true}}`},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(tc.body))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResponsesRejectsObjectWorkspaceMetadata(t *testing.T) {
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama"}, &fakeRunService{}, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
|
"model":"m",
|
|
"input":"hi",
|
|
"metadata":{
|
|
"workspace": {
|
|
"path": "/home/user/workspace",
|
|
"source_branch": "develop"
|
|
}
|
|
}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.routes().ServeHTTP(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("got %d want 400, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "metadata.workspace must be a string") {
|
|
t.Fatalf("expected workspace string error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","input":"hello"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "workspace is required") {
|
|
t.Fatalf("expected workspace error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","input":"hello","metadata":{"workspace":"relative/path"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "absolute path") {
|
|
t.Fatalf("expected absolute path error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/path"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Workspace != "/abs/path" {
|
|
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
|
|
}
|
|
}
|
|
|
|
func TestResponsesNonRequiredRouteNoWorkspaceOK(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"llama3","input":"hello"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestResponsesSurfacesDistinctRunFailures(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
runError string
|
|
want []string
|
|
forbidAny []string
|
|
}{
|
|
{
|
|
name: "missing workspace path from node",
|
|
runError: "cli adapter: workspace not found: /abs/missing",
|
|
want: []string{`"type":"run_error"`, "cli adapter: workspace not found: /abs/missing"},
|
|
},
|
|
{
|
|
name: "inaccessible workspace path from node",
|
|
runError: "cli adapter: workspace inaccessible: /abs/private: permission denied",
|
|
want: []string{`"type":"run_error"`, "cli adapter: workspace inaccessible: /abs/private"},
|
|
},
|
|
{
|
|
name: "agent process exit failure",
|
|
runError: "command failed: exit status 7",
|
|
want: []string{`"type":"run_error"`, "command failed: exit status 7"},
|
|
forbidAny: []string{"workspace not found", "workspace inaccessible", "workspace is not a directory"},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 1)}
|
|
fake.events <- &iop.RunEvent{Type: "error", Error: tc.runError}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","input":"hello","metadata":{"workspace":"/abs/workspace"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("run failure: want 502, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
for _, want := range tc.want {
|
|
if !strings.Contains(w.Body.String(), want) {
|
|
t.Fatalf("expected body to contain %q, got %s", want, w.Body.String())
|
|
}
|
|
}
|
|
for _, forbidden := range tc.forbidAny {
|
|
if strings.Contains(w.Body.String(), forbidden) {
|
|
t.Fatalf("body should not contain %q, got %s", forbidden, w.Body.String())
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsWorkspaceRequiredRouteMissingWorkspace400(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "workspace is required") {
|
|
t.Fatalf("expected workspace error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsWorkspaceRequiredRouteRelativeWorkspace400(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent)}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"some/relative"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("relative workspace: want 400, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "absolute path") {
|
|
t.Fatalf("expected absolute path error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsWorkspaceRequiredRouteAbsoluteWorkspaceOK(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"codex","messages":[{"role":"user","content":"hi"}],"metadata":{"workspace":"/abs/workspace"}}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("absolute workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if fake.req.Workspace != "/abs/workspace" {
|
|
t.Fatalf("workspace not preserved: got %q", fake.req.Workspace)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsNonRequiredRouteNoWorkspaceOK(t *testing.T) {
|
|
fake := &fakeRunService{events: make(chan *iop.RunEvent, 2)}
|
|
fake.events <- &iop.RunEvent{Type: "delta", Delta: "ok"}
|
|
fake.events <- &iop.RunEvent{Type: "complete"}
|
|
srv := NewServer(workspaceBoundCfg(), fake, nil)
|
|
body := `{"model":"llama3","messages":[{"role":"user","content":"hi"}]}`
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("non-required route no workspace: want 200, got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
}
|