summaryrefslogtreecommitdiff
path: root/src/config.zig
blob: f2f04e791eecf369a66adec43eed7bf84e50756d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Active configuration the agent consults on every turn.
//!
//! `ProviderConfig` is a tagged union keyed by `APIStyle`; each variant
//! carries the settings specific to one wire dialect. New providers add a
//! new tag and a new payload struct here; nothing else in libpanto needs a
//! central enum refresh.
//!
//! `Config` carries the active `ProviderConfig` (plus retry/compaction
//! policy). It is an **immutable snapshot**: the agent holds a `*const
//! Config` and re-reads it at the top of every turn, so swapping that
//! pointer (e.g. from a `panto.configure` hook) changes provider, model,
//! and base_url atomically at the next turn boundary. Because the snapshot
//! is read-only while a turn is in flight, concurrent tool workers reading
//! the old snapshot stay consistent.
//!
//! The tool set is **not** part of `Config` — it lives on the `Agent`
//! (`Agent.registerTool`/`registerToolSource`), so swapping provider/model
//! between turns no longer means rebuilding the tool list.

const std = @import("std");
const Io = std.Io;

/// The wire dialect a provider speaks.
pub const APIStyle = enum {
    openai_chat,
    anthropic_messages,
    /// OpenAI Responses API (`/responses`). Used by the ChatGPT-subscription
    /// Codex backend, whose request/stream shape differs from Chat Completions.
    openai_responses,
    /// ChatGPT-subscription Codex Responses dialect. User config selects this
    /// with `style = "openai_responses"` plus `dialect = "codex"`.
    openai_codex_responses,
};

/// A single HTTP request header (name/value). Used for provider
/// `extra_headers` — caller-supplied headers merged onto a provider's
/// built-in request headers (e.g. GitHub Copilot's editor-identity headers,
/// or auth-derived headers from an OAuth exchange). Borrowed slices; valid
/// as long as the owning config is.
pub const Header = struct {
    name: []const u8,
    value: []const u8,
};

/// Reasoning intensity hint sent to providers that support it.
///
/// `.default` omits the field entirely so the provider's own default applies.
/// `.off` sends `"none"` (supported by OpenRouter/NanoGPT; ignored or rejected
/// elsewhere). The remaining values mirror the `reasoning_effort` parameter
/// accepted by OpenAI reasoning models and OpenAI-compatible proxies.
pub const ReasoningEffort = enum {
    default,
    off,
    minimal,
    low,
    medium,
    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 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+.
    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,
    model: []const u8,
    reasoning: ReasoningEffort = .default,
    max_tokens: u32 = 64_000,
    /// Caller-supplied request headers merged onto the built-in ones
    /// (content-type/accept/authorization). Used for provider identity
    /// headers and auth-exchange-derived headers. Empty by default.
    /// Borrowed; valid as long as this config is.
    extra_headers: []const Header = &.{},
};

pub const AnthropicMessagesConfig = struct {
    api_key: []const u8,
    base_url: []const u8,
    model: []const u8,
    /// Value sent in the `anthropic-version` header.
    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.
    ///
    /// 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,
    /// 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,
    /// Use `Authorization: Bearer ...` instead of `x-api-key`. This is set
    /// by the embedder from the configured auth family (not guessed from the
    /// base URL): standard Anthropic uses `false`, while OAuth-backed
    /// Anthropic-compatible providers such as Copilot set `true`.
    use_bearer_auth: bool = false,
    /// Place one `cache_control` breakpoint on the last cacheable block of
    /// each request, replicating Anthropic's "automatic caching" (a single
    /// advancing breakpoint) via the broadly-supported per-block marker
    /// rather than the top-level field.
    ///
    /// Defaults on: for a normal append-only multi-turn session it's a
    /// near-pure win (reads 0.1x base input vs. a one-time 1.25x write).
    /// Set false when the workload makes that write premium unrecoverable
    /// — e.g. one-shot requests, or an embedder that aggressively rewrites
    /// history so prefixes are never reused. There the 1.25x write is pure
    /// overhead with no read to amortize it.
    prompt_cache: bool = true,
    /// Caller-supplied request headers merged onto the built-in ones. See
    /// `OpenAIChatConfig.extra_headers`. Empty by default.
    extra_headers: []const Header = &.{},
};

/// OpenAI Responses API config. Same transport knobs as `openai_chat`, but a
/// distinct request body (`input` items, `reasoning`, `store`, `include`) and
/// streaming event shape. Auth-derived headers (`chatgpt-account-id`) and the
/// static Codex identity headers (`OpenAI-Beta`, `originator`) ride on
/// `extra_headers`.
pub const OpenAIResponsesConfig = struct {
    api_key: []const u8,
    base_url: []const u8,
    model: []const u8,
    reasoning: ReasoningEffort = .default,
    max_tokens: u32 = 64_000,
    extra_headers: []const Header = &.{},
};

/// Per-provider transport/auth/model configuration. Tagged by `APIStyle`.
pub const ProviderConfig = union(APIStyle) {
    openai_chat: OpenAIChatConfig,
    anthropic_messages: AnthropicMessagesConfig,
    openai_responses: OpenAIResponsesConfig,
    openai_codex_responses: OpenAIResponsesConfig,

    pub fn style(self: ProviderConfig) APIStyle {
        return @as(APIStyle, self);
    }

    /// The wire-format provider identity for this config: the ground-truth
    /// `{api_style, base_url, model, reasoning}` that a turn is sent with.
    /// 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| .{
                .api_style = .openai_chat,
                .base_url = c.base_url,
                .model = c.model,
                .reasoning = c.reasoning,
            },
            .anthropic_messages => |c| .{
                .api_style = .anthropic_messages,
                .base_url = c.base_url,
                .model = c.model,
                .thinking = c.thinking,
                .effort = c.effort,
                .thinking_budget_tokens = c.thinking_budget_tokens,
                .thinking_interleaved = c.thinking_interleaved,
            },
            .openai_responses => |c| .{
                .api_style = .openai_responses,
                .base_url = c.base_url,
                .model = c.model,
                .reasoning = c.reasoning,
            },
            .openai_codex_responses => |c| .{
                .api_style = .openai_codex_responses,
                .base_url = c.base_url,
                .model = c.model,
                .reasoning = c.reasoning,
            },
        };
    }
};

/// 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.
///
/// `keep_verbatim` is the budget (in tokens) governing how much recent
/// conversation is kept verbatim after compaction. The retention walk
/// accumulates whole turns backward and stops once the running total
/// exceeds this value, so `keep_verbatim` is an *upper bound* on the size
/// of the kept suffix (the turn that crosses the threshold is summarized,
/// not kept).
///
/// `model`, when set, is the provider/model used to run the compaction
/// request itself. On failure (e.g. the compaction model rejects the
/// transcript for context length) the agent falls back to the active chat
/// model. When null, compaction uses the active chat model directly.
pub const CompactionConfig = struct {
    keep_verbatim: u32 = 20_000,
    model: ?ProviderConfig = null,
    /// The compaction system prompt. Used both for automatic compaction on
    /// context overflow and as the default for an explicit `Agent.compact`
    /// call (which may override it per-call). Borrowed; set by the embedder
    /// (e.g. resolved from its `COMPACTION.md` layers, or a built-in
    /// default). When null, auto-compaction is disabled and a
    /// context-overflow error propagates unchanged, and an explicit
    /// `compact` with no override is a no-op error.
    compaction_prompt: ?[]const u8 = null,
};

/// Policy for retrying transient provider/API failures. Conservative
/// defaults: four attempts (one initial + three retries) with exponential
/// backoff and jitter, capped at 10s per delay.
pub const RetryConfig = struct {
    /// Total attempts including the first. `4` => initial try + up to 3
    /// retries. Must be >= 1.
    max_attempts: usize = 4,
    /// Base delay before the first retry, in milliseconds.
    initial_delay_ms: u64 = 500,
    /// Upper bound on any single backoff delay, in milliseconds. Also caps
    /// a provider-supplied `Retry-After`.
    max_delay_ms: u64 = 10_000,
    /// Exponential growth factor applied per retry.
    multiplier: f64 = 2.0,
    /// When true, apply random jitter in `[0, computed_delay)` (full
    /// jitter) to avoid thundering-herd retries.
    jitter: bool = true,
};

/// An immutable snapshot of the provider/model the agent talks to, plus
/// retry and compaction policy. The agent holds a `*const Config` and
/// re-reads it each turn; replacing the pointer swaps the active
/// configuration at the next turn boundary. The tool set is owned by the
/// `Agent`, not the snapshot, so a swap never touches tools.
pub const Config = struct {
    provider: ProviderConfig,
    compaction: CompactionConfig = .{},
    retry: RetryConfig = .{},

    pub fn style(self: Config) APIStyle {
        return self.provider.style();
    }
};

// ===========================================================================
// Process-global HTTP client
// ===========================================================================
//
// `std.http.Client`'s connection pool is mutex-guarded and keyed by host,
// so a single client safely multiplexes every provider/base_url the agent
// ever switches to, across concurrent turns. We keep exactly one for the
// whole process: switching `base_url` simply leaves the old host's idle
// connections to time out (and reuses them if the user switches back).
//
// Embedders must call `initHttp` once before any turn and `deinitHttp`
// once at shutdown.

var global_http: ?std.http.Client = null;

/// Initialize the process-global HTTP client. Call once from the embedder's
/// `main()` before driving any agent turns. Idempotent: a second call with
/// an already-initialized client is a no-op.
pub fn initHttp(allocator: std.mem.Allocator, io: Io) void {
    if (global_http != null) return;
    global_http = .{ .allocator = allocator, .io = io };
}

/// Tear down the process-global HTTP client. Call once at shutdown, after
/// all turns have completed.
pub fn deinitHttp() void {
    if (global_http) |*c| {
        c.deinit();
        global_http = null;
    }
}

/// Borrow the process-global HTTP client. Asserts `initHttp` has run.
pub fn httpClient() *std.http.Client {
    return &(global_http orelse @panic("libpanto: httpClient() called before initHttp()"));
}

const t = std.testing;

test "ProviderConfig - openai_chat variant" {
    const cfg: ProviderConfig = .{ .openai_chat = .{
        .api_key = "sk-test",
        .base_url = "https://api.openai.com/v1",
        .model = "gpt-4o",
        .reasoning = .high,
    } };
    try t.expectEqual(APIStyle.openai_chat, cfg.style());
    try t.expectEqualStrings("sk-test", cfg.openai_chat.api_key);
    try t.expectEqual(ReasoningEffort.high, cfg.openai_chat.reasoning);
}

test "ProviderConfig - anthropic_messages variant" {
    const cfg: ProviderConfig = .{ .anthropic_messages = .{
        .api_key = "sk-ant-test",
        .base_url = "https://api.anthropic.com",
        .model = "claude-sonnet-4-20250514",
    } };
    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(false, cfg.anthropic_messages.use_bearer_auth);
    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);
}

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" {
    const cfg: ProviderConfig = .{ .openai_chat = .{
        .api_key = "k",
        .base_url = "u",
        .model = "m",
    } };
    try t.expectEqual(ReasoningEffort.default, cfg.openai_chat.reasoning);
}

test "Config carries provider and forwards style" {
    const cfg: Config = .{
        .provider = .{ .openai_chat = .{ .api_key = "k", .base_url = "u", .model = "m" } },
    };
    try t.expectEqual(APIStyle.openai_chat, cfg.style());
}

test "global http client: init/borrow/deinit" {
    var threaded: std.Io.Threaded = .init(t.allocator, .{});
    defer threaded.deinit();
    const io = threaded.io();
    initHttp(t.allocator, io);
    defer deinitHttp();
    initHttp(t.allocator, io); // idempotent
    _ = httpClient();
}