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/// 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}