Skip to main content

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