summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config_file.zig81
-rw-r--r--src/models_toml.zig176
2 files changed, 248 insertions, 9 deletions
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);
+}