diff options
| author | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:55:33 +0000 |
|---|---|---|
| committer | Ethan Werner <mail@ethanwerner.com> | 2026-10-10 20:56:50 +0000 |
| commit | f8423ebea413605b42de23f5dd06c5479d3fa1dd (patch) | |
| tree | 2612a5eb026c59e7aae01f10a1cf62f2ee43042c /tests/test_provider.cpp | |
Diffstat (limited to 'tests/test_provider.cpp')
| -rw-r--r-- | tests/test_provider.cpp | 647 |
1 files changed, 647 insertions, 0 deletions
diff --git a/tests/test_provider.cpp b/tests/test_provider.cpp new file mode 100644 index 0000000..7bc983d --- /dev/null +++ b/tests/test_provider.cpp @@ -0,0 +1,647 @@ +#include "provider.h" + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "config.h" +#include "dotagents.h" +#include "message.h" +#include "session.h" + +static int failures = 0; +static int checks = 0; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, (msg)); \ + } \ + } while (0) + +#define CHECK_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (!hay || !strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (want substring `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle)); \ + } \ + } while (0) + +#define CHECK_NOT_CONT(hay, needle, msg) \ + do { \ + checks++; \ + if (hay && strstr(hay, needle)) { \ + failures++; \ + fprintf(stderr, "FAIL %s:%d: %s (found unwanted `%s`)\n", \ + __FILE__, __LINE__, (msg), (needle)); \ + } \ + } while (0) + +static const char *SENTINEL = "SENTINEL-PREAMBLE-XYZ"; + +static void mock_destroy(llsh_model *m) { (void)m; } +static llsh_model MOCK = {NULL, NULL, mock_destroy}; + +static session *make_session(const char *provider) { + config c; + memset(&c, 0, sizeof c); + c.provider = provider_parse(provider, NULL); + c.model = xstrdup("test-model"); + c.preamble = xstrdup(SENTINEL); + dotagents d; + memset(&d, 0, sizeof d); + return session_new_with_model(&MOCK, c, d); +} + +/* The preamble must reach the wire for every provider. Before the fix it was + * only emitted for Anthropic and silently dropped for OpenAI/Ollama. */ +static void test_preamble_openai(void) { + session *s = make_session("openai"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "openai body built"); + /* OpenAI carries the system prompt as a leading system message. */ + CHECK_CONT(body, "\"role\":\"system\"", "has a system message"); + CHECK_CONT(body, SENTINEL, "system message carries the preamble"); + CHECK_CONT(body, "\"role\":\"user\"", "user message still present"); + CHECK_CONT(body, "hello", "prompt present"); + free(body); + session_free(s); +} + +static void test_preamble_ollama(void) { + session *s = make_session("ollama"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "ollama body built"); + /* Ollama carries the system prompt as a top-level `system` field. */ + CHECK_CONT(body, "\"system\":", "has a top-level system field"); + CHECK_CONT(body, SENTINEL, "system field carries the preamble"); + CHECK_CONT(body, "hello", "prompt present"); + free(body); + session_free(s); +} + +static void test_preamble_anthropic(void) { + session *s = make_session("anthropic"); + char *body = provider_preview_request(s, "hello", true); + CHECK(body != NULL, "anthropic body built"); + CHECK_CONT(body, "\"system\":", "has a system field"); + CHECK_CONT(body, SENTINEL, "system carries the preamble"); + free(body); + session_free(s); +} + +/* A model name with JSON-special characters must not corrupt the body. */ +static void test_model_name_escaped(void) { + config c; + memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("we\"ird/model"); + c.preamble = xstrdup(SENTINEL); + dotagents d; + memset(&d, 0, sizeof d); + session *s = session_new_with_model(&MOCK, c, d); + char *body = provider_preview_request(s, "hi", false); + CHECK(body != NULL, "body built"); + /* The raw `"` in the model name must be escaped to \". */ + CHECK_CONT(body, "we\\\"ird/model", "model name quote is escaped"); + free(body); + session_free(s); +} + +static void test_no_preamble_omits_system(void) { + config c; + memset(&c, 0, sizeof c); + c.provider = PROV_OPENAI; + c.model = xstrdup("m"); + c.preamble = NULL; /* no preamble */ + dotagents d; + memset(&d, 0, sizeof d); + session *s = session_new_with_model(&MOCK, c, d); + char *body = provider_preview_request(s, "hi", false); + CHECK(body != NULL, "body built"); + CHECK_NOT_CONT(body, "\"role\":\"system\"", "no system message without preamble"); + free(body); + session_free(s); +} + +/* A turn stopped mid tool-loop leaves an assistant message with more tool + * calls than the tool-result messages that follow it. The trim must drop the + * surplus calls so the history stays a valid request. */ +static void test_trim_dangling_calls(void) { + message am = msg_assistant_text("working on it"); + msg_push_assistant_tool_call(am, "read_file", "{\"path\":\"a\"}"); + msg_push_assistant_tool_call(am, "write_file", "{\"path\":\"b\"}"); + message r1 = msg_user_tool_result("call-1", "read_file", "contents"); + message msgs[2] = {am, r1}; + + CHECK(am.assistant().parts.size() == 3, + "assistant has text + 2 calls before trim"); + provider_trim_dangling_calls(msgs, 2); + int calls = 0; + for (size_t i = 0; i < msgs[0].assistant().parts.size(); i++) + if (std::holds_alternative<asst_tool_call_part>( + msgs[0].assistant().parts[i])) + calls++; + CHECK(calls == 1, "surplus call trimmed to match the single result"); + + /* Fully-answered calls must be untouched. */ + message am2 = msg_assistant_text(""); + msg_push_assistant_tool_call(am2, "read_file", "{}"); + message ra = msg_user_tool_result("c1", "read_file", "x"); + message rb = msg_user_tool_result("c2", "write_file", "ok"); + msg_push_assistant_tool_call(am2, "write_file", "{}"); + message msgs2[3] = {am2, ra, rb}; + provider_trim_dangling_calls(msgs2, 3); + int calls2 = 0; + for (size_t i = 0; i < msgs2[0].assistant().parts.size(); i++) + if (std::holds_alternative<asst_tool_call_part>( + msgs2[0].assistant().parts[i])) + calls2++; + CHECK(calls2 == 2, "complete call/result pair is preserved"); +} + +/* Reasoning models return the chain of thought in provider-specific fields. + * None of the parsers extracted it before the fix, so it was never displayed. */ +static void test_reasoning_openai(void) { + const char *body = + "{\"choices\":[{\"message\":{\"content\":\"42\"," + "\"reasoning_content\":\"think... think... 42\"}}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OPENAI; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "openai body parses"); + CHECK_CONT(comp.text, "42", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted (was NULL before fix)"); + CHECK_CONT(comp.reasoning, "think...", "reasoning content preserved"); + llm_completion_free(&comp); +} + +static void test_reasoning_ollama(void) { + const char *body = + "{\"message\":{\"content\":\"the answer\"," + "\"thinking\":\"pondering the answer...\"}}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OLLAMA; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "ollama body parses"); + CHECK_CONT(comp.text, "the answer", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted from message.thinking"); + CHECK_CONT(comp.reasoning, "pondering", "reasoning content preserved"); + llm_completion_free(&comp); +} + +static void test_reasoning_anthropic(void) { + const char *body = + "{\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm, let me " + "reason...\"},{\"type\":\"text\",\"text\":\"done\"}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_ANTHROPIC; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "anthropic body parses"); + CHECK_CONT(comp.text, "done", "answer text extracted"); + CHECK(comp.reasoning != NULL, "reasoning extracted from thinking block"); + CHECK_CONT(comp.reasoning, "reason", "reasoning content preserved"); + llm_completion_free(&comp); +} + +/* A plain (non-reasoning) response must not fabricate a reasoning field. */ +static void test_no_reasoning_when_absent(void) { + const char *body = + "{\"choices\":[{\"message\":{\"content\":\"just an answer\"}}]}"; + llm_conn conn; memset(&conn, 0, sizeof conn); + conn.provider = LLM_OPENAI; conn.model = "m"; + llm_completion comp; memset(&comp, 0, sizeof comp); + CHECK(llm_parse_body(&conn, body, &comp) == 0, "plain body parses"); + CHECK_CONT(comp.text, "just an answer", "answer text extracted"); + CHECK(comp.reasoning == NULL, "no reasoning field fabricated"); + llm_completion_free(&comp); +} + +/* ------------------------------------------------------------------ */ +/* sse_parse_line -- the pure streaming unit (AUDIT phase 2 test seam) */ +/* ------------------------------------------------------------------ */ + +/* OpenAI: text deltas, two interleaved multi-chunk tool calls, + * finish_reason, [DONE], usage. */ +static void test_sse_openai(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"Hel\"}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}", &a); + CHECK(a.text.len == 5, "text deltas accumulate"); + CHECK_CONT(a.text.data, "Hello", "text content"); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":0,\"id\":\"c1\",\"function\":{\"name\":\"read_file\"," + "\"arguments\":\"{\\\"pa\"}}]}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":1,\"id\":\"c2\",\"function\":{\"name\":\"grep\"," + "\"arguments\":\"{\\\"pat\"}}]}}]}", &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":0,\"function\":{\"arguments\":\"th\\\":\\\"a.txt\\\"}\"}}]}}]}", + &a); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[" + "{\"index\":1,\"function\":{\"arguments\":\"tern\\\":\\\"x\\\"}\"}}]}}]}", + &a); + CHECK(a.tc_len == 2, "two tool calls indexed"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "c1") == 0, "call 0 id"); + CHECK(a.tc_id[1] && strcmp(a.tc_id[1], "c2") == 0, "call 1 id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "read_file") == 0, "call 0 name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\"a.txt\"", "call 0 args assembled"); + CHECK_CONT(a.tc_args[1].data, "\"pattern\":\"x\"", "call 1 args assembled"); + llm_sse_parse_line(LLM_OPENAI, + "data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}]}", + &a); + CHECK(a.done, "finish_reason marks the stream done"); + llm_sse_parse_line(LLM_OPENAI, "data: [DONE]", &a); + llm_sse_parse_line(LLM_OPENAI, "data: {\"usage\":{\"prompt_tokens\":42}}", &a); + CHECK(a.input_tokens == 42, "usage.prompt_tokens captured"); + llm_sse_acc_free(&a); +} + +/* Anthropic: event:/data: pairing, thinking + text blocks, a tool_use + * block with chunked input_json_delta, message_start usage, message_stop. */ +static void test_sse_anthropic(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_ANTHROPIC); + llm_sse_parse_line(LLM_ANTHROPIC, "event: message_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"message\":{\"usage\":{\"input_tokens\":7}}}", &a); + CHECK(a.input_tokens == 7, "usage from message_start"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"thinking\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"hmm \"}}", + &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"text\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"text_delta\",\"text\":\"ok\"}}", &a); + CHECK_CONT(a.reasoning.data, "hmm", "thinking_delta captured"); + CHECK_CONT(a.text.data, "ok", "text_delta captured"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_start", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"content_block\":{\"type\":\"tool_use\",\"id\":\"t1\"," + "\"name\":\"write_file\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "event: content_block_delta", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"{\\\"pa\"}}", &a); + llm_sse_parse_line(LLM_ANTHROPIC, + "data: {\"delta\":{\"type\":\"input_json_delta\"," + "\"partial_json\":\"th\\\":\\\"x\\\"}\"}}", &a); + CHECK(a.tc_len == 1, "one tool_use block"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "t1") == 0, "tool_use id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "write_file") == 0, "tool_use name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\"x\"", "chunked partial_json assembled"); + llm_sse_parse_line(LLM_ANTHROPIC, "event: message_stop", &a); + llm_sse_parse_line(LLM_ANTHROPIC, "data: [DONE]", &a); + CHECK(a.done, "message_stop marks done"); + llm_sse_acc_free(&a); +} + +/* Ollama NDJSON: content + thinking chunks, prompt_eval_count, tool_calls + * on the done chunk (object arguments), done:true. */ +static void test_sse_ollama(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OLLAMA); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"He\"}}", &a); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"thinking\":\"think\"}}", &a); + llm_sse_parse_line(LLM_OLLAMA, "{\"message\":{\"content\":\"llo\"}}", &a); + CHECK_CONT(a.text.data, "Hello", "content chunks accumulate"); + CHECK_CONT(a.reasoning.data, "think", "thinking chunk captured"); + llm_sse_parse_line(LLM_OLLAMA, "{\"prompt_eval_count\":9}", &a); + CHECK(a.input_tokens == 9, "prompt_eval_count captured"); + llm_sse_parse_line(LLM_OLLAMA, + "{\"message\":{\"tool_calls\":[{\"id\":\"o1\",\"function\":" + "{\"name\":\"list_dir\",\"arguments\":{\"path\":\".\"}}}]}," + "\"done\":true}", &a); + CHECK(a.tc_len == 1, "tool call on the done chunk"); + CHECK(a.tc_id[0] && strcmp(a.tc_id[0], "o1") == 0, "tool id"); + CHECK(a.tc_name[0] && strcmp(a.tc_name[0], "list_dir") == 0, "tool name"); + CHECK_CONT(a.tc_args[0].data, "\"path\":\".\"", "object args serialized"); + CHECK(a.done, "done:true marks done"); + llm_sse_acc_free(&a); +} + +/* Lines outside the protocol (stray text, bad JSON) are ignored without + * corrupting the accumulator. */ +static void test_sse_noise_lines(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_sse_parse_line(LLM_OPENAI, "event: ping", &a); /* not `data:` -> ignored */ + llm_sse_parse_line(LLM_OPENAI, "data: not-json", &a); /* bad JSON -> ignored */ + llm_sse_parse_line(LLM_OPENAI, "data: {\"choices\":[]}", &a); /* empty choices */ + CHECK(a.text.len == 0 && a.tc_len == 0 && !a.done, "noise left acc empty"); + llm_sse_acc_free(&a); + + llm_sse_acc b; + llm_sse_acc_init(&b, LLM_ANTHROPIC); + llm_sse_parse_line(LLM_ANTHROPIC, "data: {\"no\":\"event\"}", &b); /* no event set */ + llm_sse_parse_line(LLM_ANTHROPIC, "data: {", &b); /* truncated JSON */ + CHECK(b.text.len == 0 && b.tc_len == 0 && !b.done, "anthropic noise ignored"); + llm_sse_acc_free(&b); + + llm_sse_acc c; + llm_sse_acc_init(&c, LLM_OLLAMA); + llm_sse_parse_line(LLM_OLLAMA, "hello", &c); /* not JSON */ + llm_sse_parse_line(LLM_OLLAMA, "{}", &c); /* no message field */ + CHECK(c.text.len == 0 && c.tc_len == 0 && !c.done, "ollama noise ignored"); + llm_sse_acc_free(&c); +} + +/* sse_acc_into: unnamed/unargued calls get default ids and `{}` args. */ +static void test_sse_acc_into_defaults(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OLLAMA); + llm_acc_push_tool(&a, NULL, NULL); + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&a, &c); + CHECK(c.tc_len == 1, "one materialized call"); + CHECK(c.tc_id[0] && strstr(c.tc_id[0], "call-1") != NULL, "default id"); + CHECK(c.tc_args[0] && strcmp(c.tc_args[0], "{}") == 0, "default empty args"); + llm_completion_free(&c); + llm_sse_acc_free(&a); +} + +/* sse_acc_into: non-empty text/reasoning transfer ownership to `c` and must + * survive completion_free + sse_acc_free without a double-free. Regression: + * the old code freed the stolen buffer right after handing it to `c`, + * dangling c->text/c->reasoning (crashed on real streamed responses). */ +static void test_sse_acc_into_text_reasoning_ownership(void) { + llm_sse_acc a; + llm_sse_acc_init(&a, LLM_OPENAI); + llm_buf_put(&a.text, "the answer is 42"); + llm_buf_put(&a.reasoning, "pondering deeply"); + llm_acc_push_tool(&a, xstrdup("call-9"), xstrdup("read_file")); + llm_buf_put(&a.tc_args[0], "\"path\":\"AUDIT.md\""); + + llm_completion c; + memset(&c, 0, sizeof c); + llm_sse_acc_into(&a, &c); + CHECK(c.text && strcmp(c.text, "the answer is 42") == 0, + "text transferred intact"); + CHECK(c.reasoning && strcmp(c.reasoning, "pondering deeply") == 0, + "reasoning transferred intact"); + CHECK(c.tc_len == 1 && c.tc_args[0] && + strcmp(c.tc_args[0], "\"path\":\"AUDIT.md\"") == 0, + "tool args transferred intact"); + /* Both frees must be safe: `c` owns the strings now, `a` is reset. */ + llm_completion_free(&c); + llm_sse_acc_free(&a); +} + +/* ================= llm_params -> request body ================= + * The `llm` tool threads llm_params (temperature, max_tokens, reasoning + * effort, thinking budget, response schema) into the wire body. These are + * pure request-builder tests (no network) per provider. */ + +static llm_message one_user(const char *text) { + static llm_part part; + memset(&part, 0, sizeof part); + part.kind = LLM_TEXT; + part.text = text; + llm_message m; + memset(&m, 0, sizeof m); + m.role = LLM_ROLE_USER; + m.parts = ∂ + m.part_count = 1; + return m; +} + +static void test_params_openai(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OPENAI; + c.model = "test-model"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.5; + p.max_tokens = 256; + p.reasoning_effort = "high"; + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "openai body built"); + if (body) { + CHECK_CONT(body, "\"temperature\":0.5", "openai temperature"); + CHECK_CONT(body, "\"max_tokens\":256", "openai max_tokens"); + CHECK_CONT(body, "\"reasoning_effort\":\"high\"", + "openai reasoning_effort"); + CHECK_CONT(body, "\"response_format\":{\"type\":\"json_schema\"", + "openai response_format"); + CHECK_CONT(body, "\"type\":\"object\"", "openai schema inlined"); + free(body); + } +} + +static void test_params_ollama(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OLLAMA; + c.model = "ollama-model"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.75; /* 3/4: exact in binary, so %.6g is stable */ + p.max_tokens = 128; + p.reasoning_effort = "medium"; + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p); + CHECK(body != NULL, "ollama body built"); + if (body) { + CHECK_CONT(body, "\"system\":\"SYS\"", "ollama top-level system"); + CHECK_CONT(body, "\"options\":{\"temperature\":0.75", + "ollama options.temperature"); + CHECK_CONT(body, "\"num_predict\":128", "ollama num_predict"); + CHECK_CONT(body, "\"think\":true", "ollama think enabled"); + CHECK_CONT(body, "\"format\":", "ollama format field"); + CHECK_CONT(body, "\"type\":\"object\"", "ollama schema"); + free(body); + } +} + +static void test_params_anthropic_thinking(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.thinking_budget = 16384; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "\"thinking\":{\"type\":\"enabled\"," + "\"budget_tokens\":16384}", + "anthropic thinking budget"); + /* max_tokens must be bumped above the thinking budget. */ + CHECK_CONT(body, "\"max_tokens\":17408", + "anthropic max_tokens > budget"); + free(body); + } +} + +static void test_params_anthropic_effort(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.reasoning_effort = "low"; /* maps to a 1024-token budget */ + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) + CHECK_CONT(body, "\"budget_tokens\":1024", + "effort 'low' maps to a 1024 budget"); + free(body); +} + +static void test_params_anthropic_temperature(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.temperature = 0.25; /* 1/4: exact in binary, no thinking budget */ + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "\"temperature\":0.25", "anthropic temperature"); + CHECK_NOT_CONT(body, "\"thinking\"", + "no thinking block without a budget"); + free(body); + } +} + +static void test_params_anthropic_schema(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_ANTHROPIC; + c.model = "claude"; + llm_params p; + llm_params_init(&p); + p.response_schema = "{\"type\":\"object\"}"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, "SYS", &m, 1, NULL, false, &p); + CHECK(body != NULL, "anthropic body built"); + if (body) { + CHECK_CONT(body, "SYS", "preamble kept"); + CHECK_CONT(body, "JSON Schema", "schema instruction appended"); + /* The schema is JSON-encoded into the system string, so its quotes + * are escaped in the body. */ + CHECK_CONT(body, "\\\"type\\\"", "schema text embedded (escaped)"); + CHECK_NOT_CONT(body, "response_format", + "anthropic has no response_format"); + free(body); + } +} + +static void test_params_unset(void) { + llm_conn c; + memset(&c, 0, sizeof c); + c.provider = LLM_OPENAI; + c.model = "m"; + + llm_message m = one_user("hi"); + char *body = llm_build_request_ex(&c, NULL, &m, 1, NULL, false, NULL); + CHECK(body != NULL, "body built with NULL params"); + if (body) { + CHECK_NOT_CONT(body, "temperature", "no temperature when unset"); + CHECK_NOT_CONT(body, "max_tokens", "no max_tokens when unset"); + CHECK_NOT_CONT(body, "response_format", + "no response_format when unset"); + free(body); + } +} + +/* ================= `compact` built-in tool ================= + * session_force_compact is the dispatch target for the `compact` tool call. */ + +static void test_force_compact_short(void) { + session *s = make_session("openai"); + session_push_history(s, msg_user_text("hello")); + session_push_history(s, msg_assistant_text("hi")); + compact_report rep; + memset(&rep, 0, sizeof rep); + char *err = NULL; + int rc = session_force_compact(s, &rep, &err); + /* user+assistant only: keep-boundary at index 0 -> no pass can run. */ + CHECK(rc == 0, "no pass on a too-short history"); + free(err); + session_free(s); +} + +static void test_force_compact_clears(void) { + session *s = make_session("openai"); + session_push_history(s, msg_user_text("read the file")); + session_push_history(s, msg_assistant_tool_call("read_file", "{}")); + session_push_history(s, msg_user_tool_result("c1", "read_file", + "a lot of cleared content")); + session_push_history(s, msg_assistant_text("done reading")); + session_push_history(s, msg_user_text("now edit it")); + + compact_report rep; + memset(&rep, 0, sizeof rep); + char *err = NULL; + int rc = session_force_compact(s, &rep, &err); + CHECK(rc == 1, "compaction pass ran"); + CHECK(rep.tier == TIER_FALLBACK_CLEAR, + "no summarizer -> fallback clear tier"); + CHECK(rep.cleared_tool_results >= 1, "stale tool result cleared"); + free(err); + session_free(s); +} + +int main(void) { + test_preamble_openai(); + test_preamble_ollama(); + test_preamble_anthropic(); + test_model_name_escaped(); + test_no_preamble_omits_system(); + test_trim_dangling_calls(); + test_reasoning_openai(); + test_reasoning_ollama(); + test_reasoning_anthropic(); + test_no_reasoning_when_absent(); + test_sse_openai(); + test_sse_anthropic(); + test_sse_ollama(); + test_sse_noise_lines(); + test_sse_acc_into_defaults(); + test_sse_acc_into_text_reasoning_ownership(); + test_params_openai(); + test_params_ollama(); + test_params_anthropic_thinking(); + test_params_anthropic_effort(); + test_params_anthropic_temperature(); + test_params_anthropic_schema(); + test_params_unset(); + test_force_compact_short(); + test_force_compact_clears(); + printf("provider: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +} |