locode_provider/anthropic/config.rs
1//! Per-model wire configuration: backend detection, auth, betas, env resolution.
2//!
3//! The record follows grok's per-model `{ base_url, api_backend, extra_headers }`
4//! shape (ADR-0007) plus the auth split a base-URL override forces (native
5//! `x-api-key` → proxy `Authorization: Bearer`). OpenRouter is a first-class,
6//! auto-detected backend with two quirks of its own (plan §9.2, ADR-0007
7//! amendment 2026-07-18): the beta list is mirrored onto `x-anthropic-beta`, and a
8//! default `provider` preferences block is injected into the request body.
9
10use crate::provider::ProviderError;
11
12/// The default model when config names none (plan §9.1; overridden via `--model`/settings).
13pub const DEFAULT_MODEL: &str = "claude-sonnet-5";
14
15/// The default first-party endpoint.
16pub const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
17
18/// The pinned `anthropic-version` header value (Claude Code sends the same).
19pub const ANTHROPIC_VERSION: &str = "2023-06-01";
20
21/// The interleaved-thinking beta — **on by default** (plan §9.3): thinking blocks
22/// may interleave with tool calls within one assistant turn, and `budget_tokens`
23/// may exceed `max_tokens`. Proxy-safe (OpenRouter documents it).
24pub const INTERLEAVED_THINKING_BETA: &str = "interleaved-thinking-2025-05-14";
25
26/// The effort-based reasoning beta, required by
27/// [`ReasoningEncoding::EffortAdaptive`] (opt-in; Claude Code `betas.ts:15`).
28pub const EFFORT_BETA: &str = "effort-2025-11-24";
29
30/// Per-model **ceiling** clamped onto the request's `max_tokens`.
31///
32/// This is a guard against a caller asking for more than the model will accept
33/// — not the budget itself, which is
34/// [`SamplingArgs::max_tokens`](crate::SamplingArgs::max_tokens) (default
35/// [`DEFAULT_MAX_TOKENS`](crate::DEFAULT_MAX_TOKENS), 32k).
36///
37/// Was 8000, credited to Claude Code's `CAPPED_DEFAULT_MAX_TOKENS`. That
38/// citation was wrong twice over: the 8k constant is a *default*, not a
39/// ceiling, and it is gated on a slot-reservation experiment that is off by
40/// default outside first-party (`services/api/claude.ts:3394-3397`). Because
41/// the clamp is a `min`, an 8k ceiling silently overrode any larger budget a
42/// caller set. 64k matches Claude Code's `ESCALATED_MAX_TOKENS`
43/// (`utils/context.ts:25`) — the value it retries at after a truncation — and
44/// sits at or under the `upperLimit` of every model this wire targets.
45pub const DEFAULT_MAX_TOKENS_CAP: u32 = 64_000;
46
47/// Which endpoint family the wire talks to — selects auth header + quirks.
48///
49/// This is *configuration*, not a wire schema: all three speak Anthropic Messages
50/// (`Provider::api_schema()` stays `"anthropic"`).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ApiBackend {
53 /// First-party `api.anthropic.com` — auth via `x-api-key`.
54 Native,
55 /// OpenRouter's Anthropic-compatible endpoint (plan §9.2) — Bearer auth,
56 /// betas mirrored to `x-anthropic-beta`, `provider` preferences injected.
57 OpenRouter,
58 /// Any other Anthropic-compatible gateway — Bearer auth, no extra quirks.
59 Proxy,
60}
61
62impl ApiBackend {
63 /// Detect the backend from a base URL (pinnable by setting the field directly).
64 ///
65 /// `openrouter.ai` (any subdomain) → [`ApiBackend::OpenRouter`];
66 /// `api.anthropic.com` → [`ApiBackend::Native`]; anything else →
67 /// [`ApiBackend::Proxy`]. Matches the "base-URL override changes the auth
68 /// header" rule (ADR-0007 consequences).
69 #[must_use]
70 pub fn detect(base_url: &str) -> Self {
71 let host = host_of(base_url);
72 if host == "openrouter.ai" || host.ends_with(".openrouter.ai") {
73 ApiBackend::OpenRouter
74 } else if host == "api.anthropic.com" {
75 ApiBackend::Native
76 } else {
77 ApiBackend::Proxy
78 }
79 }
80}
81
82/// Extract the host portion of a URL without pulling in a URL crate.
83fn host_of(url: &str) -> &str {
84 let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest);
85 let end = after_scheme
86 .find(['/', ':', '?', '#'])
87 .unwrap_or(after_scheme.len());
88 &after_scheme[..end]
89}
90
91/// The resolved credential and how to present it.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub enum AuthScheme {
94 /// Native `x-api-key: <key>`.
95 ApiKey(String),
96 /// Gateway `Authorization: Bearer <token>`.
97 Bearer(String),
98}
99
100/// How `Role::Developer` messages are rendered on the wire (plan §4.1).
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
102pub enum DeveloperRendering {
103 /// Portable default: a `role:"user"` message wrapped in
104 /// `<system-reminder>…</system-reminder>`. No beta needed.
105 #[default]
106 SystemReminder,
107 /// A mid-conversation `role:"system"` message (beta-gated opt-in).
108 MidConversationSystemBeta,
109}
110
111/// How `reasoning_effort` is encoded on the wire (plan §4.4).
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum ReasoningEncoding {
114 /// `thinking: {type:"enabled", budget_tokens}` — the v0 default; no beta,
115 /// widest model support.
116 #[default]
117 Budget,
118 /// `output_config.effort` + `thinking: {type:"adaptive"}` — opt-in; requires
119 /// [`EFFORT_BETA`] and an adaptive-capable model.
120 EffortAdaptive,
121}
122
123/// The per-model wire configuration record (ADR-0007 + plan §3.1/§9).
124#[derive(Debug, Clone)]
125pub struct ModelConfig {
126 /// The model id (native `claude-…`; OpenRouter `anthropic/claude-…`).
127 pub model: String,
128 /// The endpoint base URL (`{base_url}/v1/messages` is the request path).
129 pub base_url: String,
130 /// The endpoint family — selects the auth header and backend quirks.
131 pub api_backend: ApiBackend,
132 /// The resolved credential.
133 pub auth: AuthScheme,
134 /// The `anthropic-version` header value.
135 pub anthropic_version: String,
136 /// `anthropic-beta` values, **latched per session** (plan §4.8). Defaults to
137 /// [`INTERLEAVED_THINKING_BETA`] (plan §9.3).
138 pub betas: Vec<String>,
139 /// Extra headers appended to every request (gateway auth quirks etc.).
140 pub extra_headers: Vec<(String, String)>,
141 /// Hard ceiling clamped onto `SamplingArgs.max_tokens`.
142 pub max_tokens_cap: u32,
143 /// How Developer messages are rendered.
144 pub developer_rendering: DeveloperRendering,
145 /// How `reasoning_effort` is encoded.
146 pub reasoning_encoding: ReasoningEncoding,
147 /// OpenRouter `provider` routing preferences. `None` → the default trio
148 /// (`ignore: ["amazon-bedrock"], allow_fallbacks: false,
149 /// require_parameters: true`, matching cc-reverse-proxy) when the backend
150 /// is OpenRouter; ignored on other backends.
151 ///
152 /// Vertex is deliberately **not** excluded — it is a production-relevant
153 /// Anthropic provider (user decision, 2026-07-18). Note from the Task-12
154 /// live smoke: Vertex honours the thinking config only when the
155 /// interleaved-thinking beta header reaches it, and cross-provider routing
156 /// between turns forfeits prompt-cache reads; pin `only: ["anthropic"]`
157 /// here when cache-hit determinism matters more than provider failover.
158 pub provider_prefs: Option<serde_json::Value>,
159}
160
161impl ModelConfig {
162 /// Build a config for `model` against `base_url` with `key`, detecting the
163 /// backend and choosing the matching auth scheme.
164 #[must_use]
165 pub fn new(
166 model: impl Into<String>,
167 base_url: impl Into<String>,
168 key: impl Into<String>,
169 ) -> Self {
170 let base_url: String = base_url.into();
171 // Trailing slashes would double up when the request path is appended.
172 let base_url = base_url.trim_end_matches('/').to_string();
173 let api_backend = ApiBackend::detect(&base_url);
174 let key = key.into();
175 let auth = match api_backend {
176 ApiBackend::Native => AuthScheme::ApiKey(key),
177 ApiBackend::OpenRouter | ApiBackend::Proxy => AuthScheme::Bearer(key),
178 };
179 Self {
180 model: model.into(),
181 base_url,
182 api_backend,
183 auth,
184 anthropic_version: ANTHROPIC_VERSION.to_string(),
185 betas: vec![INTERLEAVED_THINKING_BETA.to_string()],
186 extra_headers: Vec::new(),
187 max_tokens_cap: DEFAULT_MAX_TOKENS_CAP,
188 developer_rendering: DeveloperRendering::default(),
189 reasoning_encoding: ReasoningEncoding::default(),
190 provider_prefs: None,
191 }
192 }
193
194 /// Resolve the common case from the environment: `LOCODE_API_KEY` (required),
195 /// `LOCODE_BASE_URL` (default [`DEFAULT_BASE_URL`]); the model defaults
196 /// (default [`DEFAULT_MODEL`]).
197 ///
198 /// # Errors
199 /// [`ProviderError::Auth`] when `LOCODE_API_KEY` is unset or empty.
200 pub fn from_env() -> Result<Self, ProviderError> {
201 let key = std::env::var("LOCODE_API_KEY")
202 .ok()
203 .filter(|k| !k.is_empty())
204 .ok_or_else(|| ProviderError::Auth("LOCODE_API_KEY is not set".to_string()))?;
205 let base_url =
206 std::env::var("LOCODE_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
207 // Model selection is settings/flag territory (ADR-0024 §1.4) — the
208 // LOCODE_MODEL env override was removed 2026-07-24 so the model's
209 // precedence chain matches every other knob (flag > settings > default).
210 Ok(Self::new(DEFAULT_MODEL.to_string(), base_url, key))
211 }
212
213 /// Whether the interleaved-thinking beta is active (waives the thinking
214 /// budget clamp, plan §9.3).
215 #[must_use]
216 pub fn interleaved_thinking(&self) -> bool {
217 self.betas.iter().any(|b| b == INTERLEAVED_THINKING_BETA)
218 }
219
220 /// The OpenRouter `provider` preferences to inject, if any (plan §9.2):
221 /// the configured value, or the default trio on the OpenRouter backend.
222 #[must_use]
223 pub fn effective_provider_prefs(&self) -> Option<serde_json::Value> {
224 if self.api_backend != ApiBackend::OpenRouter {
225 return None;
226 }
227 Some(self.provider_prefs.clone().unwrap_or_else(|| {
228 serde_json::json!({
229 "ignore": ["amazon-bedrock"],
230 "allow_fallbacks": false,
231 "require_parameters": true,
232 })
233 }))
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn backend_detection() {
243 assert_eq!(
244 ApiBackend::detect("https://api.anthropic.com"),
245 ApiBackend::Native
246 );
247 assert_eq!(
248 ApiBackend::detect("https://openrouter.ai/api"),
249 ApiBackend::OpenRouter
250 );
251 assert_eq!(
252 ApiBackend::detect("https://gateway.openrouter.ai/api"),
253 ApiBackend::OpenRouter
254 );
255 assert_eq!(
256 ApiBackend::detect("https://my-proxy.example.com:8080/v1"),
257 ApiBackend::Proxy
258 );
259 assert_eq!(
260 ApiBackend::detect("http://localhost:8080"),
261 ApiBackend::Proxy
262 );
263 }
264
265 #[test]
266 fn auth_scheme_follows_backend() {
267 let native = ModelConfig::new("m", "https://api.anthropic.com", "k1");
268 assert_eq!(native.auth, AuthScheme::ApiKey("k1".to_string()));
269 assert_eq!(native.api_backend, ApiBackend::Native);
270
271 let or = ModelConfig::new("m", "https://openrouter.ai/api/", "k2");
272 assert_eq!(or.auth, AuthScheme::Bearer("k2".to_string()));
273 assert_eq!(or.api_backend, ApiBackend::OpenRouter);
274 assert_eq!(
275 or.base_url, "https://openrouter.ai/api",
276 "trailing slash trimmed"
277 );
278 }
279
280 #[test]
281 fn interleaved_thinking_default_on_and_removable() {
282 let mut cfg = ModelConfig::new("m", DEFAULT_BASE_URL, "k");
283 assert!(cfg.interleaved_thinking(), "beta on by default (plan §9.3)");
284 cfg.betas.clear();
285 assert!(!cfg.interleaved_thinking());
286 }
287
288 #[test]
289 fn provider_prefs_only_for_openrouter() {
290 let native = ModelConfig::new("m", DEFAULT_BASE_URL, "k");
291 assert!(native.effective_provider_prefs().is_none());
292
293 let or = ModelConfig::new("m", "https://openrouter.ai/api", "k");
294 let prefs = or.effective_provider_prefs().expect("default trio");
295 assert_eq!(prefs["require_parameters"], true);
296 assert_eq!(prefs["allow_fallbacks"], false);
297 assert_eq!(prefs["ignore"][0], "amazon-bedrock", "Vertex stays allowed");
298
299 let mut custom = ModelConfig::new("m", "https://openrouter.ai/api", "k");
300 custom.provider_prefs = Some(serde_json::json!({"only": ["anthropic"]}));
301 assert_eq!(
302 custom.effective_provider_prefs().expect("custom")["only"][0],
303 "anthropic"
304 );
305 }
306}