diff --git a/cmd/olliesrv/internal/backend/notools_test.go b/cmd/olliesrv/internal/backend/notools_test.go new file mode 100644 index 0000000..47fcb4d --- /dev/null +++ b/cmd/olliesrv/internal/backend/notools_test.go @@ -0,0 +1,105 @@ +package backend + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// These tests lock in the "no tools loaded" compatibility path: an agent with +// an empty autoLoadTools set (and no /tool_load) produces an empty tool slice, +// and every backend must then emit a request with NO "tools" field. This keeps +// Ollie compatible with models that do not support tool/function calling — the +// answer for such a model is simply to use a tool-free agent. + +// captureBody spins up an httptest server, records the raw request body, drains +// the (empty) stream, and returns the decoded top-level JSON object. +func captureBody(t *testing.T, build func(baseURL string) (Backend, error)) map[string]json.RawMessage { + t.Helper() + var rawBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawBody, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + b, err := build(srv.URL) + if err != nil { + t.Fatal(err) + } + msgs := []Message{{Role: "user", Content: "hi"}} + // No tools: the empty slice must yield a request without a tools field. + ch, err := b.ChatStream(context.Background(), msgs, nil, GenerationParams{}) + if err != nil { + t.Fatal(err) + } + for range ch { //nolint:revive + } + + var top map[string]json.RawMessage + json.Unmarshal(rawBody, &top) //nolint:errcheck + return top +} + +func assertNoToolsField(t *testing.T, top map[string]json.RawMessage) { + t.Helper() + if raw, ok := top["tools"]; ok { + t.Errorf("request must omit \"tools\" when no tools are loaded; got tools=%s", raw) + } + if raw, ok := top["tool_choice"]; ok { + t.Errorf("request must omit \"tool_choice\" when no tools are loaded; got tool_choice=%s", raw) + } +} + +func TestNoTools_OpenAIOmitsToolsField(t *testing.T) { + top := captureBody(t, func(baseURL string) (Backend, error) { + b, err := NewOpenAI("openai", baseURL, "key") + if err != nil { + return nil, err + } + b.SetModel("gpt-4o") + return b, nil + }) + assertNoToolsField(t, top) +} + +func TestNoTools_OpenRouterOmitsToolsField(t *testing.T) { + top := captureBody(t, func(baseURL string) (Backend, error) { + b, err := NewOpenAI("openrouter", baseURL, "key") + if err != nil { + return nil, err + } + b.SetModel("mistral-7b") + return b, nil + }) + assertNoToolsField(t, top) +} + +func TestNoTools_OllamaOmitsToolsField(t *testing.T) { + top := captureBody(t, func(baseURL string) (Backend, error) { + return NewOllama(baseURL) + }) + assertNoToolsField(t, top) +} + +func TestNoTools_AnthropicOmitsToolsField(t *testing.T) { + top := captureBody(t, func(baseURL string) (Backend, error) { + b, err := NewAnthropic("key") + if err != nil { + return nil, err + } + u, err := url.Parse(baseURL) + if err != nil { + return nil, err + } + b.baseURL = u + return b, nil + }) + assertNoToolsField(t, top) +}