diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/config_file.zig | 81 | ||||
| -rw-r--r-- | src/models_toml.zig | 176 |
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); +} |
