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