summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-09 11:56:15 -0600
committert <t@tjp.lol>2026-06-09 11:56:35 -0600
commita7fe265365ba5e8bfbd0e65c827d38c038be7b0f (patch)
tree6c7bc4a5ef2f1ba6167b498aa03d17c5b7afb609
parent1cef19b12d59147ec32e4c0cd6a4828384b16417 (diff)
anthropic thinking
-rw-r--r--docs/archive/anthropic-thinking-plan.md96
-rw-r--r--libpanto/src/anthropic_messages_json.zig192
-rw-r--r--libpanto/src/config.zig84
-rw-r--r--libpanto/src/file_system_jsonl_store.zig4
-rw-r--r--libpanto/src/provider_anthropic_messages.zig73
-rw-r--r--libpanto/src/public.zig2
-rw-r--r--libpanto/src/session.zig227
-rw-r--r--src/config_file.zig81
-rw-r--r--src/models_toml.zig176
9 files changed, 918 insertions, 17 deletions
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: "<level>"`
+- 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 @@
//!
//! [<provider>.<alias>]
//! model = <string> # wire model id; defaults to <alias> if omitted
-//! reasoning = <string> # default | off | minimal | low | medium | high
//! max_tokens = <int> # per-request output token cap
-//! api_version = <string> # anthropic_messages only
//! # pricing (all optional; USD per million tokens):
//! input = <float>
//! output = <float>
//! cache_read = <float>
//! cache_write = <float>
//!
+//! # openai_chat-only knobs:
+//! reasoning = <string> # default | off | minimal | low | medium | high
+//!
+//! # anthropic_messages-only knobs:
+//! api_version = <string> # Anthropic-Version header; default "2023-06-01"
+//! thinking = <string> # disabled | enabled | adaptive (default: enabled)
+//! effort = <string> # low | medium | high | xhigh | max (default: medium)
+//! # only used when thinking = "adaptive"
+//! thinking_budget_tokens = <int> # max reasoning tokens for thinking = "enabled"
+//! # default: 32000; null falls back to max_tokens - 1
+//! # ignored when thinking = "adaptive" or "disabled"
+//! thinking_interleaved = <bool> # 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 {
/// `<provider_name>` — matches a `[providers.<name>]` 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);
+}