summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-06-10 16:46:30 -0600
committert <t@tjp.lol>2026-06-10 16:46:48 -0600
commitb435cf06b78853bec87bf238f526bc60679d7712 (patch)
tree0c4191f24730f9a0336accb701d627450483c9cf
parentcee08918509689adbdbd26b6384d5ef3a47e91a2 (diff)
fix anthropic thinking effort json placement
-rw-r--r--libpanto/src/anthropic_messages_json.zig12
-rw-r--r--libpanto/src/config.zig12
-rw-r--r--libpanto/src/provider_anthropic_messages.zig6
-rw-r--r--libpanto/src/provider_openai_chat.zig6
-rw-r--r--src/config_file.zig4
-rw-r--r--src/models_toml.zig8
6 files changed, 34 insertions, 14 deletions
diff --git a/libpanto/src/anthropic_messages_json.zig b/libpanto/src/anthropic_messages_json.zig
index 3ad6069..99d89eb 100644
--- a/libpanto/src/anthropic_messages_json.zig
+++ b/libpanto/src/anthropic_messages_json.zig
@@ -55,7 +55,7 @@ pub fn serializeRequest(
// Extended thinking configuration.
// `.disabled` — omit the field entirely.
// `.enabled` — manual budget: { type, budget_tokens, display }.
- // `.adaptive` — adaptive mode: { type, display } + top-level effort.
+ // `.adaptive` — adaptive mode: { type, display } + output_config.effort.
switch (cfg.thinking) {
.disabled => {},
.enabled => {
@@ -82,8 +82,11 @@ pub fn serializeRequest(
try s.objectField("display");
try s.write("summarized");
try s.endObject();
+ try s.objectField("output_config");
+ try s.beginObject();
try s.objectField("effort");
try s.write(@tagName(cfg.effort));
+ try s.endObject();
},
}
@@ -1285,7 +1288,9 @@ test "serializeRequest - thinking adaptive default effort" {
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);
+ try testing.expect(root.get("effort") == null);
+ const oc = root.get("output_config").?.object;
+ try testing.expectEqualStrings("medium", oc.get("effort").?.string);
}
test "serializeRequest - thinking adaptive explicit effort levels" {
@@ -1312,7 +1317,8 @@ test "serializeRequest - thinking adaptive explicit effort levels" {
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);
+ const oc = parsed.value.object.get("output_config").?.object;
+ try testing.expectEqualStrings(entry.name, oc.get("effort").?.string);
}
}
diff --git a/libpanto/src/config.zig b/libpanto/src/config.zig
index 587f00a..4aae81f 100644
--- a/libpanto/src/config.zig
+++ b/libpanto/src/config.zig
@@ -46,7 +46,8 @@ 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.
+ /// Supported on Haiku 4.5 and older models, but NOT on Opus 4.8+ (which
+ /// only accepts adaptive). Not safe as a cross-model 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+.
@@ -82,7 +83,12 @@ pub const AnthropicMessagesConfig = struct {
/// 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,
+ ///
+ /// Defaults to `.disabled`: it is the only mode accepted by every current
+ /// model. `.enabled` fails on Opus 4.8+ (adaptive-only) and `.adaptive`
+ /// fails on Haiku 4.5 (no adaptive support), so neither is a safe default
+ /// without parsing model strings.
+ thinking: Thinking = .disabled,
/// Effort level for adaptive thinking. Only emitted on the wire when
/// `thinking == .adaptive`; ignored otherwise.
effort: Effort = .medium,
@@ -274,7 +280,7 @@ 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(Thinking.disabled, 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);
diff --git a/libpanto/src/provider_anthropic_messages.zig b/libpanto/src/provider_anthropic_messages.zig
index 37ce818..9740c0e 100644
--- a/libpanto/src/provider_anthropic_messages.zig
+++ b/libpanto/src/provider_anthropic_messages.zig
@@ -124,6 +124,10 @@ pub const AnthropicMessagesRequest = struct {
rr.response = try rr.req.receiveHead(&redirect_buf);
if (@intFromEnum(rr.response.head.status) >= 400) {
+ // `head.bytes` (which `iterateHeaders` walks) points into the
+ // connection read buffer and is invalidated the moment the body
+ // stream is initialized below. Capture Retry-After first.
+ const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
const body_reader = rr.response.reader(&rr.transfer_buf);
var err_buf: std.ArrayList(u8) = .empty;
defer err_buf.deinit(self.allocator);
@@ -143,7 +147,7 @@ pub const AnthropicMessagesRequest = struct {
const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
if (self.diag) |d| {
d.status_code = status;
- d.retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
+ d.retry_after_ms = retry_after_ms;
}
return classified;
}
diff --git a/libpanto/src/provider_openai_chat.zig b/libpanto/src/provider_openai_chat.zig
index 8319d9d..5152d3e 100644
--- a/libpanto/src/provider_openai_chat.zig
+++ b/libpanto/src/provider_openai_chat.zig
@@ -140,6 +140,10 @@ pub const OpenAIChatRequest = struct {
rr.response = try rr.req.receiveHead(&redirect_buf);
if (@intFromEnum(rr.response.head.status) >= 400) {
+ // `head.bytes` (which `iterateHeaders` walks) points into the
+ // connection read buffer and is invalidated the moment the body
+ // stream is initialized below. Capture Retry-After first.
+ const retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
// Drain body for diagnostics.
const body_reader = rr.response.reader(&rr.transfer_buf);
var err_buf: std.ArrayList(u8) = .empty;
@@ -159,7 +163,7 @@ pub const OpenAIChatRequest = struct {
const classified = provider_mod.classifyHttpStatus(status, err_buf.items);
if (self.diag) |d| {
d.status_code = status;
- d.retry_after_ms = provider_mod.retryAfterFromHead(rr.response.head);
+ d.retry_after_ms = retry_after_ms;
}
return classified;
}
diff --git a/src/config_file.zig b/src/config_file.zig
index e5bbe99..f5ed79c 100644
--- a/src/config_file.zig
+++ b/src/config_file.zig
@@ -146,7 +146,7 @@ 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,
+ .thinking = if (def_opt) |d| d.thinking else .disabled,
.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,
@@ -1022,7 +1022,7 @@ test "buildProviderConfig: anthropic missing alias falls back to struct defaults
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.Thinking.disabled, 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);
diff --git a/src/models_toml.zig b/src/models_toml.zig
index 9d64cb9..955640d 100644
--- a/src/models_toml.zig
+++ b/src/models_toml.zig
@@ -21,7 +21,7 @@
//!
//! # anthropic_messages-only knobs:
//! api_version = <string> # Anthropic-Version header; default "2023-06-01"
-//! thinking = <string> # disabled | enabled | adaptive (default: enabled)
+//! thinking = <string> # disabled | enabled | adaptive (default: disabled)
//! effort = <string> # low | medium | high | xhigh | max (default: medium)
//! # only used when thinking = "adaptive"
//! thinking_budget_tokens = <int> # max reasoning tokens for thinking = "enabled"
@@ -271,10 +271,10 @@ fn ingestModel(
const thinking: Thinking = blk: {
if (v.get("thinking")) |t| {
if (t.asString()) |s| {
- break :blk std.meta.stringToEnum(Thinking, s) orelse .enabled;
+ break :blk std.meta.stringToEnum(Thinking, s) orelse .disabled;
}
}
- break :blk .enabled;
+ break :blk .disabled;
};
const effort: Effort = blk: {
@@ -553,7 +553,7 @@ test "parseInto: anthropic thinking defaults when absent" {
try parseInto(&models, src);
const def = models.defs.get("anthropic", "base").?;
- try testing.expectEqual(Thinking.enabled, def.thinking);
+ try testing.expectEqual(Thinking.disabled, 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);