locode_provider/request.rs
1//! The API-agnostic request the engine hands to any [`Provider`](crate::Provider).
2
3use locode_protocol::{Message, ToolSpec};
4
5/// One provider-neutral sampling request (ADR-0007).
6///
7/// The loop builds exactly one of these and knows nothing about any wire. Per
8/// ADR-0013 there is **no separate `system` field** — `messages` carries the whole
9/// role-tagged stream (System/Developer included), and each wire hoists leading
10/// System messages into its own top-level slot (e.g. Anthropic's `system`).
11#[derive(Debug, Clone)]
12pub struct ConversationRequest {
13 /// The full role-tagged conversation stream (System/Developer/User/Assistant).
14 pub messages: Vec<Message>,
15 /// The tool specs offered to the model, mapped by the wire to its tool format.
16 pub tools: Vec<ToolSpec>,
17 /// Provider-neutral sampling knobs.
18 pub sampling_args: SamplingArgs,
19 /// A hint for where the wire should place prompt-cache breakpoints.
20 pub cache_hint: CacheHint,
21}
22
23/// Provider-neutral sampling knobs — the **common core** only.
24///
25/// Wire-specific parameters (Anthropic `top_k`/`stop_sequences`/thinking budget,
26/// OpenAI `frequency_penalty`, …) are the concern of each wire's request builder,
27/// not this type, keeping [`ConversationRequest`] API-agnostic (ADR-0007). Grok
28/// Build uses the same layering: a neutral core plus per-wire superset structs.
29#[derive(Debug, Clone, PartialEq)]
30pub struct SamplingArgs {
31 /// The maximum number of tokens to generate; see
32 /// [`DEFAULT_MAX_TOKENS`] for the default and why.
33 pub max_tokens: u32,
34 /// Sampling temperature; a wire may omit it (e.g. Anthropic requires temp=1
35 /// when thinking is on, so the wire drops it there).
36 pub temperature: Option<f32>,
37 /// Nucleus-sampling probability mass.
38 pub top_p: Option<f32>,
39 /// Neutral reasoning budget; each wire maps it to its own control
40 /// (Anthropic `budget_tokens`, OpenAI reasoning `effort`).
41 pub reasoning_effort: Option<ReasoningEffort>,
42}
43
44/// The default output-token budget for one sample.
45///
46/// A file-writing agent spends most of a turn inside one `tool_use` argument
47/// blob, so this is not a "typical reply length" knob — it is the ceiling on
48/// the largest single tool call the model can emit. Set it too low and the
49/// wire truncates mid-call: the API returns the `tool_use` block with an
50/// **empty** `input` (`{}`) and `stop_reason: "max_tokens"`, the typed decode
51/// then reports a missing required field, and the model retries the same
52/// oversized call forever. That is what 4096 did here.
53///
54/// 64k is the ceiling every current model this crate targets accepts, and the
55/// value Claude Code escalates to when a turn truncates (`ESCALATED_MAX_TOKENS`,
56/// `utils/context.ts:25`). Claude Code otherwise resolves a per-model
57/// `{default, upperLimit}` pair (`utils/context.ts:149-208`) — 64k default for
58/// opus-4-6, 32k for the sonnet-4-6 / 4.5 families — and its 8k
59/// `CAPPED_DEFAULT_MAX_TOKENS` is **not** the shipped default: it is gated on
60/// the `tengu_otk_slot_v1` slot-reservation experiment, which defaults to
61/// *false* off first-party (`services/api/claude.ts:3394-3397`).
62///
63/// Not 128k, even though the current frontier models allow it: `upperLimit` is
64/// per model, and Haiku 4.5 stops at 64k. 64k is the largest value that is
65/// correct everywhere, and the gap is academic — a single tool call whose
66/// arguments exceed 64k tokens is not a call worth completing.
67///
68/// This is a **ceiling on one response**, not a reservation: nothing is spent
69/// unless the model generates it, so a generous value costs only the risk of a
70/// long generation, never tokens.
71///
72/// The field itself cannot become `Option` to mean "let the API decide": the
73/// Anthropic Messages API requires `max_tokens` on every request. opencode
74/// encodes exactly that asymmetry — `max_tokens: Schema.Number` (required) for
75/// Anthropic against `Schema.optional` for both OpenAI protocols — and always
76/// sends a value, falling back to the model's declared output limit
77/// (`protocols/anthropic-messages.ts:510,546`). A per-model table is the same
78/// eventual answer here; one safe default is the honest v0.
79pub const DEFAULT_MAX_TOKENS: u32 = 64_000;
80
81impl Default for SamplingArgs {
82 fn default() -> Self {
83 Self {
84 max_tokens: DEFAULT_MAX_TOKENS,
85 temperature: None,
86 top_p: None,
87 reasoning_effort: None,
88 }
89 }
90}
91
92/// A neutral reasoning-effort level, mapped per-wire (ADR-0007).
93///
94/// Grok Build proves this shape: one neutral enum with per-backend mappings
95/// (`to_messages_api()` → Anthropic budget, `to_responses_api()` → OpenAI effort).
96/// Tiers fragment per vendor/model generation (OpenAI `none…xhigh`, codex
97/// `minimal…xhigh`, grok stores per-model allowed-effort lists), so the ladder
98/// covers the observed union and `Other` passes vendor-specific strings
99/// through verbatim (ADR-0007 amendment 2026-07-19). Wires surface the API's
100/// own error on unsupported tiers — never silently clamp (silent clamping
101/// would corrupt eval comparisons).
102#[derive(Debug, Clone, PartialEq, Eq)]
103#[non_exhaustive]
104pub enum ReasoningEffort {
105 /// Explicitly send the wire's "none" (distinct from omitting the param —
106 /// `SamplingArgs.reasoning_effort: None` omits it entirely).
107 None,
108 /// Minimal reasoning.
109 Minimal,
110 /// Low reasoning effort.
111 Low,
112 /// Medium reasoning effort.
113 Medium,
114 /// High reasoning effort.
115 High,
116 /// Extra-high reasoning effort.
117 XHigh,
118 /// The deepest tier a wire offers. Distinct from [`ReasoningEffort::XHigh`]:
119 /// Anthropic exposes both, and `max` is the "correctness matters more than
120 /// cost" setting rather than one more notch of `xhigh`.
121 Max,
122 /// A vendor/model-specific tier, passed through verbatim by wires that
123 /// take effort strings; wires with fixed mappings soft-reject it.
124 Other(String),
125}
126
127/// Where the wire should place prompt-cache breakpoints (ADR-0007).
128///
129/// A reserved seam: the actual `cache_control` placement (Anthropic: one marker on
130/// the last message + ≤4 on system blocks) lives in the wire (Task 12).
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
132pub enum CacheHint {
133 /// No prompt caching.
134 Off,
135 /// Apply the wire's default breakpoint policy.
136 #[default]
137 Standard,
138}