From a7fe265365ba5e8bfbd0e65c827d38c038be7b0f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 9 Jun 2026 11:56:15 -0600 Subject: anthropic thinking --- docs/archive/anthropic-thinking-plan.md | 96 +++++++++++ libpanto/src/anthropic_messages_json.zig | 192 ++++++++++++++++++++++ libpanto/src/config.zig | 84 +++++++++- libpanto/src/file_system_jsonl_store.zig | 4 + libpanto/src/provider_anthropic_messages.zig | 73 ++++++++- libpanto/src/public.zig | 2 + libpanto/src/session.zig | 227 ++++++++++++++++++++++++++- src/config_file.zig | 81 ++++++++++ src/models_toml.zig | 176 +++++++++++++++++++-- 9 files changed, 918 insertions(+), 17 deletions(-) create mode 100644 docs/archive/anthropic-thinking-plan.md diff --git a/docs/archive/anthropic-thinking-plan.md b/docs/archive/anthropic-thinking-plan.md new file mode 100644 index 0000000..7577647 --- /dev/null +++ b/docs/archive/anthropic-thinking-plan.md @@ -0,0 +1,96 @@ +# Anthropic Thinking Support — Implementation Plan + +## Overview + +Add `Thinking`, `Effort`, `thinking_budget_tokens`, and `thinking_interleaved` +fields to `AnthropicMessagesConfig` and wire them through to the Anthropic +Messages API. OpenAI config is unchanged. + +## Design decisions + +- `Thinking = .enabled` by default (compatible with all current models incl. Haiku 4.5) +- `Effort = .medium` by default (only used when `thinking = .adaptive`) +- `thinking_budget_tokens: ?u32 = 32_000` (quality/cost shoulder per Anthropic docs; ignored when adaptive) +- `thinking_interleaved: bool = false` (requires explicit opt-in; beta header 400s on unsupported models) +- Always send `display: "summarized"` to get visible thinking text in all modes +- `effort` is only emitted when `thinking == .adaptive` +- `budget_tokens` uses `thinking_budget_tokens orelse (max_tokens - 1)` when `.enabled` without interleaving +- `interleaved-thinking-2025-05-14` beta header only sent when `thinking == .enabled && thinking_interleaved == true` +- `WireIdentity` for Anthropic extended with thinking/effort/budget/interleaved so sessions with different thinking configs are distinct + +--- + +## Phase 1 — Config types and `AnthropicMessagesConfig` fields ✅ COMPLETE + +**Files:** `libpanto/src/config.zig` + +- Add `Thinking` enum: `disabled`, `enabled`, `adaptive` +- Add `Effort` enum: `low`, `medium`, `high`, `xhigh`, `max` +- Add fields to `AnthropicMessagesConfig`: + - `thinking: Thinking = .enabled` + - `effort: Effort = .medium` + - `thinking_budget_tokens: ?u32 = 32_000` + - `thinking_interleaved: bool = false` +- Update `WireIdentity` to include `thinking`, `effort`, `thinking_budget_tokens`, + `thinking_interleaved` for Anthropic; `wireIdentity()` on Anthropic variant fills them in +- Update tests in `config.zig` + +--- + +## Phase 2 — Wire serialization (`anthropic_messages_json.zig`) ✅ COMPLETE + +**Files:** `libpanto/src/anthropic_messages_json.zig` + +- In `serializeRequest`, after the system prompt block: + - `thinking == .disabled` → nothing + - `thinking == .enabled` → emit `thinking: { type: "enabled", budget_tokens: B, display: "summarized" }` + where `B = thinking_budget_tokens orelse (max_tokens - 1)`, clamped so `B < max_tokens` + - `thinking == .adaptive` → emit `thinking: { type: "adaptive", display: "summarized" }` + top-level `effort: ""` +- Add unit tests covering all three modes, budget fallback/clamp, effort gating + +--- + +## Phase 3 — Beta header in provider (`provider_anthropic_messages.zig`) ✅ COMPLETE + +**Files:** `libpanto/src/provider_anthropic_messages.zig` + +- In `AnthropicMessagesRequest.open`, add `anthropic-beta` header only when + `config.thinking == .enabled && config.thinking_interleaved == true` +- Value: `"interleaved-thinking-2025-05-14"` +- Add unit test (or update existing open/serialize path test) confirming header presence/absence + +--- + +## Phase 4 — Session persistence ✅ COMPLETE + +**Files:** `libpanto/src/session.zig`, `libpanto/src/session_store.zig`, +`libpanto/src/file_system_jsonl_store.zig`, `libpanto/src/null_store.zig` + +- Extend the stamp serialization / deserialization in `session.zig` to + read/write `thinking`, `effort`, `thinking_budget_tokens`, `thinking_interleaved` + from/to JSONL +- Update `WireIdentity` in `session_store.zig` (distinct from config's) to carry the same fields +- Update `null_store.zig` and `file_system_jsonl_store.zig` default stamps +- Add round-trip tests + +--- + +## Phase 5 — `models.toml` parsing and `buildProviderConfig` ✅ COMPLETE + +**Files:** `src/models_toml.zig`, `src/config_file.zig` + +- Add Anthropic fields to `ModelDef`: `thinking`, `effort`, `thinking_budget_tokens`, `thinking_interleaved` +- Hand-parse all four from TOML in `ingestModel` (string→enum for thinking/effort, int for budget, bool for interleaved) +- Thread them through `buildProviderConfig` into `AnthropicMessagesConfig` +- Update schema doc comment in `models_toml.zig` and sample config in `src/luarocks_runtime.zig` +- Add parse and buildProviderConfig tests + +--- + +## Phase 6 — Build verification and test run ✅ COMPLETE + +- `zig build` clean +- `zig build test` passes (all existing + new tests) +- Smoke-check that the TOML sample in `luarocks_runtime.zig` is valid + +--- diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig index bccf692..78f4b5f 100644 --- a/libpanto/src/anthropic_messages_json.zig +++ b/libpanto/src/anthropic_messages_json.zig @@ -52,6 +52,41 @@ pub fn serializeRequest( try s.objectField("stream"); try s.write(true); + // Extended thinking configuration. + // `.disabled` — omit the field entirely. + // `.enabled` — manual budget: { type, budget_tokens, display }. + // `.adaptive` — adaptive mode: { type, display } + top-level effort. + switch (cfg.thinking) { + .disabled => {}, + .enabled => { + // Resolve budget: explicit value or fall back to max_tokens - 1. + // Clamp so budget is always strictly less than max_tokens (required + // by Anthropic when not using interleaved thinking). + const raw_budget: u32 = cfg.thinking_budget_tokens orelse (cfg.max_tokens -| 1); + const budget: u32 = if (raw_budget >= cfg.max_tokens) cfg.max_tokens -| 1 else raw_budget; + try s.objectField("thinking"); + try s.beginObject(); + try s.objectField("type"); + try s.write("enabled"); + try s.objectField("budget_tokens"); + try s.write(budget); + try s.objectField("display"); + try s.write("summarized"); + try s.endObject(); + }, + .adaptive => { + try s.objectField("thinking"); + try s.beginObject(); + try s.objectField("type"); + try s.write("adaptive"); + try s.objectField("display"); + try s.write("summarized"); + try s.endObject(); + try s.objectField("effort"); + try s.write(@tagName(cfg.effort)); + }, + } + // Build and emit the concatenated system prompt, if any. var system_buf: std.ArrayList(u8) = .empty; defer system_buf.deinit(allocator); @@ -1134,6 +1169,163 @@ test "serializeRequest - tool result with image part emits image block" { try testing.expectEqualStrings("iVBOR==", src.get("data").?.string); } +test "serializeRequest - thinking disabled omits thinking and effort fields" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); + cfg.thinking = .disabled; + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try testing.expect(root.get("thinking") == null); + try testing.expect(root.get("effort") == null); +} + +test "serializeRequest - thinking enabled explicit budget" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); // max_tokens = 1024 + cfg.thinking = .enabled; + cfg.thinking_budget_tokens = 500; // within max_tokens + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + const th = root.get("thinking").?.object; + try testing.expectEqualStrings("enabled", th.get("type").?.string); + try testing.expectEqual(@as(i64, 500), th.get("budget_tokens").?.integer); + try testing.expectEqualStrings("summarized", th.get("display").?.string); + try testing.expect(root.get("effort") == null); +} + +test "serializeRequest - thinking enabled null budget falls back to max_tokens - 1" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); // max_tokens = 1024 + cfg.thinking = .enabled; + cfg.thinking_budget_tokens = null; + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const th = parsed.value.object.get("thinking").?.object; + try testing.expectEqual(@as(i64, 1023), th.get("budget_tokens").?.integer); +} + +test "serializeRequest - thinking enabled budget clamped when >= max_tokens" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); // max_tokens = 1024 + cfg.thinking = .enabled; + cfg.thinking_budget_tokens = 2_000; // > max_tokens + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const th = parsed.value.object.get("thinking").?.object; + // Clamped to max_tokens - 1 = 1023 + try testing.expectEqual(@as(i64, 1023), th.get("budget_tokens").?.integer); +} + +test "serializeRequest - thinking adaptive default effort" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); + cfg.thinking = .adaptive; + // effort defaults to .medium + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + const th = root.get("thinking").?.object; + try testing.expectEqualStrings("adaptive", th.get("type").?.string); + try testing.expectEqualStrings("summarized", th.get("display").?.string); + try testing.expect(th.get("budget_tokens") == null); + try testing.expectEqualStrings("medium", root.get("effort").?.string); +} + +test "serializeRequest - thinking adaptive explicit effort levels" { + const allocator = testing.allocator; + const efforts = [_]struct { e: config_mod.Effort, name: []const u8 }{ + .{ .e = .low, .name = "low" }, + .{ .e = .medium, .name = "medium" }, + .{ .e = .high, .name = "high" }, + .{ .e = .xhigh, .name = "xhigh" }, + .{ .e = .max, .name = "max" }, + }; + for (efforts) |entry| { + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); + cfg.thinking = .adaptive; + cfg.effort = entry.e; + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + try testing.expectEqualStrings(entry.name, parsed.value.object.get("effort").?.string); + } +} + +test "serializeRequest - thinking adaptive ignores budget_tokens" { + const allocator = testing.allocator; + var conv = conversation.Conversation.init(allocator); + defer conv.deinit(); + try conv.addUserMessage("hi"); + + var cfg = testConfig("claude-x"); + cfg.thinking = .adaptive; + cfg.thinking_budget_tokens = 9_999; // should be ignored + var empty_tools = tool_registry_mod.ToolRegistry.init(allocator); + defer empty_tools.deinit(); + const body = try serializeRequest(allocator, &cfg, &conv, &empty_tools); + defer allocator.free(body); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, body, .{}); + defer parsed.deinit(); + const th = parsed.value.object.get("thinking").?.object; + try testing.expect(th.get("budget_tokens") == null); +} + test "serializeRequest - empty ToolUse input becomes {} not invalid JSON" { const allocator = testing.allocator; diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig index 5e01bb1..587f00a 100644 --- a/libpanto/src/config.zig +++ b/libpanto/src/config.zig @@ -41,6 +41,28 @@ pub const ReasoningEffort = enum { high, }; +/// Anthropic extended-thinking mode. +pub const Thinking = enum { + /// Do not request extended thinking. + disabled, + /// Manual extended thinking with an explicit token budget (`thinking_budget_tokens`). + /// Supported on all current models including Haiku 4.5. Default. + enabled, + /// Adaptive thinking: Claude decides when and how much to think. + /// Requires Opus 4.6 / Sonnet 4.6 or newer; automatic on Opus 4.7+. + adaptive, +}; + +/// Effort level sent with Anthropic adaptive thinking (`thinking = .adaptive`). +/// Ignored when `thinking` is `.enabled` or `.disabled`. +pub const Effort = enum { + low, + medium, + high, + xhigh, + max, +}; + pub const OpenAIChatConfig = struct { api_key: []const u8, base_url: []const u8, @@ -57,6 +79,22 @@ pub const AnthropicMessagesConfig = struct { api_version: []const u8 = "2023-06-01", /// Required by Anthropic's Messages API. max_tokens: u32 = 64_000, + /// Extended-thinking mode. `.enabled` sends a manual thinking block with + /// `thinking_budget_tokens`; `.adaptive` lets Claude decide and uses + /// `effort` instead. `.disabled` omits thinking entirely. + thinking: Thinking = .enabled, + /// Effort level for adaptive thinking. Only emitted on the wire when + /// `thinking == .adaptive`; ignored otherwise. + effort: Effort = .medium, + /// Maximum tokens Claude may spend on internal reasoning when + /// `thinking == .enabled`. `null` falls back to `max_tokens - 1`. + /// Ignored when `thinking == .adaptive` or `.disabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// When true and `thinking == .enabled`, sends the + /// `interleaved-thinking-2025-05-14` beta header so Claude can think + /// between tool calls. Ignored when `thinking == .adaptive` (interleaving + /// is automatic there) or `.disabled`. + thinking_interleaved: bool = false, }; /// Per-provider transport/auth/model configuration. Tagged by `APIStyle`. @@ -70,8 +108,8 @@ pub const ProviderConfig = union(APIStyle) { /// The wire-format provider identity for this config: the ground-truth /// `{api_style, base_url, model, reasoning}` that a turn is sent with. - /// Anthropic has no reasoning-effort knob, so it reports `.default`. - /// Borrowed slices; valid as long as the config is. + /// Anthropic carries thinking/effort/budget/interleaved instead of + /// reasoning. Borrowed slices; valid as long as the config is. pub fn wireIdentity(self: ProviderConfig) WireIdentity { return switch (self) { .openai_chat => |c| .{ @@ -84,7 +122,10 @@ pub const ProviderConfig = union(APIStyle) { .api_style = .anthropic_messages, .base_url = c.base_url, .model = c.model, - .reasoning = .default, + .thinking = c.thinking, + .effort = c.effort, + .thinking_budget_tokens = c.thinking_budget_tokens, + .thinking_interleaved = c.thinking_interleaved, }, }; } @@ -93,11 +134,24 @@ pub const ProviderConfig = union(APIStyle) { /// Wire-format provider identity (see `ProviderConfig.wireIdentity`). This /// is the same shape as `session_store.WireIdentity`; defined here to avoid /// a module cycle (config must not import session_store). +/// +/// OpenAI uses `reasoning`; Anthropic uses `thinking`/`effort`/ +/// `thinking_budget_tokens`/`thinking_interleaved`. Fields unused by the +/// active provider carry their zero/default values. pub const WireIdentity = struct { api_style: APIStyle, base_url: []const u8, model: []const u8, + /// OpenAI only. reasoning: ReasoningEffort = .default, + /// Anthropic only. + thinking: Thinking = .enabled, + /// Anthropic only; only meaningful when `thinking == .adaptive`. + effort: Effort = .medium, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_budget_tokens: ?u32 = 32_000, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_interleaved: bool = false, }; /// Compaction settings the agent consults when summarizing old history. @@ -220,6 +274,30 @@ test "ProviderConfig - anthropic_messages variant" { try t.expectEqual(APIStyle.anthropic_messages, cfg.style()); try t.expectEqualStrings("2023-06-01", cfg.anthropic_messages.api_version); try t.expectEqual(@as(u32, 64_000), cfg.anthropic_messages.max_tokens); + try t.expectEqual(Thinking.enabled, cfg.anthropic_messages.thinking); + try t.expectEqual(Effort.medium, cfg.anthropic_messages.effort); + try t.expectEqual(@as(?u32, 32_000), cfg.anthropic_messages.thinking_budget_tokens); + try t.expectEqual(false, cfg.anthropic_messages.thinking_interleaved); +} + +test "ProviderConfig - anthropic_messages wireIdentity carries thinking fields" { + const cfg: ProviderConfig = .{ .anthropic_messages = .{ + .api_key = "k", + .base_url = "https://api.anthropic.com", + .model = "claude-opus-4-8", + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + } }; + const id = cfg.wireIdentity(); + try t.expectEqual(APIStyle.anthropic_messages, id.api_style); + try t.expectEqual(Thinking.adaptive, id.thinking); + try t.expectEqual(Effort.high, id.effort); + try t.expectEqual(@as(?u32, null), id.thinking_budget_tokens); + try t.expectEqual(false, id.thinking_interleaved); + // reasoning field carries its zero default; not meaningful for Anthropic + try t.expectEqual(ReasoningEffort.default, id.reasoning); } test "ProviderConfig - openai_chat reasoning defaults to .default" { diff --git a/libpanto/src/file_system_jsonl_store.zig b/libpanto/src/file_system_jsonl_store.zig index bbf8ea5..c77d7d2 100644 --- a/libpanto/src/file_system_jsonl_store.zig +++ b/libpanto/src/file_system_jsonl_store.zig @@ -1351,6 +1351,10 @@ pub const FileSystemJSONLStore = struct { .base_url = pm.identity.base_url, .model = pm.identity.model, .reasoning = pm.identity.reasoning, + .thinking = pm.identity.thinking, + .effort = pm.identity.effort, + .thinking_budget_tokens = pm.identity.thinking_budget_tokens, + .thinking_interleaved = pm.identity.thinking_interleaved, }; built += 1; } diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig index d76793b..8e41695 100644 --- a/libpanto/src/provider_anthropic_messages.zig +++ b/libpanto/src/provider_anthropic_messages.zig @@ -75,15 +75,31 @@ pub const AnthropicMessagesRequest = struct { const body = try json_mod.serializeRequest(self.allocator, self.config, conv, tools); defer self.allocator.free(body); - const extra_headers = [_]http.Header{ + // Build headers. The four base headers are always present; the + // interleaved-thinking beta header is added only when the config + // explicitly requests manual extended thinking with interleaving. + // It is intentionally NOT sent for `.adaptive` (interleaving is + // automatic there and the header causes 400s on some backends) or + // `.disabled`. + var headers_buf: [5]http.Header = .{ .{ .name = "content-type", .value = "application/json" }, .{ .name = "accept", .value = "text/event-stream" }, .{ .name = "x-api-key", .value = self.config.api_key }, .{ .name = "anthropic-version", .value = self.config.api_version }, + undefined, // slot reserved for the optional beta header }; + const send_interleaved = self.config.thinking == .enabled and + self.config.thinking_interleaved; + if (send_interleaved) { + headers_buf[4] = .{ + .name = "anthropic-beta", + .value = "interleaved-thinking-2025-05-14", + }; + } + const extra_headers = headers_buf[0 .. if (send_interleaved) @as(usize, 5) else @as(usize, 4)]; rr.req = try self.http_client.request(.POST, uri, .{ - .extra_headers = &extra_headers, + .extra_headers = extra_headers, // Disable compression: gzip buffers small SSE frames, defeating // the streaming property we paid for `stream: true` to get. .headers = .{ .accept_encoding = .{ .override = "identity" } }, @@ -1095,3 +1111,56 @@ test "two streamed turns persist assistant replies in the conversation" { conv.messages.items[4].content.items[0].Text.items, ); } + +/// Helper: build the header slice exactly as `open` does, given a config, +/// and return whether the interleaved beta header is present. +/// This lets us test the header-selection logic without a live HTTP connection. +fn headerSliceIncludesInterleaved(cfg: *const config_mod.AnthropicMessagesConfig) bool { + const send_interleaved = cfg.thinking == .enabled and cfg.thinking_interleaved; + return send_interleaved; +} + +test "interleaved beta header: enabled when thinking=.enabled and interleaved=true" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .enabled, + .thinking_interleaved = true, + }; + try testing.expect(headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.enabled and interleaved=false" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .enabled, + .thinking_interleaved = false, + }; + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.adaptive even if interleaved=true" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .adaptive, + .thinking_interleaved = true, + }; + // .adaptive does not send the header; interleaving is automatic there. + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} + +test "interleaved beta header: absent when thinking=.disabled" { + const cfg: config_mod.AnthropicMessagesConfig = .{ + .api_key = "k", + .base_url = "u", + .model = "m", + .thinking = .disabled, + .thinking_interleaved = true, + }; + try testing.expect(!headerSliceIncludesInterleaved(&cfg)); +} diff --git a/libpanto/src/public.zig b/libpanto/src/public.zig index 5488220..f9c2830 100644 --- a/libpanto/src/public.zig +++ b/libpanto/src/public.zig @@ -64,6 +64,8 @@ pub const OpenAIChatConfig = config_mod.OpenAIChatConfig; pub const AnthropicMessagesConfig = config_mod.AnthropicMessagesConfig; pub const APIStyle = config_mod.APIStyle; pub const ReasoningEffort = config_mod.ReasoningEffort; +pub const Thinking = config_mod.Thinking; +pub const Effort = config_mod.Effort; pub const CompactionConfig = config_mod.CompactionConfig; pub const RetryConfig = config_mod.RetryConfig; pub const WireIdentity = config_mod.WireIdentity; diff --git a/libpanto/src/session.zig b/libpanto/src/session.zig index ca7046c..492f94b 100644 --- a/libpanto/src/session.zig +++ b/libpanto/src/session.zig @@ -27,6 +27,8 @@ const config = @import("config.zig"); pub const APIStyle = config.APIStyle; pub const ReasoningEffort = config.ReasoningEffort; +pub const Thinking = config.Thinking; +pub const Effort = config.Effort; /// Wire-format provider identity stamped on a message entry. This is the /// ground truth of which endpoint a turn was sent to — never a CLI config @@ -36,7 +38,17 @@ pub const WireStamp = struct { api_style: APIStyle, base_url: []const u8, // owned model: []const u8, // owned + /// OpenAI only. Defaults to `.default` (field omitted on the wire). reasoning: ReasoningEffort = .default, + /// Anthropic only. Defaults to `.enabled`. + thinking: Thinking = .enabled, + /// Anthropic only; only meaningful when `thinking == .adaptive`. + effort: Effort = .medium, + /// Anthropic only; only meaningful when `thinking == .enabled`. `null` + /// means "use the config default" (falls back to `max_tokens - 1`). + thinking_budget_tokens: ?u32 = 32_000, + /// Anthropic only; only meaningful when `thinking == .enabled`. + thinking_interleaved: bool = false, pub fn deinit(self: WireStamp, alloc: Allocator) void { alloc.free(self.base_url); @@ -52,6 +64,10 @@ pub const WireStamp = struct { .base_url = burl, .model = mdl, .reasoning = self.reasoning, + .thinking = self.thinking, + .effort = self.effort, + .thinking_budget_tokens = self.thinking_budget_tokens, + .thinking_interleaved = self.thinking_interleaved, }; } }; @@ -338,8 +354,35 @@ fn writeWireStamp(s: *std.json.Stringify, st: WireStamp) !void { try s.write(st.base_url); try s.objectField("model"); try s.write(st.model); - try s.objectField("reasoning"); - try s.write(@tagName(st.reasoning)); + // OpenAI: emit reasoning only when non-default (keeps logs compact). + if (st.reasoning != .default) { + try s.objectField("reasoning"); + try s.write(@tagName(st.reasoning)); + } + // Anthropic: emit thinking fields only when they differ from defaults. + if (st.thinking != .enabled) { + try s.objectField("thinking"); + try s.write(@tagName(st.thinking)); + } + if (st.effort != .medium) { + try s.objectField("effort"); + try s.write(@tagName(st.effort)); + } + if (st.thinking_budget_tokens) |b| { + if (b != 32_000) { + try s.objectField("thinkingBudgetTokens"); + try s.write(b); + } + } else { + // null means "use max_tokens - 1"; record the absence explicitly + // so round-trips preserve the null intent. + try s.objectField("thinkingBudgetTokens"); + try s.write(null); + } + if (st.thinking_interleaved) { + try s.objectField("thinkingInterleaved"); + try s.write(true); + } } fn writeDiskMessage(s: *std.json.Stringify, msg: StoredMessage) !void { @@ -572,12 +615,45 @@ fn parseWireStamp(allocator: Allocator, obj: std.json.ObjectMap) ParseError!?Wir errdefer allocator.free(base_url); const model = try dupeStringField(allocator, obj, "model"); errdefer allocator.free(model); + // OpenAI: absent reasoning defaults to .default. const reasoning: ReasoningEffort = blk: { const rv = obj.get("reasoning") orelse break :blk .default; if (rv != .string) break :blk .default; break :blk std.meta.stringToEnum(ReasoningEffort, rv.string) orelse .default; }; - return .{ .api_style = api_style, .base_url = base_url, .model = model, .reasoning = reasoning }; + // Anthropic: absent fields default to the same values as the config defaults. + const thinking: Thinking = blk: { + const tv = obj.get("thinking") orelse break :blk .enabled; + if (tv != .string) break :blk .enabled; + break :blk std.meta.stringToEnum(Thinking, tv.string) orelse .enabled; + }; + const effort: Effort = blk: { + const ev = obj.get("effort") orelse break :blk .medium; + if (ev != .string) break :blk .medium; + break :blk std.meta.stringToEnum(Effort, ev.string) orelse .medium; + }; + const thinking_budget_tokens: ?u32 = blk: { + const bv = obj.get("thinkingBudgetTokens") orelse break :blk 32_000; + if (bv == .null) break :blk null; + if (bv != .integer) break :blk 32_000; + if (bv.integer < 0) break :blk 32_000; + break :blk @intCast(bv.integer); + }; + const thinking_interleaved: bool = blk: { + const iv = obj.get("thinkingInterleaved") orelse break :blk false; + if (iv != .bool) break :blk false; + break :blk iv.bool; + }; + return .{ + .api_style = api_style, + .base_url = base_url, + .model = model, + .reasoning = reasoning, + .thinking = thinking, + .effort = effort, + .thinking_budget_tokens = thinking_budget_tokens, + .thinking_interleaved = thinking_interleaved, + }; } fn parseDiskMessage(allocator: Allocator, obj: std.json.ObjectMap) ParseError!StoredMessage { @@ -1366,3 +1442,148 @@ test "compactionSummary bridges in-memory <-> disk both directions" { defer inmem.deinit(a); try testing.expectEqualStrings("S1", inmem.CompactionSummary.text.items); } + +test "WireStamp: Anthropic non-default thinking fields round-trip" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "aa000001"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .anthropic_messages, + .base_url = try dupe(a, "https://api.anthropic.com"), + .model = try dupe(a, "claude-opus-4-8"), + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = true, + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Non-default fields must appear in the serialized line. + try testing.expect(std.mem.indexOf(u8, line, "\"thinking\":\"adaptive\"") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"effort\":\"high\"") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"thinkingBudgetTokens\":null") != null); + try testing.expect(std.mem.indexOf(u8, line, "\"thinkingInterleaved\":true") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.anthropic_messages, got.api_style); + try testing.expectEqual(Thinking.adaptive, got.thinking); + try testing.expectEqual(Effort.high, got.effort); + try testing.expectEqual(@as(?u32, null), got.thinking_budget_tokens); + try testing.expectEqual(true, got.thinking_interleaved); + // reasoning carries its default (unused for Anthropic) + try testing.expectEqual(ReasoningEffort.default, got.reasoning); +} + +test "WireStamp: Anthropic stamp with all-default thinking fields omits non-essential keys" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "bb000002"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .anthropic_messages, + .base_url = try dupe(a, "https://api.anthropic.com"), + .model = try dupe(a, "claude-haiku-4-5"), + // All defaults: thinking=.enabled, effort=.medium, + // thinking_budget_tokens=32_000, thinking_interleaved=false + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Default-valued fields should be omitted (keeps logs compact). + try testing.expect(std.mem.indexOf(u8, line, "thinking") == null); + try testing.expect(std.mem.indexOf(u8, line, "effort") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null); + // thinkingBudgetTokens=32_000 is the default, should be omitted too. + try testing.expect(std.mem.indexOf(u8, line, "thinkingBudgetTokens") == null); + + // Round-trip: all defaults parse back correctly. + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(Thinking.enabled, got.thinking); + try testing.expectEqual(Effort.medium, got.effort); + try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens); + try testing.expectEqual(false, got.thinking_interleaved); +} + +test "WireStamp: legacy Anthropic stamp (no thinking fields) parses with defaults" { + // Simulate a session log written before thinking fields were added. + const a = testing.allocator; + const line = + \\{"type":"message","id":"cc000003","parentId":null,"timestamp":"2026-06-01T00:00:00Z","apiStyle":"anthropic_messages","baseUrl":"https://api.anthropic.com","model":"claude-3-7-sonnet","message":{"role":"user","content":[{"type":"text","text":"hi"}]}} + ; + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.anthropic_messages, got.api_style); + try testing.expectEqual(Thinking.enabled, got.thinking); + try testing.expectEqual(Effort.medium, got.effort); + try testing.expectEqual(@as(?u32, 32_000), got.thinking_budget_tokens); + try testing.expectEqual(false, got.thinking_interleaved); +} + +test "WireStamp: OpenAI stamp is unchanged by Anthropic fields" { + const a = testing.allocator; + + var content = try a.alloc(StoredContentBlock, 1); + content[0] = .{ .text = .{ .text = try dupe(a, "hi") } }; + + const entry: SessionEntry = .{ .message = .{ + .base = .{ + .id = try dupe(a, "dd000004"), + .parent_id = null, + .timestamp = try dupe(a, "2026-06-01T00:00:00Z"), + }, + .stamp = .{ + .api_style = .openai_chat, + .base_url = try dupe(a, "https://api.openai.com/v1"), + .model = try dupe(a, "gpt-4o"), + .reasoning = .high, + }, + .message = .{ .role = .user, .content = content }, + } }; + defer entry.deinit(a); + + const line = try serializeEntry(a, entry); + defer a.free(line); + + // Anthropic fields should not appear for an OpenAI stamp. + try testing.expect(std.mem.indexOf(u8, line, "thinking") == null); + try testing.expect(std.mem.indexOf(u8, line, "effort") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingBudget") == null); + try testing.expect(std.mem.indexOf(u8, line, "thinkingInterleaved") == null); + // reasoning=high should be present + try testing.expect(std.mem.indexOf(u8, line, "\"reasoning\":\"high\"") != null); + + var fe = try parseLine(a, line); + defer fe.deinit(a); + const got = fe.entry.message.stamp.?; + try testing.expectEqual(APIStyle.openai_chat, got.api_style); + try testing.expectEqual(ReasoningEffort.high, got.reasoning); +} diff --git a/src/config_file.zig b/src/config_file.zig index 229a5b2..e5bbe99 100644 --- a/src/config_file.zig +++ b/src/config_file.zig @@ -146,6 +146,10 @@ pub fn buildProviderConfig( .model = wire_model, .api_version = if (def_opt) |d| (d.api_version orelse "2023-06-01") else "2023-06-01", .max_tokens = max_tokens, + .thinking = if (def_opt) |d| d.thinking else .enabled, + .effort = if (def_opt) |d| d.effort else .medium, + .thinking_budget_tokens = if (def_opt) |d| d.thinking_budget_tokens else 32_000, + .thinking_interleaved = if (def_opt) |d| d.thinking_interleaved else false, } }, } } @@ -911,6 +915,10 @@ test "buildProviderConfig: anthropic ref pulls knobs from model defs" { .reasoning = .high, .max_tokens = 8192, .api_version = try a.dupe(u8, "2023-06-01"), + .thinking = .enabled, + .effort = .medium, + .thinking_budget_tokens = 16_000, + .thinking_interleaved = false, }); const ref = try parseModelRef("anthropic:sonnet"); @@ -919,6 +927,10 @@ test "buildProviderConfig: anthropic ref pulls knobs from model defs" { try testing.expectEqualStrings("claude-sonnet-4-20250514", pc.anthropic_messages.model); try testing.expectEqual(@as(u32, 8192), pc.anthropic_messages.max_tokens); try testing.expectEqualStrings("sk-ant", pc.anthropic_messages.api_key); + try testing.expectEqual(panto.Thinking.enabled, pc.anthropic_messages.thinking); + try testing.expectEqual(panto.Effort.medium, pc.anthropic_messages.effort); + try testing.expectEqual(@as(?u32, 16_000), pc.anthropic_messages.thinking_budget_tokens); + try testing.expectEqual(false, pc.anthropic_messages.thinking_interleaved); } test "buildProviderConfig: missing alias uses the alias verbatim as wire model" { @@ -947,6 +959,75 @@ test "buildProviderConfig: missing alias uses the alias verbatim as wire model" try testing.expectEqual(panto.ReasoningEffort.default, pc.openai_chat.reasoning); } +test "buildProviderConfig: anthropic adaptive thinking threaded from model def" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + try models.entries.append(a, .{ + .provider = try a.dupe(u8, "anthropic"), + .alias = try a.dupe(u8, "opus"), + .model = try a.dupe(u8, "claude-opus-4-8"), + .reasoning = .default, + .max_tokens = null, + .api_version = null, + .thinking = .adaptive, + .effort = .high, + .thinking_budget_tokens = null, + .thinking_interleaved = false, + }); + + const ref = try parseModelRef("anthropic:opus"); + const pc = try buildProviderConfig(&cfg, &models, ref); + try testing.expectEqual(panto.Thinking.adaptive, pc.anthropic_messages.thinking); + try testing.expectEqual(panto.Effort.high, pc.anthropic_messages.effort); + try testing.expectEqual(@as(?u32, null), pc.anthropic_messages.thinking_budget_tokens); +} + +test "buildProviderConfig: anthropic missing alias falls back to struct defaults" { + const a = testing.allocator; + var env = emptyEnv(a); + defer env.deinit(); + try env.put("ANTHROPIC_API_KEY", "sk-ant"); + + const src = + \\[providers.anthropic] + \\style = "anthropic_messages" + \\base_url = "https://api.anthropic.com" + \\api_key_env_var = "ANTHROPIC_API_KEY" + ; + const doc = try parseDoc(a, src); + defer doc.deinit(); + var cfg = try resolve(a, &env, doc.root); + defer cfg.deinit(); + + var models = models_toml.ModelRegistry.init(a); + defer models.deinit(); + + // No model def at all — alias used verbatim, defaults applied. + const ref = try parseModelRef("anthropic:claude-haiku-4-5-20251001"); + const pc = try buildProviderConfig(&cfg, &models, ref); + try testing.expectEqual(APIStyle.anthropic_messages, pc.style()); + try testing.expectEqual(panto.Thinking.enabled, pc.anthropic_messages.thinking); + try testing.expectEqual(panto.Effort.medium, pc.anthropic_messages.effort); + try testing.expectEqual(@as(?u32, 32_000), pc.anthropic_messages.thinking_budget_tokens); + try testing.expectEqual(false, pc.anthropic_messages.thinking_interleaved); +} + test "buildProviderConfig: unknown provider errors" { const a = testing.allocator; var env = emptyEnv(a); diff --git a/src/models_toml.zig b/src/models_toml.zig index 2e2a920..9d64cb9 100644 --- a/src/models_toml.zig +++ b/src/models_toml.zig @@ -9,15 +9,27 @@ //! //! [.] //! model = # wire model id; defaults to if omitted -//! reasoning = # default | off | minimal | low | medium | high //! max_tokens = # per-request output token cap -//! api_version = # anthropic_messages only //! # pricing (all optional; USD per million tokens): //! input = //! output = //! cache_read = //! cache_write = //! +//! # openai_chat-only knobs: +//! reasoning = # default | off | minimal | low | medium | high +//! +//! # anthropic_messages-only knobs: +//! api_version = # Anthropic-Version header; default "2023-06-01" +//! thinking = # disabled | enabled | adaptive (default: enabled) +//! effort = # low | medium | high | xhigh | max (default: medium) +//! # only used when thinking = "adaptive" +//! thinking_budget_tokens = # max reasoning tokens for thinking = "enabled" +//! # default: 32000; null falls back to max_tokens - 1 +//! # ignored when thinking = "adaptive" or "disabled" +//! thinking_interleaved = # send interleaved-thinking beta header (default: false) +//! # only honoured when thinking = "enabled" +//! //! Pricing semantics are unchanged from the original models.toml: any //! omitted price field is "unknown" (null), not zero. Write `= 0` //! explicitly to declare a known-zero rate. @@ -25,13 +37,19 @@ //! Example: //! //! [anthropic.sonnet] -//! model = "claude-sonnet-4-20250514" -//! reasoning = "high" -//! max_tokens = 8192 -//! input = 3.0 -//! output = 15.0 -//! cache_read = 0.3 -//! cache_write = 3.75 +//! model = "claude-sonnet-4-20250514" +//! max_tokens = 8192 +//! thinking = "enabled" +//! thinking_budget_tokens = 16000 +//! input = 3.0 +//! output = 15.0 +//! cache_read = 0.3 +//! cache_write = 3.75 +//! +//! [anthropic.opus] +//! model = "claude-opus-4-8" +//! thinking = "adaptive" +//! effort = "high" //! //! [openai.gpt] //! model = "gpt-4o" @@ -55,6 +73,9 @@ pub const ReasoningEffort = panto.ReasoningEffort; /// A resolved model definition: the wire model id plus per-model knobs. /// Strings are owned by the `ModelRegistry`. +pub const Thinking = panto.Thinking; +pub const Effort = panto.Effort; + pub const ModelDef = struct { /// `` — matches a `[providers.]` in config.toml. provider: []const u8, @@ -68,6 +89,14 @@ pub const ModelDef = struct { max_tokens: ?u32, /// anthropic_messages only; null = use the library default. api_version: ?[]const u8, + /// anthropic_messages only. Extended-thinking mode. + thinking: Thinking, + /// anthropic_messages only. Effort level for adaptive thinking. + effort: Effort, + /// anthropic_messages only. Manual thinking token budget; null = max_tokens - 1. + thinking_budget_tokens: ?u32, + /// anthropic_messages only. Whether to send the interleaved-thinking beta header. + thinking_interleaved: bool, }; /// In-memory map of `(provider, alias) -> ModelDef`. Owns all strings. @@ -239,6 +268,40 @@ fn ingestModel( break :blk null; }; + const thinking: Thinking = blk: { + if (v.get("thinking")) |t| { + if (t.asString()) |s| { + break :blk std.meta.stringToEnum(Thinking, s) orelse .enabled; + } + } + break :blk .enabled; + }; + + const effort: Effort = blk: { + if (v.get("effort")) |e| { + if (e.asString()) |s| { + break :blk std.meta.stringToEnum(Effort, s) orelse .medium; + } + } + break :blk .medium; + }; + + const thinking_budget_tokens: ?u32 = blk: { + if (v.get("thinking_budget_tokens")) |bt| { + if (bt.asI64()) |i| { + if (i > 0) break :blk @intCast(i); + } + } + break :blk null; + }; + + const thinking_interleaved: bool = blk: { + if (v.get("thinking_interleaved")) |ti| { + if (ti.asBool()) |b| break :blk b; + } + break :blk false; + }; + // Own all the strings. const provider_copy = try alloc.dupe(u8, provider); errdefer alloc.free(provider_copy); @@ -256,6 +319,10 @@ fn ingestModel( .reasoning = reasoning, .max_tokens = max_tokens, .api_version = api_version_copy, + .thinking = thinking, + .effort = effort, + .thinking_budget_tokens = thinking_budget_tokens, + .thinking_interleaved = thinking_interleaved, }); // Pricing keys on the *wire* model id so usage records (which carry @@ -420,3 +487,94 @@ test "configPath: falls back to HOME/.config" { defer a.free(got); try testing.expectEqualStrings("/home/user/.config/panto/models.toml", got); } + +test "parseInto: anthropic thinking fields parse correctly" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.sonnet] + \\model = "claude-sonnet-4-6" + \\thinking = "enabled" + \\thinking_budget_tokens = 16000 + \\thinking_interleaved = true + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "sonnet").?; + try testing.expectEqual(Thinking.enabled, def.thinking); + try testing.expectEqual(Effort.medium, def.effort); // default + try testing.expectEqual(@as(?u32, 16000), def.thinking_budget_tokens); + try testing.expectEqual(true, def.thinking_interleaved); +} + +test "parseInto: anthropic adaptive thinking with effort" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.opus] + \\model = "claude-opus-4-8" + \\thinking = "adaptive" + \\effort = "high" + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "opus").?; + try testing.expectEqual(Thinking.adaptive, def.thinking); + try testing.expectEqual(Effort.high, def.effort); + try testing.expectEqual(@as(?u32, null), def.thinking_budget_tokens); // absent => null + try testing.expectEqual(false, def.thinking_interleaved); // default +} + +test "parseInto: anthropic thinking disabled" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.haiku] + \\model = "claude-haiku-4-5-20251001" + \\thinking = "disabled" + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "haiku").?; + try testing.expectEqual(Thinking.disabled, def.thinking); +} + +test "parseInto: anthropic thinking defaults when absent" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.base] + \\model = "claude-sonnet-4-20250514" + ; + try parseInto(&models, src); + + const def = models.defs.get("anthropic", "base").?; + try testing.expectEqual(Thinking.enabled, def.thinking); + try testing.expectEqual(Effort.medium, def.effort); + try testing.expectEqual(@as(?u32, null), def.thinking_budget_tokens); + try testing.expectEqual(false, def.thinking_interleaved); +} + +test "parseInto: effort xhigh and max parse" { + var models = emptyModels(testing.allocator); + defer models.deinit(); + + const src = + \\[anthropic.opus-xhigh] + \\model = "claude-opus-4-7" + \\thinking = "adaptive" + \\effort = "xhigh" + \\[anthropic.opus-max] + \\model = "claude-opus-4-6" + \\thinking = "adaptive" + \\effort = "max" + ; + try parseInto(&models, src); + + try testing.expectEqual(Effort.xhigh, models.defs.get("anthropic", "opus-xhigh").?.effort); + try testing.expectEqual(Effort.max, models.defs.get("anthropic", "opus-max").?.effort); +} -- cgit v1.3