chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+462
View File
@@ -0,0 +1,462 @@
/// Claude Code OAuth beta headers used by the Anthropic transport.
pub const ANTHROPIC_OAUTH_BETA_HEADERS: &str = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24";
/// Claude Code OAuth beta headers with Anthropic's explicit 1M context beta.
pub const ANTHROPIC_OAUTH_BETA_HEADERS_1M: &str = "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,effort-2025-11-24,context-1m-2025-08-07";
/// How a Claude model exposes its 1M-token long-context window.
///
/// These classifications were verified against the live Anthropic API on a
/// Claude subscription (raw 250K-token requests): the catalog's
/// `max_input_tokens` field is not a reliable signal because it over-advertises
/// 1M for models that are still hard-capped at 200K (e.g. `claude-sonnet-4-5`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnthropicContextMode {
/// 1M input window available by default, no beta header or `[1m]` opt-in
/// needed (e.g. `claude-opus-4-8`, `claude-opus-4-7`).
Native1M,
/// 200K by default; 1M available as an opt-in via the `context-1m` beta
/// header (the `[1m]` suffix), which may require usage credits
/// (e.g. `claude-opus-4-6`, `claude-sonnet-4-6`).
OptIn1M,
/// 200K input window, with no 1M path (e.g. `claude-opus-4-5`,
/// `claude-sonnet-4-5`, `claude-haiku-4-5`).
Standard,
}
impl AnthropicContextMode {
/// The default context window (in tokens) for this mode, i.e. what a request
/// gets without opting in to the 1M beta.
pub fn default_context_window(self) -> usize {
match self {
AnthropicContextMode::Native1M => 1_000_000,
AnthropicContextMode::OptIn1M | AnthropicContextMode::Standard => 200_000,
}
}
/// The context window (in tokens) when the 1M long-context path is engaged
/// (the `[1m]` suffix). For `Standard` models there is no 1M path, so this is
/// the same as the default.
pub fn long_context_window(self) -> usize {
match self {
AnthropicContextMode::Native1M => 1_000_000,
// Anthropic's opt-in beta advertises a 1,048,576-token window.
AnthropicContextMode::OptIn1M => 1_048_576,
AnthropicContextMode::Standard => 200_000,
}
}
/// Whether this model has any 1M long-context path at all (native or opt-in).
pub fn has_1m_window(self) -> bool {
!matches!(self, AnthropicContextMode::Standard)
}
/// Whether jcode should surface a distinct `[1m]` picker alias for this model.
/// Only opt-in models benefit, native-1M models already use 1M by default so
/// a `[1m]` alias would be a redundant duplicate.
pub fn exposes_1m_alias(self) -> bool {
matches!(self, AnthropicContextMode::OptIn1M)
}
}
/// Classify how a Claude model exposes long context. Accepts both canonical
/// (`claude-opus-4-8`) and dotted (`claude-opus-4.8`) forms, with or without a
/// trailing `[1m]` suffix.
pub fn anthropic_context_mode(model: &str) -> AnthropicContextMode {
let base = anthropic_strip_1m_suffix(model.trim()).to_ascii_lowercase();
// Native 1M (default, no opt-in): Opus 4.8 and 4.7, Sonnet 5, Fable 5.
// Sonnet 5 supports the 1M window by default (1M is both the default and
// the maximum; there is no smaller context variant).
if base.starts_with("claude-opus-4-8")
|| base.starts_with("claude-opus-4.8")
|| base.starts_with("claude-opus-4-7")
|| base.starts_with("claude-opus-4.7")
|| base.starts_with("claude-sonnet-5")
|| base.starts_with("claude-fable-5")
{
return AnthropicContextMode::Native1M;
}
// Opt-in 1M via the context-1m beta: Opus 4.6 and Sonnet 4.6.
if base.starts_with("claude-opus-4-6")
|| base.starts_with("claude-opus-4.6")
|| base.starts_with("claude-sonnet-4-6")
|| base.starts_with("claude-sonnet-4.6")
{
return AnthropicContextMode::OptIn1M;
}
AnthropicContextMode::Standard
}
/// Check if a model name explicitly requests 1M context via suffix
/// (for example `claude-opus-4-6[1m]`).
pub fn anthropic_is_1m_model(model: &str) -> bool {
model.ends_with("[1m]")
}
/// Check if a model explicitly requests 1M context via the `[1m]` suffix.
pub fn anthropic_effectively_1m(model: &str) -> bool {
anthropic_is_1m_model(model)
}
/// Strip the `[1m]` suffix to get the actual API model name.
pub fn anthropic_strip_1m_suffix(model: &str) -> &str {
crate::model_id::strip_long_context_suffix(model)
}
/// Get the OAuth beta header value appropriate for the model.
pub fn anthropic_oauth_beta_headers(model: &str) -> &'static str {
if anthropic_is_1m_model(model) {
ANTHROPIC_OAUTH_BETA_HEADERS_1M
} else {
ANTHROPIC_OAUTH_BETA_HEADERS
}
}
/// How a Claude model exposes reasoning effort and thinking on the live
/// Messages API.
///
/// This is the single source of truth shared by the Anthropic runtime (request
/// building, `set_reasoning_effort` validation) and the TUI effort cycler, so
/// new models cannot drift between the two.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AnthropicReasoningCaps {
/// Accepts `output_config: {effort}`.
pub output_effort: bool,
/// Accepts `thinking: {type: adaptive}`.
pub adaptive_thinking: bool,
/// Needs `thinking: {type: enabled, budget_tokens}` (manual budgets).
pub manual_thinking: bool,
/// Accepts the `xhigh` effort level.
pub xhigh_effort: bool,
/// Accepts the `max` effort level.
pub max_effort: bool,
}
impl AnthropicReasoningCaps {
/// Full modern ladder: `output_config` effort low..xhigh/max + adaptive thinking.
const FULL: Self = Self {
output_effort: true,
adaptive_thinking: true,
manual_thinking: false,
xhigh_effort: true,
max_effort: true,
};
/// `output_config` effort + adaptive thinking, but no `xhigh` level.
const EFFORT_NO_XHIGH: Self = Self {
output_effort: true,
adaptive_thinking: true,
manual_thinking: false,
xhigh_effort: false,
max_effort: true,
};
/// `output_config` effort with manual thinking budgets (Opus 4.5).
const MANUAL_WITH_EFFORT: Self = Self {
output_effort: true,
adaptive_thinking: false,
manual_thinking: true,
xhigh_effort: false,
max_effort: false,
};
/// Manual thinking budgets only (Claude 3.7 Sonnet).
const MANUAL_ONLY: Self = Self {
output_effort: false,
adaptive_thinking: false,
manual_thinking: true,
xhigh_effort: false,
max_effort: false,
};
const NONE: Self = Self {
output_effort: false,
adaptive_thinking: false,
manual_thinking: false,
xhigh_effort: false,
max_effort: false,
};
/// Whether any reasoning-effort control is available at all.
pub fn supports_reasoning_effort(self) -> bool {
self.output_effort || self.manual_thinking
}
}
/// Normalize a Claude id for capability matching: lowercase, `[1m]` and
/// `-YYYYMMDD` date suffixes stripped, dotted versions (`4.6`) dashed (`4-6`).
fn normalized_claude_caps_key(model: &str) -> String {
let base = anthropic_strip_1m_suffix(model.trim())
.to_ascii_lowercase()
.replace('.', "-");
crate::model_id::strip_date_suffix(&base).to_string()
}
/// Parse `(family, version)` from a normalized Claude id. Handles both
/// version-last (`claude-sonnet-4-6`) and version-first (`claude-3-7-sonnet`)
/// forms. A single version number means `.0` (`claude-sonnet-5` -> 5.0).
fn parse_claude_family_version(base: &str) -> (Option<&str>, Option<(u32, u32)>) {
let mut family = None;
let mut nums: Vec<u32> = Vec::new();
for segment in base.split('-') {
if segment == "claude" {
continue;
}
if let Ok(num) = segment.parse::<u32>() {
if nums.len() < 2 {
nums.push(num);
}
} else if family.is_none() && segment.chars().all(|c| c.is_ascii_alphabetic()) {
family = Some(segment);
}
}
let version = match nums.as_slice() {
[] => None,
[major] => Some((*major, 0)),
[major, minor, ..] => Some((*major, *minor)),
};
(family, version)
}
/// Reasoning-effort capabilities for a Claude model.
///
/// Known generations are pinned to what the live API accepts (verified live
/// 2026-07-01 for Fable 5 / Opus 4.x, 2026-07-07 for Sonnet 5). Unknown
/// *future* generations (version 5+ in any family) optimistically default to
/// the full ladder: the Anthropic runtime self-heals by stripping the
/// reasoning fields and retrying if a model rejects them, so optimism degrades
/// gracefully while pessimism silently disables effort until someone probes
/// the model and updates a table.
pub fn anthropic_reasoning_caps(model: &str) -> AnthropicReasoningCaps {
let base = normalized_claude_caps_key(model);
if !base.starts_with("claude") {
return AnthropicReasoningCaps::NONE;
}
if base.contains("mythos") {
return AnthropicReasoningCaps::EFFORT_NO_XHIGH;
}
let (family, version) = parse_claude_family_version(&base);
let Some(version) = version else {
return AnthropicReasoningCaps::NONE;
};
match family {
Some("opus") => {
if version >= (4, 7) {
AnthropicReasoningCaps::FULL
} else if version == (4, 6) {
AnthropicReasoningCaps::EFFORT_NO_XHIGH
} else if version == (4, 5) {
AnthropicReasoningCaps::MANUAL_WITH_EFFORT
} else {
AnthropicReasoningCaps::NONE
}
}
Some("sonnet") => {
if version >= (5, 0) {
AnthropicReasoningCaps::FULL
} else if version == (4, 6) {
AnthropicReasoningCaps::EFFORT_NO_XHIGH
} else if version == (3, 7) {
AnthropicReasoningCaps::MANUAL_ONLY
} else {
AnthropicReasoningCaps::NONE
}
}
// Optimistic default for new generations (Fable 5, Haiku 5, future
// families): assume the modern full ladder from version 5 on.
_ => {
if version >= (5, 0) {
AnthropicReasoningCaps::FULL
} else {
AnthropicReasoningCaps::NONE
}
}
}
}
pub fn anthropic_map_tool_name_for_oauth(name: &str) -> String {
match name {
"bash" => "Bash",
"read" => "Read",
"write" => "Write",
"edit" => "Edit",
"glob" => "Glob",
"grep" => "Grep",
"subagent" => "Agent",
"schedule" => "ScheduleWakeup",
"skill_manage" => "Skill",
_ => name,
}
.to_string()
}
pub fn anthropic_map_tool_name_from_oauth(name: &str) -> String {
match name {
"Bash" => "bash",
"Read" => "read",
"Write" => "write",
"Edit" => "edit",
"Glob" => "glob",
"Grep" => "grep",
"Agent" => "subagent",
"ScheduleWakeup" => "schedule",
"Skill" => "skill_manage",
_ => name,
}
.to_string()
}
pub fn anthropic_stainless_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
other => other,
}
}
pub fn anthropic_stainless_os() -> &'static str {
match std::env::consts::OS {
"linux" => "Linux",
"macos" => "MacOS",
"windows" => "Windows",
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_suffix_helpers_require_explicit_1m_suffix() {
assert!(!anthropic_effectively_1m("claude-opus-4-6"));
assert!(anthropic_effectively_1m("claude-opus-4-6[1m]"));
assert_eq!(
anthropic_strip_1m_suffix("claude-opus-4-6[1m]"),
"claude-opus-4-6"
);
}
#[test]
fn oauth_beta_headers_follow_1m_suffix() {
assert_eq!(
anthropic_oauth_beta_headers("claude-opus-4-6"),
ANTHROPIC_OAUTH_BETA_HEADERS
);
assert_eq!(
anthropic_oauth_beta_headers("claude-opus-4-6[1m]"),
ANTHROPIC_OAUTH_BETA_HEADERS_1M
);
}
#[test]
fn oauth_tool_name_mapping_is_reversible_for_known_tools() {
for (local, oauth) in [
("bash", "Bash"),
("read", "Read"),
("subagent", "Agent"),
("schedule", "ScheduleWakeup"),
("skill_manage", "Skill"),
] {
assert_eq!(anthropic_map_tool_name_for_oauth(local), oauth);
assert_eq!(anthropic_map_tool_name_from_oauth(oauth), local);
}
assert_eq!(anthropic_map_tool_name_for_oauth("custom"), "custom");
}
#[test]
fn stainless_labels_are_non_empty() {
assert!(!anthropic_stainless_arch().is_empty());
assert!(!anthropic_stainless_os().is_empty());
}
#[test]
fn reasoning_caps_match_live_verified_generations() {
// Full ladder: Fable 5 (live 2026-07-01), Sonnet 5 (live 2026-07-07),
// Opus 4.7/4.8.
for model in [
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
] {
let caps = anthropic_reasoning_caps(model);
assert!(caps.output_effort, "{model} should support output effort");
assert!(caps.adaptive_thinking, "{model} should be adaptive");
assert!(caps.xhigh_effort, "{model} should support xhigh");
assert!(caps.max_effort, "{model} should support max");
assert!(!caps.manual_thinking);
}
// Effort without xhigh: Opus/Sonnet 4.6, Mythos.
for model in ["claude-opus-4-6", "claude-sonnet-4-6", "claude-mythos"] {
let caps = anthropic_reasoning_caps(model);
assert!(caps.output_effort, "{model} should support output effort");
assert!(caps.adaptive_thinking);
assert!(!caps.xhigh_effort, "{model} has no xhigh");
assert!(caps.max_effort, "{model} still supports max");
}
// Manual thinking generations.
let opus_4_5 = anthropic_reasoning_caps("claude-opus-4-5");
assert!(opus_4_5.output_effort && opus_4_5.manual_thinking);
assert!(!opus_4_5.adaptive_thinking && !opus_4_5.xhigh_effort && !opus_4_5.max_effort);
let sonnet_3_7 = anthropic_reasoning_caps("claude-3-7-sonnet");
assert!(sonnet_3_7.manual_thinking && !sonnet_3_7.output_effort);
assert_eq!(
anthropic_reasoning_caps("claude-sonnet-3-7"),
sonnet_3_7,
"version-first and version-last forms must match"
);
// No reasoning-effort support.
for model in [
"claude-sonnet-4-5",
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-3-5-haiku",
"gpt-5.5",
] {
assert!(
!anthropic_reasoning_caps(model).supports_reasoning_effort(),
"{model} should not support effort"
);
}
}
#[test]
fn reasoning_caps_normalize_suffixes_and_dots() {
let base = anthropic_reasoning_caps("claude-sonnet-5");
assert_eq!(anthropic_reasoning_caps("claude-sonnet-5[1m]"), base);
assert_eq!(anthropic_reasoning_caps("claude-sonnet-5-20260701"), base);
assert_eq!(anthropic_reasoning_caps("Claude-Sonnet-5"), base);
assert_eq!(
anthropic_reasoning_caps("claude-opus-4.6"),
anthropic_reasoning_caps("claude-opus-4-6")
);
}
#[test]
fn reasoning_caps_are_optimistic_for_future_generations() {
// New 5.x+ models default to the full ladder (the runtime self-heals
// on 400 by stripping reasoning fields), instead of silently
// disabling effort until someone probes them.
for model in [
"claude-sonnet-5-1",
"claude-sonnet-6",
"claude-opus-5",
"claude-haiku-5",
"claude-fable-6",
"claude-nova-5",
] {
let caps = anthropic_reasoning_caps(model);
assert_eq!(
caps,
anthropic_reasoning_caps("claude-fable-5"),
"{model} should default to the full modern ladder"
);
}
// But old/unversioned ids stay conservative.
assert!(!anthropic_reasoning_caps("claude-haiku-4-5").supports_reasoning_effort());
assert!(!anthropic_reasoning_caps("claude-instant").supports_reasoning_effort());
}
}
@@ -0,0 +1,237 @@
//! Per-attempt stream output tracking for provider retry loops.
//!
//! Retry loops re-invoke a provider's `stream_response` with the same event
//! sender after a transient transport fault (e.g. TLS `BadRecordMac`, idle
//! timeout, connection reset). If the failed attempt already streamed output
//! to the consumer, blindly replaying the request duplicates that output.
//!
//! [`track_attempt_output`] wraps the outer sender for the duration of one
//! attempt and records whether any *replay-visible* event passed through. On a
//! retryable failure the loop can then emit [`StreamEvent::RetryRollback`] so
//! consumers discard the partial output before the replay streams in.
//!
//! This is safe for jcode's HTTP providers because tools are only executed by
//! the agent loop *after* the stream completes; a partially streamed attempt
//! has no side effects. The Claude CLI path is the exception (the CLI executes
//! tools live mid-stream) and keeps its no-retry-after-output guard instead.
use anyhow::Result;
use jcode_message_types::StreamEvent;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
/// Whether an event renders as content on the consumer side, such that
/// replaying the request after it was emitted would visibly duplicate output.
///
/// Status-style events (connection phases, token usage snapshots, transport
/// labels) are excluded: consumers overwrite rather than accumulate them, so a
/// replay is harmless.
fn stream_event_is_replay_visible(event: &StreamEvent) -> bool {
match event {
StreamEvent::TextDelta(_)
| StreamEvent::ToolUseStart { .. }
| StreamEvent::ToolInputDelta(_)
| StreamEvent::ToolUseEnd
| StreamEvent::ToolUseSignature(_)
| StreamEvent::ToolResult { .. }
| StreamEvent::GeneratedImage { .. }
| StreamEvent::ThinkingDelta(_)
| StreamEvent::ThinkingSignatureDelta(_)
| StreamEvent::OpenAIReasoning { .. }
| StreamEvent::MessageEnd { .. }
| StreamEvent::Compaction { .. }
| StreamEvent::NativeToolCall { .. } => true,
StreamEvent::ThinkingStart
| StreamEvent::ThinkingEnd
| StreamEvent::ThinkingDone { .. }
| StreamEvent::RetryRollback { .. }
| StreamEvent::TokenUsage { .. }
| StreamEvent::ConnectionType { .. }
| StreamEvent::ConnectionPhase { .. }
| StreamEvent::StatusDetail { .. }
| StreamEvent::Error { .. }
| StreamEvent::SessionId(_)
| StreamEvent::UpstreamProvider { .. } => false,
}
}
/// Handle returned by [`track_attempt_output`]. Await [`AttemptGuard::finish`]
/// after the attempt completes (and after dropping the attempt sender) to
/// drain in-flight events and learn whether the attempt streamed output.
pub struct AttemptGuard {
saw_output: Arc<AtomicBool>,
forwarder: JoinHandle<()>,
}
impl AttemptGuard {
/// Wait for all events the attempt buffered to be forwarded to the outer
/// sender (preserving ordering relative to any subsequent rollback event),
/// then report whether any replay-visible output was emitted.
pub async fn finish(self) -> bool {
let _ = self.forwarder.await;
self.saw_output.load(Ordering::SeqCst)
}
}
/// Wrap `outer` for a single retry attempt. Returns a sender to pass into the
/// attempt's `stream_response` call and a guard that reports whether the
/// attempt emitted replay-visible output.
///
/// All events are forwarded in order to `outer`. The forwarder ends when the
/// attempt sender (and all its clones) are dropped, so callers must drop the
/// returned sender (usually implicit: `stream_response` consumes it) before
/// awaiting the guard.
pub fn track_attempt_output(
outer: mpsc::Sender<Result<StreamEvent>>,
) -> (mpsc::Sender<Result<StreamEvent>>, AttemptGuard) {
let (attempt_tx, mut attempt_rx) = mpsc::channel::<Result<StreamEvent>>(32);
let saw_output = Arc::new(AtomicBool::new(false));
let saw_output_writer = Arc::clone(&saw_output);
let forwarder = tokio::spawn(async move {
while let Some(item) = attempt_rx.recv().await {
if let Ok(event) = &item
&& stream_event_is_replay_visible(event)
{
saw_output_writer.store(true, Ordering::SeqCst);
}
if outer.send(item).await.is_err() {
// Consumer hung up; closing the attempt channel lets the
// in-flight attempt observe `tx.closed()` / send errors.
break;
}
}
});
(
attempt_tx,
AttemptGuard {
saw_output,
forwarder,
},
)
}
/// Exponential backoff with +/-20% jitter for provider retry loops.
///
/// Pure exponential backoff retries every in-flight session in lockstep during
/// a correlated upstream outage (thundering herd). Jitter spreads the retries
/// out. `attempt` is the 0-based index of the attempt about to run (so the
/// first retry, `attempt == 1`, waits ~`base_ms`).
pub fn retry_backoff_delay(attempt: u32, base_ms: u64) -> std::time::Duration {
use rand::Rng;
let exp = base_ms.saturating_mul(1u64 << attempt.saturating_sub(1).min(16));
let jittered = (exp as f64 * rand::rng().random_range(0.8..1.2)) as u64;
std::time::Duration::from_millis(jittered.max(1))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_tool_events_are_replay_visible() {
assert!(stream_event_is_replay_visible(&StreamEvent::TextDelta(
"hi".into()
)));
assert!(stream_event_is_replay_visible(&StreamEvent::ToolUseStart {
id: "t1".into(),
name: "bash".into(),
}));
assert!(stream_event_is_replay_visible(&StreamEvent::ThinkingDelta(
"hmm".into()
)));
assert!(stream_event_is_replay_visible(&StreamEvent::MessageEnd {
stop_reason: None,
}));
}
#[test]
fn status_events_are_not_replay_visible() {
assert!(!stream_event_is_replay_visible(
&StreamEvent::ConnectionPhase {
phase: jcode_message_types::ConnectionPhase::Connecting,
}
));
assert!(!stream_event_is_replay_visible(&StreamEvent::TokenUsage {
input_tokens: Some(1),
output_tokens: Some(2),
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
}));
assert!(!stream_event_is_replay_visible(
&StreamEvent::StatusDetail {
detail: "https".into(),
}
));
assert!(!stream_event_is_replay_visible(
&StreamEvent::RetryRollback { attempt: 1, max: 3 }
));
}
#[tokio::test]
async fn tracker_forwards_in_order_and_reports_output() {
let (outer_tx, mut outer_rx) = mpsc::channel::<Result<StreamEvent>>(8);
let (attempt_tx, guard) = track_attempt_output(outer_tx.clone());
attempt_tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: jcode_message_types::ConnectionPhase::Connecting,
}))
.await
.unwrap();
attempt_tx
.send(Ok(StreamEvent::TextDelta("partial".into())))
.await
.unwrap();
drop(attempt_tx);
// Guard must report output and only resolve after both events are
// forwarded, so a rollback sent afterwards is ordered behind them.
assert!(guard.finish().await);
let _ = outer_tx
.send(Ok(StreamEvent::RetryRollback { attempt: 1, max: 3 }))
.await;
let first = outer_rx.recv().await.unwrap().unwrap();
assert!(matches!(first, StreamEvent::ConnectionPhase { .. }));
let second = outer_rx.recv().await.unwrap().unwrap();
assert!(matches!(second, StreamEvent::TextDelta(t) if t == "partial"));
let third = outer_rx.recv().await.unwrap().unwrap();
assert!(matches!(
third,
StreamEvent::RetryRollback { attempt: 1, max: 3 }
));
}
#[tokio::test]
async fn tracker_reports_no_output_for_status_only_attempt() {
let (outer_tx, _outer_rx) = mpsc::channel::<Result<StreamEvent>>(8);
let (attempt_tx, guard) = track_attempt_output(outer_tx);
attempt_tx
.send(Ok(StreamEvent::ConnectionPhase {
phase: jcode_message_types::ConnectionPhase::Connecting,
}))
.await
.unwrap();
drop(attempt_tx);
assert!(!guard.finish().await);
}
#[test]
fn backoff_delay_is_jittered_exponential() {
for attempt in 1..=3u32 {
let base = 1000u64;
let exp = base * (1 << (attempt - 1));
let lo = (exp as f64 * 0.8) as u64;
let hi = (exp as f64 * 1.2) as u64 + 1;
for _ in 0..50 {
let d = retry_backoff_delay(attempt, base).as_millis() as u64;
assert!(
(lo..=hi).contains(&d),
"attempt {attempt}: delay {d} outside [{lo}, {hi}]"
);
}
}
}
}
+439
View File
@@ -0,0 +1,439 @@
//! Canonical source of truth for the OAuth-vs-API-key credential decision of
//! the two "dual-auth" providers: Anthropic/Claude and OpenAI.
//!
//! These providers each support *both* a subscription/OAuth login and a direct
//! API key, so every request needs an explicit "which credential" decision.
//!
//! Historically jcode encoded that decision as free-form strings spread across
//! several overlapping vocabularies:
//!
//! | concept | runtime env (`JCODE_RUNTIME_PROVIDER`) | route / stable-id | CLI `--provider` | model prefix |
//! |----------------------|----------------------------------------|----------------------|------------------|------------------|
//! | Claude, OAuth | `claude` | `claude-oauth` | `claude` | `claude-oauth:` |
//! | Claude, API key | `claude-api` | `anthropic-api-key` | `anthropic-api` | `claude-api:` |
//! | OpenAI, OAuth | `openai` | `openai-oauth` | `openai` | `openai-oauth:` |
//! | OpenAI, API key | `openai-api` | `openai-api-key` | `openai-api` | `openai-api:` |
//!
//! Each call site used to parse its own subset of these aliases by hand, and the
//! subsets drifted: e.g. one parser accepted `openai` but not `openai-oauth`,
//! another accepted `claude`/`anthropic` but silently ignored `claude-oauth`.
//! When a string from one vocabulary leaked into a parser that only knew
//! another, the OAuth and API-key paths got mixed up.
//!
//! This module is the single place that:
//! * parses *any* alias from *any* vocabulary into a structured
//! [`AuthRoute`] (`provider` + `mode`), and
//! * emits the canonical string for each vocabulary.
//!
//! Every credential-mode parser and every UI/billing surface should go through
//! here instead of re-deriving the decision from ad-hoc string matches.
use crate::ResolvedCredential;
use crate::selection::ActiveProvider;
/// A provider that supports *both* a subscription/OAuth login and a direct
/// API-key credential, and therefore needs an explicit OAuth-vs-API decision.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DualAuthProvider {
/// Anthropic / Claude (Claude subscription OAuth vs `ANTHROPIC_API_KEY`).
Anthropic,
/// OpenAI (ChatGPT/Codex OAuth vs `OPENAI_API_KEY`).
OpenAI,
}
impl DualAuthProvider {
/// The dual-auth provider backing an [`ActiveProvider`], if any. Returns
/// `None` for providers with no OAuth-vs-API-key ambiguity.
pub const fn from_active_provider(provider: ActiveProvider) -> Option<Self> {
match provider {
ActiveProvider::Claude => Some(Self::Anthropic),
ActiveProvider::OpenAI => Some(Self::OpenAI),
_ => None,
}
}
/// The execution slot this credential decision routes through.
pub const fn active_provider(self) -> ActiveProvider {
match self {
Self::Anthropic => ActiveProvider::Claude,
Self::OpenAI => ActiveProvider::OpenAI,
}
}
}
/// Which credential a dual-auth provider will actually use for a request.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum AuthMode {
/// OAuth / subscription login (Claude subscription, ChatGPT/Codex login).
Oauth,
/// Direct provider API key (metered / cost-based billing).
ApiKey,
}
impl AuthMode {
/// True when requests bill against a subscription rather than a metered key.
pub const fn is_subscription(self) -> bool {
matches!(self, Self::Oauth)
}
/// Map to the wire-level [`ResolvedCredential`] billing identity.
pub const fn resolved_credential(self) -> ResolvedCredential {
match self {
Self::Oauth => ResolvedCredential::Oauth,
Self::ApiKey => ResolvedCredential::ApiKey,
}
}
}
impl From<AuthMode> for ResolvedCredential {
fn from(mode: AuthMode) -> Self {
mode.resolved_credential()
}
}
/// A fully resolved dual-auth credential decision: *which provider* and *which
/// credential*. This is the structured value that every vocabulary string maps
/// to and is generated from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AuthRoute {
pub provider: DualAuthProvider,
pub mode: AuthMode,
}
impl AuthRoute {
pub const fn new(provider: DualAuthProvider, mode: AuthMode) -> Self {
Self { provider, mode }
}
pub const fn anthropic(mode: AuthMode) -> Self {
Self::new(DualAuthProvider::Anthropic, mode)
}
pub const fn openai(mode: AuthMode) -> Self {
Self::new(DualAuthProvider::OpenAI, mode)
}
/// Parse a dual-auth token from *any* of jcode's overlapping vocabularies
/// (runtime env, route stable-id, CLI `--provider`, or bare model prefix).
///
/// Returns `None` for tokens that do not pin a dual-auth credential route,
/// including bare aliases for non-dual providers (`openrouter`, `copilot`,
/// ...), unknown strings, and the empty string. A `None` result is what the
/// providers treat as "auto" (no explicit OAuth-vs-API pin).
///
/// A single trailing `:` is tolerated so callers can pass a model prefix
/// such as `claude-oauth:` directly. Full prefixed model specs
/// (`claude-oauth:model`) are *not* parsed here; resolve the prefix with
/// `explicit_model_provider_prefix` first.
pub fn parse(token: &str) -> Option<Self> {
let token = token.trim().strip_suffix(':').unwrap_or(token.trim());
match token.trim().to_ascii_lowercase().as_str() {
// Anthropic / Claude -- OAuth / subscription.
"claude" | "anthropic" | "claude-oauth" | "anthropic-oauth" => {
Some(Self::anthropic(AuthMode::Oauth))
}
// Anthropic / Claude -- direct API key.
//
// Bare `api-key` historically resolves to Anthropic in the route
// vocabulary (see `ModelRouteApiMethod::parse`), so keep that.
"claude-api" | "anthropic-api" | "anthropic-api-key" | "claude-api-key"
| "anthropic-key" | "claude-key" | "api-key" => Some(Self::anthropic(AuthMode::ApiKey)),
// OpenAI -- OAuth / ChatGPT-Codex login.
"openai" | "openai-oauth" => Some(Self::openai(AuthMode::Oauth)),
// OpenAI -- direct API key.
"openai-api" | "openai-api-key" | "openai-key" | "openai-apikey"
| "openai-platform" | "platform-openai" => Some(Self::openai(AuthMode::ApiKey)),
_ => None,
}
}
/// The execution slot this route runs through.
pub const fn active_provider(self) -> ActiveProvider {
self.provider.active_provider()
}
/// The wire-level billing identity for this route.
pub const fn resolved_credential(self) -> ResolvedCredential {
self.mode.resolved_credential()
}
/// Parse a *model prefix* that explicitly pins a dual-auth credential.
///
/// This differs from [`AuthRoute::parse`] in the bare-provider cases: in the
/// model-prefix vocabulary `claude:` / `anthropic:` / `openai:` mean "route
/// to this provider but keep the current credential (auto)", so they do NOT
/// pin a credential and return `None` here. Only the explicit credential
/// prefixes (`claude-oauth:`, `claude-api:`, `openai-oauth:`, `openai-api:`,
/// and their stable-id spellings) pin one.
///
/// A single trailing `:` is tolerated so callers can pass the raw prefix.
pub fn parse_explicit_credential_prefix(prefix: &str) -> Option<Self> {
let token = prefix.trim().strip_suffix(':').unwrap_or(prefix.trim());
match token.trim().to_ascii_lowercase().as_str() {
// Bare provider aliases do not pin a credential in this vocabulary.
"claude" | "anthropic" | "openai" => None,
other => Self::parse(other),
}
}
/// Canonical `JCODE_RUNTIME_PROVIDER` value that pins this route.
pub const fn runtime_provider_key(self) -> &'static str {
match (self.provider, self.mode) {
(DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude",
(DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api",
(DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai",
(DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api",
}
}
/// Canonical route `api_method` / [`crate::RuntimeKey`] stable-id.
pub const fn route_api_method(self) -> &'static str {
match (self.provider, self.mode) {
(DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth",
(DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api-key",
(DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth",
(DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api-key",
}
}
/// Canonical model-switch prefix (without the trailing colon).
pub const fn model_prefix(self) -> &'static str {
match (self.provider, self.mode) {
(DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude-oauth",
(DualAuthProvider::Anthropic, AuthMode::ApiKey) => "claude-api",
(DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai-oauth",
(DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api",
}
}
/// Canonical session `provider_key` (the folded, route-free form).
pub const fn session_provider_key(self) -> &'static str {
// Identical to the runtime-env vocabulary today; kept as its own method
// so the session-key meaning is explicit at call sites.
self.runtime_provider_key()
}
/// Canonical CLI `--provider` argument value.
pub const fn cli_provider_arg(self) -> &'static str {
match (self.provider, self.mode) {
(DualAuthProvider::Anthropic, AuthMode::Oauth) => "claude",
(DualAuthProvider::Anthropic, AuthMode::ApiKey) => "anthropic-api",
(DualAuthProvider::OpenAI, AuthMode::Oauth) => "openai",
(DualAuthProvider::OpenAI, AuthMode::ApiKey) => "openai-api",
}
}
}
/// Resolve the explicit dual-auth mode that `runtime_provider` pins for a
/// specific provider.
///
/// Returns `None` (i.e. "auto") when `runtime_provider` is absent, does not pin
/// a dual-auth route, or pins the *other* dual-auth provider.
pub fn pinned_mode_for(
provider: DualAuthProvider,
runtime_provider: Option<&str>,
) -> Option<AuthMode> {
let route = AuthRoute::parse(runtime_provider?)?;
(route.provider == provider).then_some(route.mode)
}
/// Read `JCODE_RUNTIME_PROVIDER` and return the dual-auth route it pins, if any.
pub fn runtime_env_auth_route() -> Option<AuthRoute> {
let value = std::env::var("JCODE_RUNTIME_PROVIDER").ok()?;
AuthRoute::parse(&value)
}
/// Read `JCODE_RUNTIME_PROVIDER` and resolve the dual-auth mode it pins for a
/// specific provider (or `None` for "auto").
pub fn runtime_env_pinned_mode(provider: DualAuthProvider) -> Option<AuthMode> {
pinned_mode_for(
provider,
std::env::var("JCODE_RUNTIME_PROVIDER").ok().as_deref(),
)
}
#[cfg(test)]
mod tests {
use super::*;
const ALL_ROUTES: [AuthRoute; 4] = [
AuthRoute::anthropic(AuthMode::Oauth),
AuthRoute::anthropic(AuthMode::ApiKey),
AuthRoute::openai(AuthMode::Oauth),
AuthRoute::openai(AuthMode::ApiKey),
];
#[test]
fn every_vocabulary_string_round_trips_back_to_the_same_route() {
for route in ALL_ROUTES {
for token in [
route.runtime_provider_key(),
route.route_api_method(),
route.model_prefix(),
route.cli_provider_arg(),
route.session_provider_key(),
] {
assert_eq!(
AuthRoute::parse(token),
Some(route),
"token {token:?} should parse back to {route:?}",
);
// Trailing-colon (model-prefix) form must parse identically.
assert_eq!(
AuthRoute::parse(&format!("{token}:")),
Some(route),
"token {token:?}: should parse back to {route:?}",
);
}
}
}
#[test]
fn parse_is_case_and_whitespace_insensitive() {
assert_eq!(
AuthRoute::parse(" Claude-OAuth "),
Some(AuthRoute::anthropic(AuthMode::Oauth))
);
assert_eq!(
AuthRoute::parse("ANTHROPIC-API-KEY"),
Some(AuthRoute::anthropic(AuthMode::ApiKey))
);
}
#[test]
fn bare_provider_aliases_pin_oauth() {
assert_eq!(
AuthRoute::parse("claude").map(|r| r.mode),
Some(AuthMode::Oauth)
);
assert_eq!(
AuthRoute::parse("anthropic").map(|r| r.mode),
Some(AuthMode::Oauth)
);
assert_eq!(
AuthRoute::parse("openai").map(|r| r.mode),
Some(AuthMode::Oauth)
);
}
#[test]
fn cross_vocabulary_aliases_resolve_consistently() {
// The whole point: route-vocabulary strings and runtime-env strings for
// the same concept resolve to the same structured route.
for (a, b) in [
("claude", "claude-oauth"),
("claude-api", "anthropic-api-key"),
("openai", "openai-oauth"),
("openai-api", "openai-api-key"),
] {
assert_eq!(
AuthRoute::parse(a),
AuthRoute::parse(b),
"{a:?} and {b:?} must resolve to the same route",
);
}
}
#[test]
fn non_dual_and_unknown_tokens_are_none() {
for token in [
"",
"openrouter",
"copilot",
"gemini",
"bedrock",
"jcode",
"nonsense",
] {
assert_eq!(AuthRoute::parse(token), None, "{token:?} must be None");
}
}
#[test]
fn explicit_credential_prefix_ignores_bare_provider_aliases() {
// Bare provider prefixes route without pinning a credential.
for token in ["claude", "claude:", "anthropic:", "openai", "openai:"] {
assert_eq!(
AuthRoute::parse_explicit_credential_prefix(token),
None,
"{token:?} must not pin a credential",
);
}
// Explicit credential prefixes still pin.
assert_eq!(
AuthRoute::parse_explicit_credential_prefix("claude-oauth:"),
Some(AuthRoute::anthropic(AuthMode::Oauth))
);
assert_eq!(
AuthRoute::parse_explicit_credential_prefix("claude-api:"),
Some(AuthRoute::anthropic(AuthMode::ApiKey))
);
assert_eq!(
AuthRoute::parse_explicit_credential_prefix("openai-oauth:"),
Some(AuthRoute::openai(AuthMode::Oauth))
);
assert_eq!(
AuthRoute::parse_explicit_credential_prefix("openai-api:"),
Some(AuthRoute::openai(AuthMode::ApiKey))
);
}
#[test]
fn pinned_mode_only_matches_its_own_provider() {
assert_eq!(
pinned_mode_for(DualAuthProvider::Anthropic, Some("claude-api")),
Some(AuthMode::ApiKey)
);
// A pin for the *other* dual-auth provider is "auto" here.
assert_eq!(
pinned_mode_for(DualAuthProvider::Anthropic, Some("openai")),
None
);
assert_eq!(
pinned_mode_for(DualAuthProvider::OpenAI, Some("claude")),
None
);
assert_eq!(pinned_mode_for(DualAuthProvider::OpenAI, None), None);
}
#[test]
fn resolved_credential_mapping() {
assert_eq!(
AuthMode::Oauth.resolved_credential(),
ResolvedCredential::Oauth
);
assert_eq!(
AuthMode::ApiKey.resolved_credential(),
ResolvedCredential::ApiKey
);
}
#[test]
fn route_api_method_round_trips_through_model_route_api_method() {
use crate::ModelRouteApiMethod;
// The route-vocabulary parser (`ModelRouteApiMethod`) must agree with the
// canonical auth-mode parser for every dual-auth route, so routing and
// billing never disagree about OAuth-vs-API-key.
for route in ALL_ROUTES {
let parsed = ModelRouteApiMethod::parse(route.route_api_method());
assert_eq!(
parsed,
ModelRouteApiMethod::from_auth_route(route),
"route {route:?} api_method must round-trip through ModelRouteApiMethod",
);
// And every alias vocabulary maps to the same ModelRouteApiMethod.
for token in [
route.runtime_provider_key(),
route.model_prefix(),
route.cli_provider_arg(),
route.session_provider_key(),
] {
assert_eq!(
ModelRouteApiMethod::parse(token),
parsed,
"token {token:?} must map to the same ModelRouteApiMethod as {route:?}",
);
}
}
}
}
@@ -0,0 +1,128 @@
use crate::ModelRoute;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
type CatalogRouteKey = (String, String, String);
type CatalogRouteSnapshot = (bool, String, Option<u64>);
type CatalogRouteMap = BTreeMap<CatalogRouteKey, CatalogRouteSnapshot>;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ModelCatalogRefreshSummary {
pub model_count_before: usize,
pub model_count_after: usize,
pub models_added: usize,
pub models_removed: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models_added_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models_removed_names: Vec<String>,
pub route_count_before: usize,
pub route_count_after: usize,
pub routes_added: usize,
pub routes_removed: usize,
pub routes_changed: usize,
}
pub fn summarize_model_catalog_refresh(
before_models: Vec<String>,
after_models: Vec<String>,
before_routes: Vec<ModelRoute>,
after_routes: Vec<ModelRoute>,
) -> ModelCatalogRefreshSummary {
fn is_display_only_age_suffix(detail: &str) -> bool {
let detail = detail.trim();
["m ago", "h ago", "d ago"]
.iter()
.find_map(|suffix| detail.strip_suffix(suffix))
.is_some_and(|prefix| !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()))
}
fn normalize_route_refresh_detail(detail: &str) -> String {
let detail = detail.trim();
if detail.is_empty() {
return String::new();
}
if is_display_only_age_suffix(detail) {
return String::new();
}
if let Some((prefix, suffix)) = detail.rsplit_once(',')
&& is_display_only_age_suffix(suffix)
{
return prefix.trim_end().trim_end_matches(',').trim().to_string();
}
detail.to_string()
}
let before_model_set: BTreeSet<String> = before_models.into_iter().collect();
let after_model_set: BTreeSet<String> = after_models.into_iter().collect();
let before_route_map: CatalogRouteMap = before_routes
.into_iter()
.map(|route| {
let estimated_cost = route.estimated_reference_cost_micros();
(
(route.model, route.provider, route.api_method),
(
route.available,
normalize_route_refresh_detail(&route.detail),
estimated_cost,
),
)
})
.collect();
let after_route_map: CatalogRouteMap = after_routes
.into_iter()
.map(|route| {
let estimated_cost = route.estimated_reference_cost_micros();
(
(route.model, route.provider, route.api_method),
(
route.available,
normalize_route_refresh_detail(&route.detail),
estimated_cost,
),
)
})
.collect();
let models_added = after_model_set.difference(&before_model_set).count();
let models_removed = before_model_set.difference(&after_model_set).count();
let models_added_names = after_model_set
.difference(&before_model_set)
.cloned()
.collect();
let models_removed_names = before_model_set
.difference(&after_model_set)
.cloned()
.collect();
let routes_added = after_route_map
.keys()
.filter(|key| !before_route_map.contains_key(*key))
.count();
let routes_removed = before_route_map
.keys()
.filter(|key| !after_route_map.contains_key(*key))
.count();
let routes_changed = after_route_map
.iter()
.filter(|(key, value)| {
before_route_map
.get(*key)
.is_some_and(|before| before != *value)
})
.count();
ModelCatalogRefreshSummary {
model_count_before: before_model_set.len(),
model_count_after: after_model_set.len(),
models_added,
models_removed,
models_added_names,
models_removed_names,
route_count_before: before_route_map.len(),
route_count_after: after_route_map.len(),
routes_added,
routes_removed,
routes_changed,
}
}
+182
View File
@@ -0,0 +1,182 @@
use serde::{Deserialize, Serialize};
const PROVIDER_FAILOVER_PROMPT_PREFIX: &str = "[jcode-provider-failover]";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProviderFailoverPrompt {
pub from_provider: String,
pub from_label: String,
pub to_provider: String,
pub to_label: String,
pub reason: String,
pub estimated_input_chars: usize,
pub estimated_input_tokens: usize,
}
impl ProviderFailoverPrompt {
pub fn to_error_message(&self) -> String {
let payload = serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string());
format!(
"{PROVIDER_FAILOVER_PROMPT_PREFIX}{payload}\n{} is unavailable; switching to {} would resend about {} input tokens (~{} chars).",
self.from_label, self.to_label, self.estimated_input_tokens, self.estimated_input_chars,
)
}
}
pub fn parse_failover_prompt_message(message: &str) -> Option<ProviderFailoverPrompt> {
let line = message.lines().next()?.trim();
let json = line.strip_prefix(PROVIDER_FAILOVER_PROMPT_PREFIX)?;
serde_json::from_str(json).ok()
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FailoverDecision {
None,
RetryNextProvider,
RetryAndMarkUnavailable,
}
impl FailoverDecision {
pub fn should_failover(self) -> bool {
!matches!(self, Self::None)
}
pub fn should_mark_provider_unavailable(self) -> bool {
matches!(self, Self::RetryAndMarkUnavailable)
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::RetryNextProvider => "retry-next-provider",
Self::RetryAndMarkUnavailable => "retry-and-mark-unavailable",
}
}
}
fn contains_independent_status_code(haystack: &str, code: &str) -> bool {
let haystack_bytes = haystack.as_bytes();
let code_len = code.len();
haystack.match_indices(code).any(|(start, _)| {
let before_ok = start == 0 || !haystack_bytes[start - 1].is_ascii_digit();
let end = start + code_len;
let after_ok = end == haystack_bytes.len() || !haystack_bytes[end].is_ascii_digit();
before_ok && after_ok
})
}
pub fn classify_failover_error_message(message: &str) -> FailoverDecision {
let lower = message.to_ascii_lowercase();
let request_size_or_context = [
"context length",
"context_length",
"context window",
"maximum context",
"prompt is too long",
"input is too long",
"too many tokens",
"max tokens",
"token limit",
"token_limit",
"413 payload too large",
"413 request entity too large",
]
.iter()
.any(|needle| lower.contains(needle))
|| contains_independent_status_code(&lower, "413");
if request_size_or_context {
return FailoverDecision::RetryNextProvider;
}
let rate_or_quota = [
"rate limit",
"rate-limited",
"too many requests",
"quota",
"credit balance",
"credits have run out",
"insufficient credit",
"billing",
"payment required",
"usage tier",
]
.iter()
.any(|needle| lower.contains(needle))
|| contains_independent_status_code(&lower, "429")
|| contains_independent_status_code(&lower, "402");
if rate_or_quota {
return FailoverDecision::RetryAndMarkUnavailable;
}
let auth_or_access = [
"access denied",
"not accessible by integration",
"provider unavailable",
"provider not available",
"provider is unavailable",
"provider currently unavailable",
"provider not configured",
"credentials are not configured",
"no credentials",
"token exchange failed",
"authentication failed",
"unauthorized",
"forbidden",
]
.iter()
.any(|needle| lower.contains(needle))
|| contains_independent_status_code(&lower, "401")
|| contains_independent_status_code(&lower, "403");
if auth_or_access {
return FailoverDecision::RetryAndMarkUnavailable;
}
FailoverDecision::None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failover_prompt_roundtrips_from_error_message() {
let prompt = ProviderFailoverPrompt {
from_provider: "claude".to_string(),
from_label: "Anthropic".to_string(),
to_provider: "openai".to_string(),
to_label: "OpenAI".to_string(),
reason: "rate limit".to_string(),
estimated_input_chars: 1200,
estimated_input_tokens: 300,
};
let parsed = parse_failover_prompt_message(&prompt.to_error_message()).expect("prompt");
assert_eq!(parsed, prompt);
}
#[test]
fn classifier_marks_rate_limits_unavailable() {
assert_eq!(
classify_failover_error_message("429 Too Many Requests"),
FailoverDecision::RetryAndMarkUnavailable
);
}
#[test]
fn classifier_retries_context_errors_without_marking_unavailable() {
assert_eq!(
classify_failover_error_message("context length exceeded"),
FailoverDecision::RetryNextProvider
);
}
#[test]
fn classifier_ignores_embedded_status_digits() {
assert_eq!(
classify_failover_error_message("model version 4130 failed"),
FailoverDecision::None
);
}
}
@@ -0,0 +1,362 @@
//! Pick the "next best" model route to fall back to after a provider error.
//!
//! When the active model/route fails (auth error, rate limit, provider outage,
//! a broken API key while an OAuth login still works, ...), the UI offers the
//! user a one-keypress switch to a usable alternative and resends the turn. This
//! module is the single, pure source of truth for *which* alternative to offer.
//!
//! It is intentionally provider-agnostic and side-effect free so it can be unit
//! tested exhaustively and reused by both the local and remote TUI paths.
use crate::{ModelRoute, ModelRouteApiMethod, model_route_provider_labels_match};
/// Whether an api_method authenticates via an OAuth / subscription login rather
/// than a metered API key. Subscription logins are preferred as fallbacks: they
/// are the most likely path to "just work" when an API key is broken/unfunded,
/// and they bill against a flat subscription instead of per-token cost.
fn api_method_is_oauth(api_method: &ModelRouteApiMethod) -> bool {
matches!(
api_method,
ModelRouteApiMethod::ClaudeOAuth
| ModelRouteApiMethod::OpenAIOAuth
| ModelRouteApiMethod::CodeAssistOAuth
)
}
fn models_match(a: &str, b: &str) -> bool {
a.trim().eq_ignore_ascii_case(b.trim())
}
fn api_methods_match(a: &str, b: &str) -> bool {
ModelRouteApiMethod::parse(a) == ModelRouteApiMethod::parse(b)
}
/// Extra context about the failure that triggered the fallback search.
#[derive(Debug, Clone, Copy, Default)]
pub struct FallbackPickOptions {
/// The failure was a credential/auth failure (expired OAuth session,
/// invalid API key, failed token refresh, ...). Every route that uses the
/// same credential is equally broken, so:
/// - when the failed api_method is known, all same-provider routes with
/// that method are excluded (not just the same model), and
/// - when the failed api_method is unknown, all same-provider routes are
/// excluded because any of them could share the broken credential.
pub credential_failure: bool,
}
/// Whether `error` looks like a credential/auth failure for the active route's
/// provider account (expired or revoked OAuth session, failed token refresh,
/// invalid API key). Used to widen the fallback exclusion set: a broken
/// credential breaks every model behind it, so offering a sibling model on the
/// same credential would fail identically.
pub fn error_looks_like_credential_failure(error: &str) -> bool {
let lower = error.to_ascii_lowercase();
let markers = [
"token refresh failed",
"refresh_token_invalidated",
"re-authenticate",
"session has ended",
"authentication_error",
"invalid_grant",
"invalid x-api-key",
"invalid api key",
"incorrect api key",
"api key not valid",
"token expired",
"unauthorized",
"oauth token expired",
"no refresh token",
"credentials have been revoked",
"please log in again",
"run /login",
];
markers.iter().any(|marker| lower.contains(marker))
}
/// Pick the index of the best alternative route to fall back to, given the
/// currently-selected route that just failed.
///
/// Ranking (lower tier wins; ties broken to keep the result stable and to
/// prefer subscription logins, then original catalog order):
///
/// 1. **Same model, different auth method** - e.g. the active `claude-api`
/// route's key is broken but a `claude-oauth` login for the *same* model is
/// available. This is the least disruptive switch (identical model, just a
/// different credential) and is exactly the case cross-provider failover
/// cannot currently handle.
/// 2. **Same provider, different model** - stay on the provider the user chose
/// when only a sibling model is usable.
/// 3. **Different provider** - last resort cross-provider hop.
///
/// Returns `None` when no *available* route other than the current one exists.
pub fn pick_next_fallback_route(
routes: &[ModelRoute],
current_model: &str,
current_provider: &str,
current_api_method: &str,
) -> Option<usize> {
pick_next_fallback_route_with_options(
routes,
current_model,
current_provider,
current_api_method,
FallbackPickOptions::default(),
)
}
/// [`pick_next_fallback_route`] with failure-classification options.
pub fn pick_next_fallback_route_with_options(
routes: &[ModelRoute],
current_model: &str,
current_provider: &str,
current_api_method: &str,
options: FallbackPickOptions,
) -> Option<usize> {
let unknown_method = current_api_method.trim().is_empty();
routes
.iter()
.enumerate()
.filter(|(_, route)| route.available)
.filter_map(|(index, route)| {
let same_model = models_match(&route.model, current_model);
let same_method = api_methods_match(&route.api_method, current_api_method);
let same_provider =
model_route_provider_labels_match(&route.provider, current_provider)
|| models_match(&route.provider, current_provider);
// Never offer the exact route that just failed. When the caller does
// not know which auth method the failed route used (empty
// `current_api_method`, e.g. a remote session without route
// bookkeeping), any same-model route on the same provider could be
// that exact failed route, so skip those too instead of offering
// the user a "fallback" that is guaranteed to fail identically.
if same_model && same_provider && (same_method || unknown_method) {
return None;
}
// A credential failure breaks every route that authenticates with
// the same credential, not just the failed model. Skip them all so
// the offer is a route that can plausibly work.
if options.credential_failure && same_provider && (same_method || unknown_method) {
return None;
}
let tier = if same_model && !same_method {
0
} else if same_provider {
1
} else {
2
};
// Within a tier, prefer subscription (OAuth) logins, then preserve
// the catalog's original ordering for determinism.
let prefers_oauth = u8::from(!api_method_is_oauth(&ModelRouteApiMethod::parse(
&route.api_method,
)));
Some((tier, prefers_oauth, index))
})
.min()
.map(|(_, _, index)| index)
}
#[cfg(test)]
mod tests {
use super::*;
fn route(model: &str, provider: &str, api_method: &str, available: bool) -> ModelRoute {
ModelRoute {
model: model.to_string(),
provider: provider.to_string(),
api_method: api_method.to_string(),
available,
detail: String::new(),
cheapness: None,
}
}
#[test]
fn prefers_same_model_oauth_when_api_key_broken() {
// The user's exact case: active Claude API-key route is broken, but the
// same Claude model is reachable via an OAuth login.
let routes = vec![
route("claude-sonnet-4", "Anthropic", "claude-api", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
route("gpt-5", "OpenAI", "openai-oauth", true),
];
let pick = pick_next_fallback_route(&routes, "claude-sonnet-4", "Anthropic", "claude-api")
.expect("a fallback should exist");
assert_eq!(routes[pick].api_method, "claude-oauth");
assert_eq!(routes[pick].model, "claude-sonnet-4");
}
#[test]
fn falls_back_to_same_provider_sibling_model() {
let routes = vec![
route("claude-opus-4", "Anthropic", "claude-oauth", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
];
// Active opus route failed and has no alternate method; offer the
// sibling Anthropic model rather than jumping providers.
let pick = pick_next_fallback_route(&routes, "claude-opus-4", "Anthropic", "claude-oauth")
.expect("a fallback should exist");
assert_eq!(routes[pick].model, "claude-sonnet-4");
assert_eq!(routes[pick].provider, "Anthropic");
}
#[test]
fn falls_back_cross_provider_as_last_resort() {
let routes = vec![
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
route("gpt-5", "OpenAI", "openai-oauth", true),
];
let pick =
pick_next_fallback_route(&routes, "claude-sonnet-4", "Anthropic", "claude-oauth")
.expect("a fallback should exist");
assert_eq!(routes[pick].provider, "OpenAI");
}
#[test]
fn skips_unavailable_routes() {
let routes = vec![
route("claude-sonnet-4", "Anthropic", "claude-api", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", false),
route("gpt-5", "OpenAI", "openai-oauth", false),
];
// The only same-model alt is unavailable and the cross-provider option
// is unavailable too, so there is nothing to offer.
assert!(
pick_next_fallback_route(&routes, "claude-sonnet-4", "Anthropic", "claude-api")
.is_none()
);
}
#[test]
fn returns_none_when_only_current_route_exists() {
let routes = vec![route("gpt-5", "OpenAI", "openai-oauth", true)];
assert!(pick_next_fallback_route(&routes, "gpt-5", "OpenAI", "openai-oauth").is_none());
}
#[test]
fn cross_provider_prefers_oauth_over_api_key() {
let routes = vec![
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
route("gpt-5", "OpenAI", "openai-api", true),
route("gpt-5", "OpenAI", "openai-oauth", true),
];
let pick =
pick_next_fallback_route(&routes, "claude-sonnet-4", "Anthropic", "claude-oauth")
.expect("a fallback should exist");
assert_eq!(routes[pick].provider, "OpenAI");
assert_eq!(routes[pick].api_method, "openai-oauth");
}
#[test]
fn unknown_method_never_offers_same_model_same_provider() {
// Remote session without route bookkeeping: the failed api_method is
// unknown, so a same-model/same-provider route could be the exact
// failed route and must not be offered.
let routes = vec![
route("gpt-5.5", "OpenAI", "openai-oauth", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
];
let pick = pick_next_fallback_route(&routes, "gpt-5.5", "OpenAI", "")
.expect("a fallback should exist");
assert_eq!(routes[pick].provider, "Anthropic");
}
#[test]
fn credential_failure_skips_sibling_models_on_same_credential() {
// The user's exact case: an expired OpenAI OAuth session. Every OpenAI
// OAuth model is equally broken, so offer another provider instead of
// a sibling model behind the same dead login.
let routes = vec![
route("gpt-5.5", "OpenAI", "openai-oauth", true),
route("gpt-5.4", "OpenAI", "openai-oauth", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
];
let options = FallbackPickOptions {
credential_failure: true,
};
let pick = pick_next_fallback_route_with_options(
&routes,
"gpt-5.5",
"OpenAI",
"openai-oauth",
options,
)
.expect("a fallback should exist");
assert_eq!(routes[pick].provider, "Anthropic");
}
#[test]
fn credential_failure_still_offers_other_method_same_provider() {
// A broken OAuth session does not implicate the API key: same-model
// different-method remains the best offer.
let routes = vec![
route("gpt-5.5", "OpenAI", "openai-oauth", true),
route("gpt-5.5", "OpenAI", "openai-api", true),
];
let options = FallbackPickOptions {
credential_failure: true,
};
let pick = pick_next_fallback_route_with_options(
&routes,
"gpt-5.5",
"OpenAI",
"openai-oauth",
options,
)
.expect("a fallback should exist");
assert_eq!(routes[pick].api_method, "openai-api");
}
#[test]
fn credential_failure_with_unknown_method_skips_whole_provider() {
// Unknown failed method + credential failure: any same-provider route
// could share the broken credential, so hop providers.
let routes = vec![
route("gpt-5.5", "OpenAI", "openai-oauth", true),
route("gpt-5.5", "OpenAI", "openai-api", true),
route("gpt-5.4", "OpenAI", "openai-oauth", true),
route("claude-sonnet-4", "Anthropic", "claude-oauth", true),
];
let options = FallbackPickOptions {
credential_failure: true,
};
let pick = pick_next_fallback_route_with_options(&routes, "gpt-5.5", "OpenAI", "", options)
.expect("a fallback should exist");
assert_eq!(routes[pick].provider, "Anthropic");
}
#[test]
fn classifies_credential_failures() {
assert!(error_looks_like_credential_failure(
"OpenAI token refresh failed; run /login to re-authenticate: refresh_token_invalidated"
));
assert!(error_looks_like_credential_failure(
"Your session has ended. Please log in again."
));
assert!(error_looks_like_credential_failure(
"Anthropic API error (401 Unauthorized): authentication_error invalid x-api-key"
));
// Observed auth-failure wave: expired OAuth + revoked refresh token.
// The wave detector depends on these exact shapes classifying true.
assert!(error_looks_like_credential_failure(
"task failed: Anthropic API error (401 Unauthorized)"
));
assert!(error_looks_like_credential_failure(
"invalid_grant: refresh token invalid"
));
assert!(error_looks_like_credential_failure(
"provider returned Unauthorized"
));
assert!(!error_looks_like_credential_failure(
"429 rate limit exceeded, retry after 30s"
));
assert!(!error_looks_like_credential_failure(
"500 internal server error"
));
}
}
@@ -0,0 +1,202 @@
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::time::Instant;
#[derive(Debug, Clone)]
struct ProviderInputSnapshot {
request_hash: u64,
item_hashes: Vec<u64>,
item_hashes_hash: u64,
system_hash: Option<u64>,
tools_hash: Option<u64>,
captured_at: Instant,
}
static PROVIDER_INPUT_BASELINES: LazyLock<Mutex<HashMap<String, ProviderInputSnapshot>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub fn stable_hash_str(value: &str) -> u64 {
let digest = Sha256::digest(value.as_bytes());
let mut bytes = [0_u8; 8];
bytes.copy_from_slice(&digest[..8]);
u64::from_be_bytes(bytes)
}
pub fn stable_hash_json<T: Serialize + ?Sized>(value: &T) -> u64 {
let encoded = serde_json::to_string(value).unwrap_or_default();
stable_hash_str(&encoded)
}
fn stable_json_len<T: Serialize + ?Sized>(value: &T) -> usize {
serde_json::to_string(value)
.map(|encoded| encoded.len())
.unwrap_or_default()
}
fn item_hashes(items: &[Value]) -> Vec<u64> {
items.iter().map(stable_hash_json).collect()
}
fn prefix_matches(current: &[u64], previous: &[u64]) -> bool {
if previous.len() > current.len() {
return false;
}
current[..previous.len()] == *previous
}
fn common_prefix_len(current: &[u64], previous: &[u64]) -> usize {
current
.iter()
.zip(previous.iter())
.take_while(|(current, previous)| current == previous)
.count()
}
/// Log a privacy-preserving fingerprint of the provider-specific prompt payload.
///
/// `payload` should be the prompt/cache-relevant request shape after provider-specific
/// normalization, not the high-level Jcode message list. Do not include volatile transport
/// IDs unless they are intentionally part of the cache key. `items` should be the ordered
/// provider-visible message/content array so prefix drift can be diagnosed by index.
#[allow(clippy::too_many_arguments)]
pub fn log_provider_canonical_input(
provider: &str,
model: &str,
format: &str,
payload: &Value,
items: &[Value],
system: Option<&Value>,
tools: Option<&Value>,
tool_count: Option<usize>,
extra_fields: &[(&str, String)],
) {
let request_hash = stable_hash_json(payload);
let request_json_chars = stable_json_len(payload);
let item_hashes = item_hashes(items);
let item_hashes_hash = stable_hash_json(&item_hashes);
let input_hash = stable_hash_json(items);
let system_hash = system.map(stable_hash_json);
let system_json_chars = system.map(stable_json_len);
let tools_hash = tools.map(stable_hash_json);
let tools_json_chars = tools.map(stable_json_len);
let first_item_hash = item_hashes.first().copied();
let last_item_hash = item_hashes.last().copied();
let log_context = jcode_logging::current_context_snapshot();
let session_key = log_context.session.as_deref().unwrap_or("no-session");
let key = format!(
"{}\u{1f}{}\u{1f}{}\u{1f}{}",
session_key, provider, model, format
);
let snapshot = ProviderInputSnapshot {
request_hash,
item_hashes: item_hashes.clone(),
item_hashes_hash,
system_hash,
tools_hash,
captured_at: Instant::now(),
};
let previous = PROVIDER_INPUT_BASELINES
.lock()
.map(|mut baselines| baselines.insert(key, snapshot))
.ok()
.flatten();
let previous_age_secs = previous
.as_ref()
.map(|previous| previous.captured_at.elapsed().as_secs());
let request_changed = previous
.as_ref()
.map(|previous| previous.request_hash != request_hash);
let item_hashes_changed = previous
.as_ref()
.map(|previous| previous.item_hashes_hash != item_hashes_hash);
let prefix_matches = previous
.as_ref()
.map(|previous| prefix_matches(&item_hashes, &previous.item_hashes));
let common_prefix_items = previous
.as_ref()
.map(|previous| common_prefix_len(&item_hashes, &previous.item_hashes));
let first_changed_item_index = common_prefix_items
.zip(previous.as_ref().map(|previous| previous.item_hashes.len()))
.and_then(|(common, previous_len)| (common < previous_len).then_some(common));
let previous_item_count = previous.as_ref().map(|previous| previous.item_hashes.len());
let system_changed = previous
.as_ref()
.map(|previous| previous.system_hash != system_hash);
let tools_changed = previous
.as_ref()
.map(|previous| previous.tools_hash != tools_hash);
let mut extras = String::new();
for (key, value) in extra_fields {
if !key.is_empty() && !value.is_empty() {
extras.push(' ');
extras.push_str(key);
extras.push('=');
extras.push_str(value);
}
}
jcode_logging::info(&format!(
"PROVIDER_CANONICAL_INPUT: provider={} model={} format={} request_hash={} request_json_chars={} \
input_hash={} item_count={} previous_item_count={:?} item_hashes_hash={} first_item_hash={:?} last_item_hash={:?} \
previous_age_secs={:?} prefix_matches={:?} common_prefix_items={:?} first_changed_item_index={:?} \
request_changed={:?} item_hashes_changed={:?} system_hash={:?} system_json_chars={:?} system_changed={:?} \
tools_hash={:?} tools_json_chars={:?} tool_count={:?} tools_changed={:?}{}",
provider,
model,
format,
request_hash,
request_json_chars,
input_hash,
items.len(),
previous_item_count,
item_hashes_hash,
first_item_hash,
last_item_hash,
previous_age_secs,
prefix_matches,
common_prefix_items,
first_changed_item_index,
request_changed,
item_hashes_changed,
system_hash,
system_json_chars,
system_changed,
tools_hash,
tools_json_chars,
tool_count,
tools_changed,
extras,
));
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn prefix_matching_allows_append_only_growth() {
assert!(prefix_matches(&[1, 2, 3], &[1, 2]));
}
#[test]
fn prefix_matching_detects_changed_prefix() {
assert!(!prefix_matches(&[1, 9, 3], &[1, 2]));
assert_eq!(common_prefix_len(&[1, 9, 3], &[1, 2]), 1);
}
#[test]
fn json_hashes_are_content_sensitive() {
assert_ne!(
stable_hash_json(&json!({"a": 1})),
stable_hash_json(&json!({"a": 2}))
);
}
}
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
//! Canonical model-id normalization.
//!
//! Model ids arrive in many shapes: mixed case, with the Anthropic `[1m]`
//! long-context suffix, with dated releases (`claude-haiku-4-5-20251001`),
//! or provider-qualified (`openrouter/anthropic/claude-...`). Historically
//! each subsystem (catalog, pricing, capability lookup, auth preferences,
//! subscription matching) hand-rolled its own partial normalization, which
//! made the same model compare unequal across layers.
//!
//! This module is the single home for those operations. Subsystems may
//! still compose them differently (pricing keeps case, subscription
//! matching splits `@`), but the primitive transforms live here.
/// Anthropic long-context opt-in suffix.
pub const LONG_CONTEXT_SUFFIX: &str = "[1m]";
/// Strip the `[1m]` long-context suffix, if present.
pub fn strip_long_context_suffix(model: &str) -> &str {
model.strip_suffix(LONG_CONTEXT_SUFFIX).unwrap_or(model)
}
/// Split a model id into its base id and whether it carried the `[1m]`
/// long-context suffix.
pub fn split_long_context(model: &str) -> (&str, bool) {
match model.strip_suffix(LONG_CONTEXT_SUFFIX) {
Some(base) => (base, true),
None => (model, false),
}
}
/// Canonical lowercase key: trimmed, ASCII-lowercased, `[1m]` stripped.
///
/// Use this whenever model ids are compared or used as map keys across
/// subsystem boundaries.
pub fn canonical(model: &str) -> String {
let normalized = model.trim().to_ascii_lowercase();
strip_long_context_suffix(&normalized).to_string()
}
/// Strip a trailing 8-digit `-YYYYMMDD` release date so dated ids
/// (`claude-haiku-4-5-20251001`) match bare canonical ids
/// (`claude-haiku-4-5`).
pub fn strip_date_suffix(model: &str) -> &str {
match model.rsplit_once('-') {
Some((head, tail)) if tail.len() == 8 && tail.bytes().all(|byte| byte.is_ascii_digit()) => {
head
}
_ => model,
}
}
/// Final path segment of a slash-qualified id
/// (`openrouter/anthropic/claude-x` -> `claude-x`). Ids without `/` are
/// returned unchanged.
pub fn slash_base(model: &str) -> &str {
model.rsplit('/').next().unwrap_or(model)
}
/// Case-insensitive membership test against a static known-model list,
/// ignoring the `[1m]` suffix on the probe side only (the list may contain
/// explicit `[1m]` entries which are matched exactly first).
pub fn matches_known_model(model: &str, known: &[&str]) -> bool {
let trimmed = model.trim();
if known
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(trimmed))
{
return true;
}
let base = strip_long_context_suffix(trimmed);
known
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(base))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_lowercases_trims_and_strips_long_context() {
assert_eq!(canonical(" Claude-Sonnet-4-6[1m] "), "claude-sonnet-4-6");
assert_eq!(canonical("gpt-5.3-codex"), "gpt-5.3-codex");
}
#[test]
fn split_long_context_reports_suffix() {
assert_eq!(
split_long_context("claude-opus-4-6[1m]"),
("claude-opus-4-6", true)
);
assert_eq!(
split_long_context("claude-opus-4-6"),
("claude-opus-4-6", false)
);
}
#[test]
fn strip_date_suffix_only_strips_8_digit_dates() {
assert_eq!(
strip_date_suffix("claude-haiku-4-5-20251001"),
"claude-haiku-4-5"
);
assert_eq!(strip_date_suffix("claude-haiku-4-5"), "claude-haiku-4-5");
assert_eq!(strip_date_suffix("gpt-4-1106"), "gpt-4-1106");
}
#[test]
fn slash_base_takes_last_segment() {
assert_eq!(
slash_base("anthropic/claude-sonnet-4-6"),
"claude-sonnet-4-6"
);
assert_eq!(slash_base("claude-sonnet-4-6"), "claude-sonnet-4-6");
}
#[test]
fn matches_known_model_is_case_insensitive_and_1m_tolerant() {
let known = ["gpt-5.3-codex", "claude-opus-4-6[1m]"];
assert!(matches_known_model("GPT-5.3-Codex", &known));
assert!(matches_known_model("claude-opus-4-6[1m]", &known));
// probe with [1m] matches the bare known id too
assert!(matches_known_model("gpt-5.3-codex[1m]", &known));
assert!(!matches_known_model("gpt-4o", &known));
}
}
+532
View File
@@ -0,0 +1,532 @@
/// Available Claude models used by model lists and provider routing.
///
/// NOTE: The Mythos preview family was retired by Anthropic and 404s, so it is
/// intentionally NOT listed here. `claude-fable-5` was briefly retired but is
/// live again. The list is curated best-first; position 0 is the flagship
/// used for post-login default selection.
pub const ALL_CLAUDE_MODELS: &[&str] = &[
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-6",
"claude-opus-4-6[1m]",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-sonnet-4-6[1m]",
"claude-haiku-4-5",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-sonnet-4-20250514",
];
/// Available OpenAI models used by model lists and provider routing.
pub const ALL_OPENAI_MODELS: &[&str] = &[
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2-chat-latest",
"gpt-5.2-codex",
"gpt-5.2-pro",
"gpt-5.1-codex-mini",
"gpt-5.1-codex-max",
"gpt-5.2",
"gpt-5.1-chat-latest",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5-chat-latest",
"gpt-5-codex",
"gpt-5-codex-mini",
"gpt-5-pro",
"gpt-5-mini",
"gpt-5-nano",
"gpt-5",
];
/// Default context window size when model-specific data isn't known.
pub const DEFAULT_CONTEXT_LIMIT: usize = 200_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCapabilities {
pub provider: Option<String>,
pub context_window: Option<usize>,
}
fn normalize_provider_id(provider: &str) -> String {
provider.trim().to_ascii_lowercase()
}
pub fn provider_key_from_hint(provider_hint: Option<&str>) -> Option<&'static str> {
let normalized = normalize_provider_id(provider_hint?);
match normalized.as_str() {
"anthropic" | "claude" => Some("claude"),
"openai" => Some("openai"),
"openrouter" => Some("openrouter"),
"copilot" | "github copilot" => Some("copilot"),
"antigravity" => Some("antigravity"),
"gemini" | "google gemini" => Some("gemini"),
"cursor" => Some("cursor"),
_ => None,
}
}
pub fn is_listable_model_name(model: &str) -> bool {
let trimmed = model.trim();
!trimmed.is_empty() && !matches!(trimmed, "copilot models" | "openrouter models")
}
fn model_id_for_capability_lookup(model: &str, provider: Option<&str>) -> (String, bool) {
let normalized = model.trim().to_ascii_lowercase();
let (base, is_1m) = crate::model_id::split_long_context(&normalized);
let lookup = if matches!(provider, Some("openrouter")) || base.contains('/') {
crate::model_id::slash_base(base).to_string()
} else {
base.to_string()
};
(lookup, is_1m)
}
fn copilot_context_limit_for_model(model: &str) -> usize {
match model {
"claude-sonnet-4" | "claude-sonnet-4-6" | "claude-sonnet-4.6" => 128_000,
"claude-opus-4-6" | "claude-opus-4.6" | "claude-opus-4.6-fast" => 200_000,
"claude-opus-4.5" | "claude-opus-4-5" => 200_000,
"claude-sonnet-4.5" | "claude-sonnet-4-5" => 200_000,
"claude-haiku-4.5" | "claude-haiku-4-5" => 200_000,
"gpt-4o" | "gpt-4o-mini" => 128_000,
m if m.starts_with("gpt-4o") => 128_000,
m if m.starts_with("gpt-4.1") => 128_000,
m if m.starts_with("gpt-5") => 128_000,
"o3-mini" | "o4-mini" => 128_000,
m if m.starts_with("gemini-2.0-flash") => 1_000_000,
m if m.starts_with("gemini-2.5") => 1_000_000,
m if m.starts_with("gemini-3") => 1_000_000,
_ => 128_000,
}
}
/// Return the static provider class for a built-in model name.
///
/// Root providers may layer runtime-only provider catalogs on top of this.
pub fn provider_for_model_with_hint(
model: &str,
provider_hint: Option<&str>,
) -> Option<&'static str> {
if let Some(provider) = provider_key_from_hint(provider_hint) {
return Some(provider);
}
let model = model.trim();
if model.contains('@') {
Some("openrouter")
} else if ALL_CLAUDE_MODELS.contains(&model) {
Some("claude")
} else if ALL_OPENAI_MODELS.contains(&model) {
Some("openai")
} else if model.contains('/') {
Some("openrouter")
} else if model.starts_with("claude-") {
Some("claude")
} else if model.starts_with("gpt-") {
Some("openai")
} else if model.starts_with("gemini-") {
Some("gemini")
} else {
None
}
}
pub fn provider_for_model(model: &str) -> Option<&'static str> {
provider_for_model_with_hint(model, None)
}
/// Whether `model` resolves to a Claude family jcode classifies statically
/// (i.e. one whose long-context behavior we have verified). Matches by family
/// prefix so dated aliases (`claude-opus-4-5-20251101`) and `[1m]` suffixes are
/// covered, while unknown/future Claude ids fall through to the dynamic cache.
fn base_is_known_claude_model(base: &str) -> bool {
const KNOWN_CLAUDE_PREFIXES: &[&str] = &[
"claude-opus-4-8",
"claude-opus-4.8",
"claude-opus-4-7",
"claude-opus-4.7",
"claude-opus-4-6",
"claude-opus-4.6",
"claude-opus-4-5",
"claude-opus-4.5",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-sonnet-4.6",
"claude-sonnet-4-5",
"claude-sonnet-4.5",
"claude-haiku-4-5",
"claude-haiku-4.5",
// Fable 5 is a native-1M flagship (see `anthropic_context_mode`). It is
// not in `ALL_CLAUDE_MODELS` because Anthropic retired its public id (it
// 404s and is served as Opus 4.8), but sessions can still be pinned to
// it, so it must classify as 1M instead of falling through to 200K.
"claude-fable-5",
"claude-fable",
];
KNOWN_CLAUDE_PREFIXES
.iter()
.any(|prefix| base.starts_with(prefix))
}
pub fn context_limit_for_model_with_provider_and_cache(
model: &str,
provider_hint: Option<&str>,
cached_context_limit: impl Fn(&str) -> Option<usize>,
) -> Option<usize> {
let provider = provider_key_from_hint(provider_hint).or_else(|| provider_for_model(model));
let (model, is_1m) = model_id_for_capability_lookup(model, provider);
let model = model.as_str();
if matches!(provider, Some("copilot")) {
return Some(copilot_context_limit_for_model(model));
}
// Spark variant has a smaller context window than the full codex model.
if model.starts_with("gpt-5.3-codex-spark") {
return Some(128_000);
}
if model.starts_with("gpt-5.2-chat")
|| model.starts_with("gpt-5.1-chat")
|| model.starts_with("gpt-5-chat")
{
return Some(128_000);
}
// GPT-5.4-family models should default to the long-context window.
// The live Codex OAuth catalog can still override this via the dynamic cache above.
if model.starts_with("gpt-5.4") {
return Some(1_000_000);
}
// Most GPT-5.x codex/reasoning models: 272k per Codex backend API.
if model.starts_with("gpt-5") {
return Some(272_000);
}
// Claude models: classify long-context behavior centrally. This is the
// authoritative source for known Claude models because the live catalog's
// `max_input_tokens` field over-advertises 1M for models that are actually
// 200K-capped (verified against the live API). Unknown/future Claude models
// fall through to the dynamic cache below.
if base_is_known_claude_model(model) {
let mode = crate::anthropic::anthropic_context_mode(model);
return Some(if is_1m {
mode.long_context_window()
} else {
mode.default_context_window()
});
}
if let Some(limit) = cached_context_limit(model) {
return Some(limit);
}
if model.starts_with("gemini-2.0-flash")
|| model.starts_with("gemini-2.5")
|| model.starts_with("gemini-3")
{
return Some(1_000_000);
}
// Open-weight model families served by many OpenAI-compatible gateways
// (Z.AI, Moonshot, MiniMax, Alibaba, etc.). Their `/v1/models` endpoints
// frequently omit `context_length`, so without this classifier these models
// fall back to the generic 200K default even when their real window is
// larger (e.g. GLM-5.2's 1M). This is checked AFTER the dynamic cache so a
// live catalog or user `context_window` config always wins.
if let Some(limit) = open_weight_family_context_limit(model) {
return Some(limit);
}
None
}
/// Best-effort context window for well-known open-weight model families.
///
/// Keyed on the canonical (lowercased, slash-stripped) model id so the same
/// family resolves consistently regardless of which gateway serves it and how
/// it spells version numbers (`glm-4.7`, `glm-47`, `glm-4p7`). Values reflect
/// each family's published context window; a live `/v1/models` catalog or an
/// explicit user `context_window` config overrides these upstream.
pub fn open_weight_family_context_limit(model: &str) -> Option<usize> {
let m = model;
// --- Z.AI GLM family ---
if m.contains("glm") {
// GLM-5.2: first GLM with a truly usable 1M-token context window.
if m.contains("glm-5.2") || m.contains("glm-52") || m.contains("glm-5p2") {
return Some(1_000_000);
}
// GLM-5 / GLM-5.1 and GLM-4.6 / GLM-4.7: 200K context.
if m.contains("glm-5")
|| m.contains("glm-4.7")
|| m.contains("glm-47")
|| m.contains("glm-4p7")
|| m.contains("glm-4-7")
|| m.contains("glm-4.6")
|| m.contains("glm-46")
|| m.contains("glm-4p6")
{
return Some(200_000);
}
// GLM-4.5 and earlier GLM-4: 128K context.
if m.contains("glm-4") {
return Some(128_000);
}
}
// --- DeepSeek (check V4 before V3 so the more specific match wins) ---
if m.contains("deepseek-v4") {
return Some(1_000_000);
}
if m.contains("deepseek-v3.2") || m.contains("deepseek-v3p2") || m.contains("deepseek-v3-2") {
return Some(163_840);
}
if m.contains("deepseek-v3") {
return Some(131_072);
}
// --- Moonshot Kimi K2 family: 256K context ---
if m.contains("kimi") {
return Some(262_144);
}
// --- MiniMax M2 family: 204,800 context ---
if m.contains("minimax") {
return Some(204_800);
}
// --- Xiaomi MiMo V2 family: 256K context ---
if m.contains("mimo") {
return Some(262_144);
}
// --- Alibaba GTE-Qwen2 retrieval models: 32K context ---
if m.contains("gte-qwen") {
return Some(32_768);
}
// --- Alibaba Qwen3 / Qwen3.5 family: 256K context ---
if m.contains("qwen3") || m.contains("qwen-3") {
return Some(262_144);
}
// --- OpenAI gpt-oss open weights: 131K context ---
if m.contains("gpt-oss") {
return Some(131_072);
}
// --- Meta Llama 3.x: 128K context ---
if m.contains("llama-3") {
return Some(131_072);
}
// --- Nous Hermes 4 (Llama-based): 128K context ---
if m.contains("hermes-4") {
return Some(131_072);
}
// --- Google Gemma 3: 128K context ---
if m.contains("gemma-3") {
return Some(131_072);
}
// --- Mistral small 3.x: 128K context ---
if m.contains("mistral-small-3") {
return Some(131_072);
}
// --- xAI grok-code-fast: 256K context ---
if m.contains("grok-code-fast") {
return Some(256_000);
}
// --- Perplexity Sonar: 128K context ---
if m.contains("sonar") {
return Some(128_000);
}
None
}
pub fn context_limit_for_model_with_provider(
model: &str,
provider_hint: Option<&str>,
) -> Option<usize> {
context_limit_for_model_with_provider_and_cache(model, provider_hint, |_| None)
}
pub fn context_limit_for_model(model: &str) -> Option<usize> {
context_limit_for_model_with_provider(model, None)
}
/// Normalize a Copilot-style model name to the canonical form used by our
/// provider model lists. Copilot uses dots in version numbers (e.g.
/// `claude-opus-4.6`) while canonical lists use hyphens (`claude-opus-4-6`).
/// Returns None if no normalization is needed (model already canonical or unknown).
pub fn normalize_copilot_model_name(model: &str) -> Option<&'static str> {
for canonical in ALL_CLAUDE_MODELS.iter().chain(ALL_OPENAI_MODELS.iter()) {
if *canonical == model {
return None;
}
}
let normalized = model.replace('.', "-");
ALL_CLAUDE_MODELS
.iter()
.chain(ALL_OPENAI_MODELS.iter())
.find(|canonical| **canonical == normalized)
.copied()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_limit_handles_claude_1m_aliases() {
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-6[1m]", Some("claude")),
Some(1_048_576)
);
assert_eq!(
context_limit_for_model_with_provider("claude-sonnet-4.6", Some("claude")),
Some(200_000)
);
}
#[test]
fn context_limit_classifies_claude_by_context_mode() {
// Native-1M: 1M by default, suffix is a no-op.
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-8", Some("claude")),
Some(1_000_000)
);
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-8[1m]", Some("claude")),
Some(1_000_000)
);
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-7", Some("claude")),
Some(1_000_000)
);
// Opt-in 1M: 200K by default, 1M only via the [1m] suffix.
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-6", Some("claude")),
Some(200_000)
);
// Standard: 200K, even though the live catalog over-advertises 1M for it.
assert_eq!(
context_limit_for_model_with_provider("claude-sonnet-4-5", Some("claude")),
Some(200_000)
);
assert_eq!(
context_limit_for_model_with_provider("claude-opus-4-5", Some("claude")),
Some(200_000)
);
assert_eq!(
context_limit_for_model_with_provider("claude-haiku-4-5", Some("claude")),
Some(200_000)
);
}
#[test]
fn context_limit_classifies_retired_fable_as_native_1m() {
// `claude-fable-5` is a native-1M flagship. Even though Anthropic retired
// its public id, sessions pinned to it must report 1M, not the 200K
// default that would result from falling through the known-model gate.
assert_eq!(
context_limit_for_model_with_provider("claude-fable-5", Some("claude")),
Some(1_000_000)
);
assert_eq!(
context_limit_for_model_with_provider("claude-fable-5[1m]", Some("claude")),
Some(1_000_000)
);
}
#[test]
fn anthropic_context_mode_classifications() {
use crate::anthropic::{AnthropicContextMode, anthropic_context_mode};
assert_eq!(
anthropic_context_mode("claude-opus-4-8"),
AnthropicContextMode::Native1M
);
assert_eq!(
anthropic_context_mode("claude-opus-4-8[1m]"),
AnthropicContextMode::Native1M
);
assert_eq!(
anthropic_context_mode("claude-opus-4-7"),
AnthropicContextMode::Native1M
);
assert_eq!(
anthropic_context_mode("claude-opus-4-6"),
AnthropicContextMode::OptIn1M
);
// Sonnet 5 is native 1M: 1M is both the default and the maximum
// (issue #450).
assert_eq!(
anthropic_context_mode("claude-sonnet-5"),
AnthropicContextMode::Native1M
);
assert_eq!(
anthropic_context_mode("claude-sonnet-5-20260701"),
AnthropicContextMode::Native1M
);
assert_eq!(
anthropic_context_mode("claude-sonnet-4-6"),
AnthropicContextMode::OptIn1M
);
assert_eq!(
anthropic_context_mode("claude-sonnet-4-5"),
AnthropicContextMode::Standard
);
assert_eq!(
anthropic_context_mode("claude-opus-4-5"),
AnthropicContextMode::Standard
);
// Only opt-in models surface a [1m] picker alias.
assert!(!anthropic_context_mode("claude-opus-4-8").exposes_1m_alias());
assert!(anthropic_context_mode("claude-opus-4-6").exposes_1m_alias());
assert!(!anthropic_context_mode("claude-sonnet-4-5").exposes_1m_alias());
}
#[test]
fn context_limit_handles_copilot_hint() {
assert_eq!(
context_limit_for_model_with_provider("gpt-5.4", Some("copilot")),
Some(128_000)
);
assert_eq!(
context_limit_for_model_with_provider("gemini-2.5-pro", Some("copilot")),
Some(1_000_000)
);
}
#[test]
fn context_limit_uses_cache_for_unknown_models() {
assert_eq!(
context_limit_for_model_with_provider_and_cache("custom-model", None, |model| {
(model == "custom-model").then_some(42_000)
}),
Some(42_000)
);
}
#[test]
fn normalizes_copilot_model_names() {
assert_eq!(
normalize_copilot_model_name("claude-opus-4.6"),
Some("claude-opus-4-6")
);
assert_eq!(normalize_copilot_model_name("claude-opus-4-6"), None);
}
}
@@ -0,0 +1,506 @@
use serde_json::Value;
use std::collections::HashSet;
fn merge_string_sets(existing: &Value, incoming: &Value) -> Option<Value> {
fn collect_strings(value: &Value) -> Option<Vec<String>> {
match value {
Value::String(s) => Some(vec![s.clone()]),
Value::Array(items) => items
.iter()
.map(|item| item.as_str().map(ToString::to_string))
.collect(),
_ => None,
}
}
let mut combined = collect_strings(existing)?;
for item in collect_strings(incoming)? {
if !combined.contains(&item) {
combined.push(item);
}
}
if combined.len() == 1 {
Some(Value::String(combined.remove(0)))
} else {
Some(Value::Array(
combined.into_iter().map(Value::String).collect(),
))
}
}
fn merge_schema_objects(
target: &mut serde_json::Map<String, Value>,
incoming: &serde_json::Map<String, Value>,
) {
for (key, incoming_value) in incoming {
match key.as_str() {
"properties" | "$defs" | "definitions" | "patternProperties" => {
let Some(incoming_children) = incoming_value.as_object() else {
target.insert(key.clone(), incoming_value.clone());
continue;
};
match target.get_mut(key) {
Some(Value::Object(existing_children)) => {
for (child_key, child_value) in incoming_children {
if let Some(existing_child) = existing_children.get_mut(child_key) {
merge_schema_values(existing_child, child_value.clone());
} else {
existing_children.insert(child_key.clone(), child_value.clone());
}
}
}
_ => {
target.insert(key.clone(), Value::Object(incoming_children.clone()));
}
}
}
"required" | "enum" | "type" => match target.get_mut(key) {
Some(existing_value) => {
if let Some(merged) = merge_string_sets(existing_value, incoming_value) {
*existing_value = merged;
}
}
None => {
target.insert(key.clone(), incoming_value.clone());
}
},
"description" | "title" => {
target
.entry(key.clone())
.or_insert_with(|| incoming_value.clone());
}
"additionalProperties" => match target.get_mut(key) {
Some(Value::Bool(existing_bool)) => {
if incoming_value == &Value::Bool(false) {
*existing_bool = false;
}
}
Some(Value::Object(existing_obj)) => {
if let Value::Object(incoming_obj) = incoming_value {
merge_schema_objects(existing_obj, incoming_obj);
} else if incoming_value == &Value::Bool(false) {
target.insert(key.clone(), Value::Bool(false));
}
}
Some(_) => {
if incoming_value == &Value::Bool(false) {
target.insert(key.clone(), Value::Bool(false));
}
}
None => {
target.insert(key.clone(), incoming_value.clone());
}
},
_ => match target.get_mut(key) {
Some(existing_value) => merge_schema_values(existing_value, incoming_value.clone()),
None => {
target.insert(key.clone(), incoming_value.clone());
}
},
}
}
}
fn merge_schema_values(existing: &mut Value, incoming: Value) {
if *existing == incoming {
return;
}
match incoming {
Value::Object(incoming_map) => {
if let Value::Object(existing_map) = existing {
merge_schema_objects(existing_map, &incoming_map);
} else {
*existing = Value::Object(incoming_map);
}
}
Value::Array(incoming_items) => {
if let Value::Array(existing_items) = existing {
if existing_items != &incoming_items {
for item in incoming_items {
if !existing_items.contains(&item) {
existing_items.push(item);
}
}
}
} else {
*existing = Value::Array(incoming_items);
}
}
incoming_value => {
*existing = incoming_value;
}
}
}
fn flatten_all_of_schema(mut map: serde_json::Map<String, Value>) -> Value {
let Some(Value::Array(all_of_items)) = map.remove("allOf") else {
return Value::Object(map);
};
let mut merged = map;
let mut fallback_any_of = Vec::new();
for item in all_of_items {
match item {
Value::Object(item_map) => merge_schema_objects(&mut merged, &item_map),
other => fallback_any_of.push(other),
}
}
if !fallback_any_of.is_empty() {
match merged.get_mut("anyOf") {
Some(Value::Array(existing_any_of)) => existing_any_of.extend(fallback_any_of),
_ => {
merged.insert("anyOf".to_string(), Value::Array(fallback_any_of));
}
}
}
Value::Object(merged)
}
pub fn openai_compatible_schema(schema: &Value) -> Value {
match schema {
Value::Object(map) => {
let mut out = serde_json::Map::new();
for (key, value) in map {
let normalized_key = if key == "oneOf" { "anyOf" } else { key };
out.insert(normalized_key.to_string(), openai_compatible_schema(value));
}
flatten_all_of_schema(out)
}
Value::Array(items) => Value::Array(items.iter().map(openai_compatible_schema).collect()),
_ => schema.clone(),
}
}
pub fn schema_supports_strict(schema: &Value) -> bool {
fn check_map(map: &serde_json::Map<String, Value>) -> bool {
let is_object_typed = match map.get("type") {
Some(Value::String(t)) => t == "object",
Some(Value::Array(types)) => types.iter().any(|v| v.as_str() == Some("object")),
_ => false,
};
let has_properties = map
.get("properties")
.and_then(|v| v.as_object())
.map(|props| !props.is_empty())
.unwrap_or(false);
if is_object_typed && !has_properties {
return false;
}
if is_object_typed {
if matches!(map.get("additionalProperties"), Some(Value::Bool(true))) {
return false;
}
if matches!(map.get("additionalProperties"), Some(Value::Object(_))) {
return false;
}
}
map.values().all(schema_supports_strict)
}
match schema {
Value::Object(map) => check_map(map),
Value::Array(items) => items.iter().all(schema_supports_strict),
_ => true,
}
}
fn schema_is_object_typed(map: &serde_json::Map<String, Value>) -> bool {
match map.get("type") {
Some(Value::String(t)) => t == "object",
Some(Value::Array(types)) => types.iter().any(|v| v.as_str() == Some("object")),
_ => false,
}
}
fn schema_contains_null_type(schema: &Value) -> bool {
schema
.get("type")
.and_then(Value::as_str)
.map(|ty| ty == "null")
.unwrap_or(false)
}
pub fn make_schema_nullable(schema: Value) -> Value {
match schema {
Value::Object(mut map) => {
if let Some(Value::String(t)) = map.get("type").cloned() {
if t != "null" {
map.insert(
"type".to_string(),
Value::Array(vec![Value::String(t), Value::String("null".to_string())]),
);
}
return Value::Object(map);
}
if let Some(Value::Array(mut types)) = map.get("type").cloned() {
if !types.iter().any(|v| v.as_str() == Some("null")) {
types.push(Value::String("null".to_string()));
}
map.insert("type".to_string(), Value::Array(types));
return Value::Object(map);
}
if let Some(Value::Array(mut any_of)) = map.get("anyOf").cloned() {
if !any_of.iter().any(schema_contains_null_type) {
any_of.push(serde_json::json!({ "type": "null" }));
}
map.insert("anyOf".to_string(), Value::Array(any_of));
return Value::Object(map);
}
serde_json::json!({
"anyOf": [
Value::Object(map),
{ "type": "null" }
]
})
}
other => serde_json::json!({
"anyOf": [
other,
{ "type": "null" }
]
}),
}
}
fn normalize_strict_schema_keyword(key: &str, value: &Value) -> Value {
match key {
"properties" | "$defs" | "definitions" | "patternProperties" => match value {
Value::Object(children) => Value::Object(
children
.iter()
.map(|(child_key, child_value)| {
(child_key.clone(), strict_normalize_schema(child_value))
})
.collect(),
),
_ => strict_normalize_schema(value),
},
"allOf" | "anyOf" | "oneOf" | "prefixItems" => match value {
Value::Array(items) => {
Value::Array(items.iter().map(strict_normalize_schema).collect())
}
_ => strict_normalize_schema(value),
},
_ => strict_normalize_schema(value),
}
}
fn existing_required_keys(map: &serde_json::Map<String, Value>) -> HashSet<String> {
map.get("required")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn normalize_required_properties(map: &mut serde_json::Map<String, Value>) {
let Some(property_names) = map
.get("properties")
.and_then(Value::as_object)
.map(|properties| {
let mut names: Vec<String> = properties.keys().cloned().collect();
names.sort();
names
})
else {
return;
};
let existing_required = existing_required_keys(map);
if let Some(Value::Object(properties)) = map.get_mut("properties") {
for (prop_name, prop_schema) in properties.iter_mut() {
if !existing_required.contains(prop_name) {
*prop_schema = make_schema_nullable(prop_schema.clone());
}
}
}
map.insert(
"required".to_string(),
Value::Array(property_names.into_iter().map(Value::String).collect()),
);
}
pub fn strict_normalize_schema(schema: &Value) -> Value {
fn normalize_map(map: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
let mut out = serde_json::Map::new();
for (key, value) in map {
let normalized = normalize_strict_schema_keyword(key, value);
out.insert(key.clone(), normalized);
}
let is_object_typed = schema_is_object_typed(&out);
normalize_required_properties(&mut out);
if is_object_typed || out.contains_key("properties") {
out.insert("additionalProperties".to_string(), Value::Bool(false));
}
out
}
match schema {
Value::Object(map) => Value::Object(normalize_map(map)),
Value::Array(items) => Value::Array(items.iter().map(strict_normalize_schema).collect()),
_ => schema.clone(),
}
}
#[cfg(test)]
mod tests {
use super::{
make_schema_nullable, openai_compatible_schema, schema_supports_strict,
strict_normalize_schema,
};
use serde_json::json;
#[test]
fn strict_normalize_schema_marks_optional_properties_nullable_and_required() {
let schema = json!({
"type": "object",
"properties": {
"required_name": { "type": "string" },
"optional_age": { "type": "integer" }
},
"required": ["required_name"]
});
let normalized = strict_normalize_schema(&schema);
assert_eq!(
normalized,
json!({
"type": "object",
"properties": {
"required_name": { "type": "string" },
"optional_age": { "type": ["integer", "null"] }
},
"required": ["optional_age", "required_name"],
"additionalProperties": false
})
);
}
#[test]
fn strict_normalize_schema_preserves_existing_nullability() {
let schema = json!({
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
});
assert_eq!(
make_schema_nullable(schema.clone()),
json!({
"anyOf": [
{ "type": "string" },
{ "type": "null" }
]
})
);
}
#[test]
fn strict_normalize_schema_recurses_through_nested_object_keywords() {
let schema = json!({
"type": "object",
"properties": {
"child": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
});
let normalized = strict_normalize_schema(&schema);
assert_eq!(
normalized,
json!({
"type": "object",
"properties": {
"child": {
"type": ["object", "null"],
"properties": {
"name": { "type": ["string", "null"] }
},
"required": ["name"],
"additionalProperties": false
}
},
"required": ["child"],
"additionalProperties": false
})
);
}
#[test]
fn schema_supports_strict_rejects_open_or_empty_objects() {
assert!(!schema_supports_strict(&json!({ "type": "object" })));
assert!(!schema_supports_strict(&json!({
"type": "object",
"properties": { "x": { "type": "string" } },
"additionalProperties": true
})));
assert!(schema_supports_strict(&json!({
"type": "object",
"properties": { "x": { "type": "string" } },
"additionalProperties": false
})));
}
#[test]
fn openai_compatible_schema_flattens_allof_object_branches() {
let schema = json!({
"description": "Read params",
"allOf": [
{
"type": "object",
"properties": {
"file_path": { "type": "string" }
},
"required": ["file_path"]
},
{
"type": "object",
"properties": {
"start_line": { "type": "integer" }
}
}
]
});
let normalized = openai_compatible_schema(&schema);
assert!(normalized.get("allOf").is_none());
assert_eq!(normalized["type"], json!("object"));
assert_eq!(normalized["description"], json!("Read params"));
assert_eq!(
normalized["properties"]["file_path"]["type"],
json!("string")
);
assert_eq!(
normalized["properties"]["start_line"]["type"],
json!("integer")
);
assert_eq!(normalized["required"], json!(["file_path"]));
}
}
+409
View File
@@ -0,0 +1,409 @@
use crate::{RouteCheapnessEstimate, RouteCostConfidence, RouteCostSource};
fn usd_to_micros(usd: f64) -> u64 {
(usd * 1_000_000.0).round() as u64
}
fn usd_per_token_str_to_micros_per_mtok(raw: &str) -> Option<u64> {
raw.trim()
.parse::<f64>()
.ok()
.map(|usd_per_token| (usd_per_token * 1_000_000_000_000.0).round() as u64)
}
/// True when an Anthropic service tier value means fast mode. The Anthropic
/// API spells the latency-optimized tier `auto`; jcode also accepts `priority`
/// because `/fast on` is shared with OpenAI.
fn anthropic_tier_is_fast(service_tier: Option<&str>) -> bool {
matches!(
service_tier
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref(),
Some("auto") | Some("priority")
)
}
/// Published Anthropic API pricing (docs.anthropic.com/en/docs/about-claude/pricing).
///
/// `[1m]` long-context variants bill at standard per-token rates: Anthropic
/// includes the full 1M context window at standard pricing for Fable 5,
/// Opus 4.8/4.7/4.6 and Sonnet 4.6, so the suffix never changes the estimate.
pub fn anthropic_api_pricing(model: &str) -> Option<RouteCheapnessEstimate> {
anthropic_api_pricing_with_tier(model, None)
}
/// Anthropic API pricing honoring the active service tier.
///
/// Fast mode (research preview) bills premium per-token rates on Opus
/// 4.8/4.7/4.6; prompt-caching multipliers stack on top (cache read is 0.1x
/// the fast-mode input rate). Tiers on models without fast-mode pricing fall
/// back to standard rates.
pub fn anthropic_api_pricing_with_tier(
model: &str,
service_tier: Option<&str>,
) -> Option<RouteCheapnessEstimate> {
let base = model.strip_suffix("[1m]").unwrap_or(model);
let exact = |input_usd: f64, output_usd: f64, cache_read_usd: f64, note: &str| {
Some(RouteCheapnessEstimate::metered(
RouteCostSource::PublicApiPricing,
RouteCostConfidence::Exact,
usd_to_micros(input_usd),
usd_to_micros(output_usd),
Some(usd_to_micros(cache_read_usd)),
Some(note.to_string()),
))
};
if anthropic_tier_is_fast(service_tier) {
match base {
"claude-opus-4-8" => {
return exact(10.0, 50.0, 1.0, "Anthropic API fast mode pricing");
}
"claude-opus-4-7" | "claude-opus-4-6" => {
return exact(30.0, 150.0, 3.0, "Anthropic API fast mode pricing");
}
_ => {}
}
}
match base {
"claude-fable-5" => exact(10.0, 50.0, 1.0, "Anthropic API pricing"),
"claude-opus-4-8" | "claude-opus-4-7" | "claude-opus-4-6" | "claude-opus-4-5" => {
exact(5.0, 25.0, 0.5, "Anthropic API pricing")
}
// Sonnet 5 introductory pricing ($2/$10) runs through 2026-08-31,
// after which it moves to the standard Sonnet $3/$15 rates.
"claude-sonnet-5" => exact(2.0, 10.0, 0.2, "Anthropic API introductory pricing"),
"claude-sonnet-4-6" | "claude-sonnet-4-5" | "claude-sonnet-4-20250514" => {
exact(3.0, 15.0, 0.3, "Anthropic API pricing")
}
"claude-haiku-4-5" => exact(1.0, 5.0, 0.1, "Anthropic API pricing"),
_ => None,
}
}
pub fn anthropic_oauth_pricing(model: &str, subscription: Option<&str>) -> RouteCheapnessEstimate {
let base = model.strip_suffix("[1m]").unwrap_or(model);
let is_opus = base.contains("opus");
let is_1m = model.ends_with("[1m]");
match subscription
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("max") => RouteCheapnessEstimate::subscription(
RouteCostSource::RuntimePlan,
RouteCostConfidence::Medium,
usd_to_micros(100.0),
None,
Some(if is_opus {
"Claude Max plan; Opus access included; 1M context".to_string()
} else {
"Claude Max plan; 1M context".to_string()
}),
),
Some("pro") => RouteCheapnessEstimate::subscription(
RouteCostSource::RuntimePlan,
RouteCostConfidence::Medium,
usd_to_micros(20.0),
None,
Some(if is_1m {
"Claude Pro plan; 1M context requires extra usage".to_string()
} else {
"Claude Pro plan".to_string()
}),
),
Some(other) => RouteCheapnessEstimate::subscription(
RouteCostSource::RuntimePlan,
RouteCostConfidence::Low,
usd_to_micros(20.0),
None,
Some(format!(
"Claude OAuth plan '{}'; assumed Pro-like pricing",
other
)),
),
None => RouteCheapnessEstimate::subscription(
RouteCostSource::PublicPlanPricing,
RouteCostConfidence::Low,
usd_to_micros(if is_opus { 100.0 } else { 20.0 }),
None,
Some(if is_opus {
"Opus access implies Claude Max-like subscription pricing".to_string()
} else {
"Claude OAuth subscription pricing (plan not detected)".to_string()
}),
),
}
}
/// Published OpenAI API pricing (platform.openai.com/docs/pricing).
///
/// Standard tier, short-context prices. GPT-5.4+/5.5 bill a higher tier for
/// requests over ~272k input tokens; per-call estimates here use the standard
/// tier since jcode cannot see the per-request tier split.
pub fn openai_api_pricing(model: &str) -> Option<RouteCheapnessEstimate> {
openai_api_pricing_with_tier(model, None)
}
/// OpenAI API pricing honoring the active service tier.
///
/// `priority` (fast mode) and `flex` bill different per-token rates on the
/// models that support them; other tier values and unsupported models fall
/// back to standard rates.
pub fn openai_api_pricing_with_tier(
model: &str,
service_tier: Option<&str>,
) -> Option<RouteCheapnessEstimate> {
let base = model.strip_suffix("[1m]").unwrap_or(model);
let exact = |input_usd: f64, output_usd: f64, cache_read_usd: Option<f64>, note: &str| {
Some(RouteCheapnessEstimate::metered(
RouteCostSource::PublicApiPricing,
RouteCostConfidence::Exact,
usd_to_micros(input_usd),
usd_to_micros(output_usd),
cache_read_usd.map(usd_to_micros),
Some(note.to_string()),
))
};
match service_tier
.map(str::trim)
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("priority") => match base {
"gpt-5.5" => return exact(12.5, 75.0, Some(1.25), "OpenAI API priority pricing"),
"gpt-5.4" => return exact(5.0, 30.0, Some(0.5), "OpenAI API priority pricing"),
"gpt-5.4-mini" => return exact(1.5, 9.0, Some(0.15), "OpenAI API priority pricing"),
"gpt-5.3-codex" => return exact(3.5, 28.0, Some(0.35), "OpenAI API priority pricing"),
_ => {}
},
Some("flex") => match base {
"gpt-5.5" => return exact(2.5, 15.0, Some(0.25), "OpenAI API flex pricing"),
"gpt-5.5-pro" => return exact(15.0, 90.0, None, "OpenAI API flex pricing"),
"gpt-5.4" => return exact(1.25, 7.5, Some(0.13), "OpenAI API flex pricing"),
"gpt-5.4-mini" => return exact(0.375, 2.25, Some(0.0375), "OpenAI API flex pricing"),
"gpt-5.4-nano" => return exact(0.1, 0.625, Some(0.01), "OpenAI API flex pricing"),
"gpt-5.4-pro" => return exact(15.0, 90.0, None, "OpenAI API flex pricing"),
_ => {}
},
_ => {}
}
match base {
"gpt-5.5" => exact(5.0, 30.0, Some(0.5), "OpenAI API pricing"),
"gpt-5.5-pro" | "gpt-5.4-pro" => exact(30.0, 180.0, None, "OpenAI API pricing"),
"gpt-5.4" => exact(2.5, 15.0, Some(0.25), "OpenAI API pricing"),
"gpt-5.4-mini" => exact(0.75, 4.5, Some(0.075), "OpenAI API pricing"),
"gpt-5.4-nano" => exact(0.2, 1.25, Some(0.02), "OpenAI API pricing"),
"gpt-5.3-codex" | "gpt-5.3-codex-spark" | "gpt-5.3-chat-latest" => {
exact(1.75, 14.0, Some(0.175), "OpenAI API pricing")
}
"gpt-5.2" | "gpt-5.2-codex" | "gpt-5.2-chat-latest" => {
exact(1.75, 14.0, Some(0.175), "OpenAI API pricing")
}
"gpt-5.2-pro" => exact(21.0, 168.0, None, "OpenAI API pricing"),
"gpt-5.1"
| "gpt-5.1-codex"
| "gpt-5.1-codex-max"
| "gpt-5.1-chat-latest"
| "gpt-5"
| "gpt-5-codex"
| "gpt-5-chat-latest" => exact(1.25, 10.0, Some(0.125), "OpenAI API pricing"),
"gpt-5.1-codex-mini" | "gpt-5-mini" => exact(0.25, 2.0, Some(0.025), "OpenAI API pricing"),
"gpt-5-nano" => exact(0.05, 0.4, Some(0.005), "OpenAI API pricing"),
"gpt-5-pro" => exact(15.0, 120.0, None, "OpenAI API pricing"),
_ => None,
}
}
pub fn openai_oauth_pricing(model: &str) -> RouteCheapnessEstimate {
let base = model.strip_suffix("[1m]").unwrap_or(model);
let likely_pro = base.contains("pro") || matches!(base, "gpt-5.5" | "gpt-5.4");
RouteCheapnessEstimate::subscription(
RouteCostSource::PublicPlanPricing,
RouteCostConfidence::Low,
usd_to_micros(if likely_pro { 200.0 } else { 20.0 }),
None,
Some(if likely_pro {
"ChatGPT subscription estimate; advanced GPT-5 access treated as Pro-like".to_string()
} else {
"ChatGPT subscription estimate".to_string()
}),
)
}
pub fn copilot_pricing(model: &str, zero_premium_mode: bool) -> RouteCheapnessEstimate {
let likely_premium_model =
model.contains("opus") || model.contains("gpt-5.5") || model.contains("gpt-5.4");
let monthly_price = if likely_premium_model {
usd_to_micros(39.0)
} else {
usd_to_micros(10.0)
};
let included_requests = if likely_premium_model { 1_500 } else { 300 };
let estimated_reference = if zero_premium_mode {
Some(0)
} else {
Some(monthly_price / included_requests)
};
RouteCheapnessEstimate::included_quota(
RouteCostSource::RuntimePlan,
if zero_premium_mode {
RouteCostConfidence::High
} else {
RouteCostConfidence::Medium
},
monthly_price,
Some(included_requests),
estimated_reference,
Some(if zero_premium_mode {
"Copilot zero-premium mode: jcode will send requests as agent/non-premium when possible"
.to_string()
} else if likely_premium_model {
"Copilot premium-request estimate using Pro+/premium pricing".to_string()
} else {
"Copilot estimate using Pro included premium requests".to_string()
}),
)
}
pub fn openrouter_pricing_from_token_prices(
prompt: Option<&str>,
completion: Option<&str>,
input_cache_read: Option<&str>,
source: RouteCostSource,
confidence: RouteCostConfidence,
note: Option<String>,
) -> Option<RouteCheapnessEstimate> {
let input = prompt.and_then(usd_per_token_str_to_micros_per_mtok)?;
let output = completion.and_then(usd_per_token_str_to_micros_per_mtok)?;
let cache = input_cache_read.and_then(usd_per_token_str_to_micros_per_mtok);
Some(RouteCheapnessEstimate::metered(
source, confidence, input, output, cache, note,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::RouteBillingKind;
#[test]
fn anthropic_api_pricing_long_context_uses_standard_rates() {
// Anthropic includes the 1M context window at standard pricing, so the
// `[1m]` suffix must not change the estimate.
let estimate = anthropic_api_pricing("claude-opus-4-6[1m]").expect("priced model");
assert_eq!(estimate.billing_kind, RouteBillingKind::Metered);
assert_eq!(estimate.source, RouteCostSource::PublicApiPricing);
assert_eq!(estimate.confidence, RouteCostConfidence::Exact);
assert_eq!(estimate.input_price_per_mtok_micros, Some(5_000_000));
assert_eq!(estimate.output_price_per_mtok_micros, Some(25_000_000));
assert_eq!(estimate.cache_read_price_per_mtok_micros, Some(500_000));
assert_eq!(
anthropic_api_pricing("claude-opus-4-6"),
anthropic_api_pricing("claude-opus-4-6[1m]")
);
assert_eq!(
anthropic_api_pricing("claude-sonnet-4-6"),
anthropic_api_pricing("claude-sonnet-4-6[1m]")
);
}
#[test]
fn anthropic_api_pricing_matches_published_rates() {
let fable = anthropic_api_pricing("claude-fable-5").expect("priced model");
assert_eq!(fable.input_price_per_mtok_micros, Some(10_000_000));
assert_eq!(fable.output_price_per_mtok_micros, Some(50_000_000));
assert_eq!(fable.cache_read_price_per_mtok_micros, Some(1_000_000));
let sonnet = anthropic_api_pricing("claude-sonnet-4-6").expect("priced model");
assert_eq!(sonnet.input_price_per_mtok_micros, Some(3_000_000));
assert_eq!(sonnet.output_price_per_mtok_micros, Some(15_000_000));
assert_eq!(sonnet.cache_read_price_per_mtok_micros, Some(300_000));
let haiku = anthropic_api_pricing("claude-haiku-4-5").expect("priced model");
assert_eq!(haiku.input_price_per_mtok_micros, Some(1_000_000));
assert_eq!(haiku.output_price_per_mtok_micros, Some(5_000_000));
assert_eq!(haiku.cache_read_price_per_mtok_micros, Some(100_000));
}
#[test]
fn openrouter_token_pricing_parses_token_prices() {
let estimate = openrouter_pricing_from_token_prices(
Some("0.0000025"),
Some("0.000015"),
Some("0.00000025"),
RouteCostSource::OpenRouterCatalog,
RouteCostConfidence::Medium,
Some("test".to_string()),
)
.expect("parsed pricing");
assert_eq!(estimate.input_price_per_mtok_micros, Some(2_500_000));
assert_eq!(estimate.output_price_per_mtok_micros, Some(15_000_000));
assert_eq!(estimate.cache_read_price_per_mtok_micros, Some(250_000));
}
#[test]
fn anthropic_fast_mode_tier_bills_premium_rates() {
// Opus 4.6 fast mode: $30/$150, cache read 0.1x fast input.
let fast =
anthropic_api_pricing_with_tier("claude-opus-4-6", Some("auto")).expect("priced model");
assert_eq!(fast.input_price_per_mtok_micros, Some(30_000_000));
assert_eq!(fast.output_price_per_mtok_micros, Some(150_000_000));
assert_eq!(fast.cache_read_price_per_mtok_micros, Some(3_000_000));
// Opus 4.8 fast mode: $10/$50. `priority` spelling also accepted.
let opus48 = anthropic_api_pricing_with_tier("claude-opus-4-8", Some("priority"))
.expect("priced model");
assert_eq!(opus48.input_price_per_mtok_micros, Some(10_000_000));
assert_eq!(opus48.output_price_per_mtok_micros, Some(50_000_000));
// standard_only (off) and models without fast pricing use standard rates.
assert_eq!(
anthropic_api_pricing_with_tier("claude-opus-4-6", Some("standard_only")),
anthropic_api_pricing("claude-opus-4-6")
);
assert_eq!(
anthropic_api_pricing_with_tier("claude-sonnet-4-6", Some("auto")),
anthropic_api_pricing("claude-sonnet-4-6")
);
}
#[test]
fn openai_service_tiers_bill_published_rates() {
// gpt-5.4 priority: $5/$30.
let priority =
openai_api_pricing_with_tier("gpt-5.4", Some("priority")).expect("priced model");
assert_eq!(priority.input_price_per_mtok_micros, Some(5_000_000));
assert_eq!(priority.output_price_per_mtok_micros, Some(30_000_000));
// gpt-5.4 flex: $1.25/$7.50.
let flex = openai_api_pricing_with_tier("gpt-5.4", Some("flex")).expect("priced model");
assert_eq!(flex.input_price_per_mtok_micros, Some(1_250_000));
assert_eq!(flex.output_price_per_mtok_micros, Some(7_500_000));
// Models without a tier-specific price fall back to standard rates.
assert_eq!(
openai_api_pricing_with_tier("gpt-5.2", Some("priority")),
openai_api_pricing("gpt-5.2")
);
assert_eq!(
openai_api_pricing_with_tier("gpt-5.4", None),
openai_api_pricing("gpt-5.4")
);
}
#[test]
fn copilot_zero_mode_marks_estimate_high_confidence_and_zero_reference_cost() {
let estimate = copilot_pricing("claude-opus-4-6", true);
assert_eq!(estimate.billing_kind, RouteBillingKind::IncludedQuota);
assert_eq!(estimate.confidence, RouteCostConfidence::High);
assert_eq!(estimate.estimated_reference_cost_micros, Some(0));
}
}
@@ -0,0 +1,219 @@
//! Shared handling for provider `Retry-After` hints.
//!
//! Provider runtimes keep their own retry classification and request logic, but
//! parsing an untrusted server delay and carrying it through an `anyhow::Error`
//! should be consistent. Delays are capped so a malformed or hostile upstream
//! cannot stall a turn indefinitely.
use anyhow::Error;
use reqwest::header::{HeaderMap, RETRY_AFTER};
use std::fmt;
use std::time::{Duration, Instant, SystemTime};
/// Longest server-requested delay a provider retry loop will honor.
pub const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
/// Parse a `Retry-After` header as delta-seconds or an HTTP date.
///
/// Numeric values are parsed with saturation and then capped, so even an
/// arbitrarily long digit string is safe. Invalid values are ignored and let
/// the caller fall back to its normal exponential backoff.
pub fn retry_after(headers: &HeaderMap) -> Option<RetryAfter> {
retry_after_delay_at(headers, SystemTime::now()).map(RetryAfter::new)
}
fn retry_after_delay_at(headers: &HeaderMap, now: SystemTime) -> Option<Duration> {
let value = headers.get(RETRY_AFTER)?.to_str().ok()?.trim();
if value.is_empty() {
return None;
}
if value.bytes().all(|byte| byte.is_ascii_digit()) {
let max_secs = MAX_RETRY_AFTER.as_secs();
let seconds = value.bytes().fold(0u64, |seconds, byte| {
seconds
.saturating_mul(10)
.saturating_add(u64::from(byte - b'0'))
.min(max_secs)
});
return Some(Duration::from_secs(seconds));
}
let retry_at = httpdate::parse_http_date(value).ok()?;
Some(
retry_at
.duration_since(now)
.unwrap_or(Duration::ZERO)
.min(MAX_RETRY_AFTER),
)
}
/// A bounded server retry hint represented as a monotonic deadline.
#[derive(Clone, Copy, Debug)]
pub struct RetryAfter {
deadline: Instant,
}
impl RetryAfter {
fn new(delay: Duration) -> Self {
Self {
deadline: Instant::now() + delay,
}
}
/// Time still remaining on the hint. Time spent reading and classifying an
/// error response counts toward the requested wait.
pub fn remaining(self) -> Duration {
self.deadline.saturating_duration_since(Instant::now())
}
}
/// Error wrapper that preserves the provider's user-facing message while
/// carrying a parsed server retry deadline to the outer retry loop.
#[derive(Debug)]
struct RetryAfterError {
message: String,
retry_after: RetryAfter,
}
impl fmt::Display for RetryAfterError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for RetryAfterError {}
/// Build an error with an optional server retry hint without changing its
/// display text.
pub fn error_with_retry_after(message: String, retry_after: Option<RetryAfter>) -> Error {
match retry_after {
Some(retry_after) => Error::new(RetryAfterError {
message,
retry_after,
}),
None => Error::msg(message),
}
}
/// Recover a server retry hint from a provider error, including through anyhow
/// context layers.
pub fn retry_after_from_error(error: &Error) -> Option<Duration> {
error
.chain()
.find_map(|source| source.downcast_ref::<RetryAfterError>())
.map(|error| error.retry_after.remaining())
}
/// Select the delay before a retry, preferring a validated server hint over
/// the provider's normal jittered exponential backoff.
pub fn retry_delay(attempt: u32, base_ms: u64, server_hint: Option<Duration>) -> Duration {
server_hint.unwrap_or_else(|| crate::attempt_tracker::retry_backoff_delay(attempt, base_ms))
}
#[cfg(test)]
mod tests {
use super::*;
use reqwest::header::HeaderValue;
fn headers(value: HeaderValue) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(RETRY_AFTER, value);
headers
}
#[test]
fn parses_delta_seconds_without_sleeping() {
assert_eq!(
retry_after_delay_at(
&headers(HeaderValue::from_static("7")),
SystemTime::UNIX_EPOCH,
),
Some(Duration::from_secs(7))
);
}
#[test]
fn parses_http_date_relative_to_injected_clock() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let retry_at = now + Duration::from_secs(12);
let value = HeaderValue::from_str(&httpdate::fmt_http_date(retry_at)).unwrap();
assert_eq!(
retry_after_delay_at(&headers(value), now),
Some(Duration::from_secs(12))
);
}
#[test]
fn past_http_date_requests_no_additional_wait() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let value =
HeaderValue::from_str(&httpdate::fmt_http_date(now - Duration::from_secs(30))).unwrap();
assert_eq!(
retry_after_delay_at(&headers(value), now),
Some(Duration::ZERO)
);
}
#[test]
fn far_future_http_date_is_capped() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let value =
HeaderValue::from_str(&httpdate::fmt_http_date(now + Duration::from_secs(3_600)))
.unwrap();
assert_eq!(
retry_after_delay_at(&headers(value), now),
Some(MAX_RETRY_AFTER)
);
}
#[test]
fn malformed_retry_after_is_ignored() {
assert_eq!(
retry_after_delay_at(
&headers(HeaderValue::from_static("not-a-delay")),
SystemTime::UNIX_EPOCH,
),
None
);
}
#[test]
fn oversized_retry_after_is_capped_even_when_it_overflows_u64() {
let value = HeaderValue::from_static("999999999999999999999999999999999999999999");
assert_eq!(
retry_after_delay_at(&headers(value), SystemTime::UNIX_EPOCH),
Some(MAX_RETRY_AFTER)
);
}
#[test]
fn error_hint_round_trips_without_changing_message() {
let error = error_with_retry_after(
"rate limited".to_string(),
Some(RetryAfter::new(Duration::from_secs(9))),
)
.context("request failed");
assert_eq!(format!("{error:#}"), "request failed: rate limited");
let remaining = retry_after_from_error(&error).unwrap();
assert!(remaining <= Duration::from_secs(9));
assert!(remaining > Duration::from_secs(8));
}
#[test]
fn server_hint_replaces_backoff_without_sleeping() {
assert_eq!(
retry_delay(3, 10_000, Some(Duration::from_secs(4))),
Duration::from_secs(4)
);
}
#[test]
fn elapsed_hint_does_not_add_another_wait() {
let retry_after = RetryAfter {
deadline: Instant::now() - Duration::from_secs(1),
};
let error = error_with_retry_after("rate limited".to_string(), Some(retry_after));
assert_eq!(retry_after_from_error(&error), Some(Duration::ZERO));
}
}
+669
View File
@@ -0,0 +1,669 @@
use crate::{ModelRoute, normalize_copilot_model_name};
use std::borrow::Cow;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ActiveProvider {
Claude,
OpenAI,
Copilot,
Antigravity,
Gemini,
Cursor,
Bedrock,
OpenRouter,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct ProviderAvailability {
pub openai: bool,
pub claude: bool,
pub copilot: bool,
pub antigravity: bool,
pub gemini: bool,
pub cursor: bool,
pub bedrock: bool,
pub openrouter: bool,
pub copilot_premium_zero: bool,
}
impl ProviderAvailability {
pub fn is_configured(self, provider: ActiveProvider) -> bool {
match provider {
ActiveProvider::Claude => self.claude,
ActiveProvider::OpenAI => self.openai,
ActiveProvider::Copilot => self.copilot,
ActiveProvider::Antigravity => self.antigravity,
ActiveProvider::Gemini => self.gemini,
ActiveProvider::Cursor => self.cursor,
ActiveProvider::Bedrock => self.bedrock,
ActiveProvider::OpenRouter => self.openrouter,
}
}
}
pub fn auto_default_provider(availability: ProviderAvailability) -> ActiveProvider {
if availability.copilot_premium_zero && availability.copilot {
ActiveProvider::Copilot
} else if availability.openai {
ActiveProvider::OpenAI
} else if availability.claude {
ActiveProvider::Claude
} else if availability.copilot {
ActiveProvider::Copilot
} else if availability.antigravity {
ActiveProvider::Antigravity
} else if availability.gemini {
ActiveProvider::Gemini
} else if availability.cursor {
ActiveProvider::Cursor
} else if availability.bedrock {
ActiveProvider::Bedrock
} else if availability.openrouter {
ActiveProvider::OpenRouter
} else {
ActiveProvider::Claude
}
}
pub fn parse_provider_hint(value: &str) -> Option<ActiveProvider> {
match value.trim().to_ascii_lowercase().as_str() {
"claude" | "anthropic" => Some(ActiveProvider::Claude),
"openai" => Some(ActiveProvider::OpenAI),
"copilot" => Some(ActiveProvider::Copilot),
"antigravity" => Some(ActiveProvider::Antigravity),
"gemini" => Some(ActiveProvider::Gemini),
"cursor" => Some(ActiveProvider::Cursor),
"bedrock" | "aws-bedrock" | "aws_bedrock" => Some(ActiveProvider::Bedrock),
"openrouter" => Some(ActiveProvider::OpenRouter),
_ => None,
}
}
pub fn provider_label(provider: ActiveProvider) -> &'static str {
match provider {
ActiveProvider::Claude => "Anthropic",
ActiveProvider::OpenAI => "OpenAI",
ActiveProvider::Copilot => "GitHub Copilot",
ActiveProvider::Antigravity => "Antigravity",
ActiveProvider::Gemini => "Gemini",
ActiveProvider::Cursor => "Cursor",
ActiveProvider::Bedrock => "AWS Bedrock",
ActiveProvider::OpenRouter => "OpenRouter",
}
}
pub fn provider_key(provider: ActiveProvider) -> &'static str {
match provider {
ActiveProvider::Claude => "claude",
ActiveProvider::OpenAI => "openai",
ActiveProvider::Copilot => "copilot",
ActiveProvider::Antigravity => "antigravity",
ActiveProvider::Gemini => "gemini",
ActiveProvider::Cursor => "cursor",
ActiveProvider::Bedrock => "bedrock",
ActiveProvider::OpenRouter => "openrouter",
}
}
pub fn provider_from_model_key(key: &str) -> Option<ActiveProvider> {
match key {
"claude" => Some(ActiveProvider::Claude),
"openai" => Some(ActiveProvider::OpenAI),
"copilot" => Some(ActiveProvider::Copilot),
"antigravity" => Some(ActiveProvider::Antigravity),
"gemini" => Some(ActiveProvider::Gemini),
"cursor" => Some(ActiveProvider::Cursor),
"bedrock" => Some(ActiveProvider::Bedrock),
"openrouter" => Some(ActiveProvider::OpenRouter),
_ => None,
}
}
/// Translate a persisted session/runtime provider key (the `RuntimeKey`
/// stable-id or `ModelRouteApiMethod` vocabulary, e.g. `anthropic-api-key`,
/// `claude-oauth`, `openai-api-key`) into the CLI `--provider` argument value
/// (the `ProviderChoice` vocabulary, e.g. `anthropic-api`, `claude`,
/// `openai-api`).
///
/// These two vocabularies overlap but are NOT identical: the runtime key
/// distinguishes auth method (`anthropic-api-key` vs `claude-oauth`) while the
/// CLI `--provider` enum uses `anthropic-api` / `claude`. Passing a raw runtime
/// key straight to `--provider` makes clap reject it (`invalid value
/// 'anthropic-api-key'`) and the spawned process exits immediately.
///
/// Returns `None` when there is no clean, unambiguous CLI provider to pass; in
/// that case callers should omit the flag entirely and rely on the persisted
/// session (model + provider_key + route_api_method) to reconstruct the exact
/// route on resume.
pub fn cli_provider_arg_for_session_key(key: &str) -> Option<&'static str> {
let normalized = key.trim().to_ascii_lowercase();
let base = normalized
.split_once(':')
.map(|(prefix, _rest)| prefix)
.unwrap_or(normalized.as_str());
// Dual-auth (Anthropic/OpenAI OAuth-vs-API) keys share one canonical alias
// table, so the CLI arg never drifts from the route/runtime vocabularies.
if let Some(route) = crate::auth_mode::AuthRoute::parse(base) {
return Some(route.cli_provider_arg());
}
match base {
"openrouter" => Some("openrouter"),
"copilot" => Some("copilot"),
"gemini" => Some("gemini"),
"cursor" => Some("cursor"),
"bedrock" => Some("bedrock"),
"antigravity" => Some("antigravity"),
"code-assist-oauth" | "google" => Some("google"),
// openai-compatible / custom profiles, remote-catalog, current, and any
// unknown key have no clean standalone CLI provider value (they need a
// profile too), so omit the flag and let the persisted session route.
_ => None,
}
}
pub fn explicit_model_provider_prefix(model: &str) -> Option<(ActiveProvider, &'static str, &str)> {
if let Some(rest) = model.strip_prefix("claude-api:") {
Some((ActiveProvider::Claude, "claude-api:", rest))
} else if let Some(rest) = model.strip_prefix("claude-oauth:") {
Some((ActiveProvider::Claude, "claude-oauth:", rest))
} else if let Some(rest) = model.strip_prefix("claude:") {
Some((ActiveProvider::Claude, "claude:", rest))
} else if let Some(rest) = model.strip_prefix("anthropic:") {
Some((ActiveProvider::Claude, "anthropic:", rest))
} else if let Some(rest) = model.strip_prefix("openai-api:") {
Some((ActiveProvider::OpenAI, "openai-api:", rest))
} else if let Some(rest) = model.strip_prefix("openai-oauth:") {
Some((ActiveProvider::OpenAI, "openai-oauth:", rest))
} else if let Some(rest) = model.strip_prefix("openai:") {
Some((ActiveProvider::OpenAI, "openai:", rest))
} else if let Some(rest) = model.strip_prefix("copilot:") {
Some((ActiveProvider::Copilot, "copilot:", rest))
} else if let Some(rest) = model.strip_prefix("antigravity:") {
Some((ActiveProvider::Antigravity, "antigravity:", rest))
} else if let Some(rest) = model.strip_prefix("gemini:") {
Some((ActiveProvider::Gemini, "gemini:", rest))
} else if let Some(rest) = model.strip_prefix("cursor:") {
Some((ActiveProvider::Cursor, "cursor:", rest))
} else if let Some(rest) = model.strip_prefix("bedrock:") {
Some((ActiveProvider::Bedrock, "bedrock:", rest))
} else if let Some(rest) = model.strip_prefix("openrouter:") {
Some((ActiveProvider::OpenRouter, "openrouter:", rest))
} else {
None
}
}
pub fn model_name_for_provider(provider: ActiveProvider, model: &str) -> Cow<'_, str> {
if matches!(provider, ActiveProvider::Claude)
&& let Some(canonical) = normalize_copilot_model_name(model)
{
return Cow::Borrowed(canonical);
}
Cow::Borrowed(model)
}
pub fn dedupe_model_routes(routes: Vec<ModelRoute>) -> Vec<ModelRoute> {
use std::collections::HashMap;
let mut deduped: Vec<ModelRoute> = Vec::with_capacity(routes.len());
// Bucket candidate duplicates by (provider, model). The api_method match is
// fuzzy (generic vs profile openai-compatible), so buckets keep a linear
// scan, but each bucket only holds the handful of routes for one model.
// The previous full `deduped.iter().position(..)` scan was O(n^2) over
// 2000+ routes and showed up in server connect-burst profiles.
let mut buckets: HashMap<(String, String), Vec<usize>> = HashMap::with_capacity(routes.len());
for route in routes {
let key = (route.provider.clone(), route.model.clone());
let bucket = buckets.entry(key).or_default();
if let Some(existing_idx) = bucket
.iter()
.copied()
.find(|&idx| duplicate_route_api_method(&deduped[idx].api_method, &route.api_method))
{
if should_replace_duplicate_route(&deduped[existing_idx], &route) {
deduped[existing_idx] = route;
}
continue;
}
bucket.push(deduped.len());
deduped.push(route);
}
deduped
}
#[cfg(test)]
fn duplicate_model_route(existing: &ModelRoute, candidate: &ModelRoute) -> bool {
existing.provider == candidate.provider
&& existing.model == candidate.model
&& duplicate_route_api_method(&existing.api_method, &candidate.api_method)
}
/// Reference O(n^2) dedupe used to prove the bucketed implementation above is
/// behavior-identical (see `bucketed_dedupe_matches_reference` test).
#[cfg(test)]
fn dedupe_model_routes_reference(routes: Vec<ModelRoute>) -> Vec<ModelRoute> {
let mut deduped: Vec<ModelRoute> = Vec::with_capacity(routes.len());
for route in routes {
if let Some(existing_idx) = deduped
.iter()
.position(|existing| duplicate_model_route(existing, &route))
{
if should_replace_duplicate_route(&deduped[existing_idx], &route) {
deduped[existing_idx] = route;
}
continue;
}
deduped.push(route);
}
deduped
}
fn duplicate_route_api_method(existing: &str, candidate: &str) -> bool {
existing == candidate
|| (is_generic_openai_compatible_route(existing)
&& is_profile_openai_compatible_route(candidate))
|| (is_profile_openai_compatible_route(existing)
&& is_generic_openai_compatible_route(candidate))
}
fn is_generic_openai_compatible_route(api_method: &str) -> bool {
api_method == "openai-compatible"
}
fn is_profile_openai_compatible_route(api_method: &str) -> bool {
api_method.starts_with("openai-compatible:")
}
fn should_replace_duplicate_route(existing: &ModelRoute, candidate: &ModelRoute) -> bool {
// A direct OpenAI-compatible provider can briefly appear twice in merged
// catalogs: once as the generic transport and once as the named profile
// transport. Keep the profile-scoped route so selection writes
// `profile:model` rather than falling back to ambiguous generic routing.
let existing_profile_scoped = is_profile_openai_compatible_route(&existing.api_method);
let candidate_profile_scoped = is_profile_openai_compatible_route(&candidate.api_method);
!existing_profile_scoped && candidate_profile_scoped
}
pub fn fallback_sequence(active: ActiveProvider) -> Vec<ActiveProvider> {
match active {
ActiveProvider::Claude => vec![
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Copilot,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
ActiveProvider::Bedrock,
ActiveProvider::OpenRouter,
],
ActiveProvider::OpenAI => vec![
ActiveProvider::OpenAI,
ActiveProvider::Claude,
ActiveProvider::Copilot,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
ActiveProvider::Bedrock,
ActiveProvider::OpenRouter,
],
ActiveProvider::Copilot => vec![
ActiveProvider::Copilot,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Antigravity,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
ActiveProvider::Bedrock,
ActiveProvider::OpenRouter,
],
ActiveProvider::Antigravity => vec![
ActiveProvider::Antigravity,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Copilot,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
ActiveProvider::Bedrock,
ActiveProvider::OpenRouter,
],
ActiveProvider::Gemini => vec![
ActiveProvider::Gemini,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Antigravity,
ActiveProvider::Copilot,
ActiveProvider::Cursor,
ActiveProvider::Bedrock,
ActiveProvider::OpenRouter,
],
ActiveProvider::Cursor => vec![
ActiveProvider::Cursor,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Copilot,
ActiveProvider::Antigravity,
ActiveProvider::Gemini,
ActiveProvider::OpenRouter,
],
ActiveProvider::Bedrock => vec![
ActiveProvider::Bedrock,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Copilot,
ActiveProvider::Antigravity,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
ActiveProvider::OpenRouter,
],
ActiveProvider::OpenRouter => vec![
ActiveProvider::OpenRouter,
ActiveProvider::Claude,
ActiveProvider::OpenAI,
ActiveProvider::Copilot,
ActiveProvider::Antigravity,
ActiveProvider::Gemini,
ActiveProvider::Cursor,
],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_provider_hints() {
assert_eq!(
parse_provider_hint("Anthropic"),
Some(ActiveProvider::Claude)
);
assert_eq!(parse_provider_hint("openai"), Some(ActiveProvider::OpenAI));
assert_eq!(parse_provider_hint("unknown"), None);
}
#[test]
fn cli_provider_arg_translates_runtime_keys() {
// Anthropic API key (the regression: this is NOT a valid --provider
// value verbatim; it must map to `anthropic-api`).
assert_eq!(
cli_provider_arg_for_session_key("anthropic-api-key"),
Some("anthropic-api")
);
assert_eq!(
cli_provider_arg_for_session_key("claude-api"),
Some("anthropic-api")
);
// Anthropic OAuth -> claude.
assert_eq!(
cli_provider_arg_for_session_key("claude-oauth"),
Some("claude")
);
assert_eq!(cli_provider_arg_for_session_key("claude"), Some("claude"));
// OpenAI variants.
assert_eq!(
cli_provider_arg_for_session_key("openai-oauth"),
Some("openai")
);
assert_eq!(
cli_provider_arg_for_session_key("openai-api-key"),
Some("openai-api")
);
// Passthrough providers.
assert_eq!(
cli_provider_arg_for_session_key("openrouter"),
Some("openrouter")
);
assert_eq!(cli_provider_arg_for_session_key("copilot"), Some("copilot"));
assert_eq!(cli_provider_arg_for_session_key("gemini"), Some("gemini"));
assert_eq!(cli_provider_arg_for_session_key("bedrock"), Some("bedrock"));
// Case-insensitive and whitespace tolerant.
assert_eq!(
cli_provider_arg_for_session_key(" Anthropic-API-Key "),
Some("anthropic-api")
);
// Profile-scoped openai-compatible keys have no clean standalone CLI
// value, so we omit the flag and let the persisted session route.
assert_eq!(
cli_provider_arg_for_session_key("openai-compatible:zai"),
None
);
assert_eq!(cli_provider_arg_for_session_key("openai-compatible"), None);
assert_eq!(cli_provider_arg_for_session_key("remote-catalog"), None);
assert_eq!(cli_provider_arg_for_session_key("current"), None);
assert_eq!(cli_provider_arg_for_session_key("totally-unknown"), None);
}
#[test]
fn parses_model_provider_prefixes() {
assert_eq!(
provider_from_model_key("gemini"),
Some(ActiveProvider::Gemini)
);
assert_eq!(provider_from_model_key("missing"), None);
for (raw, expected_provider, expected_prefix, expected_model) in [
(
"claude-api:sonnet",
ActiveProvider::Claude,
"claude-api:",
"sonnet",
),
(
"claude-oauth:sonnet",
ActiveProvider::Claude,
"claude-oauth:",
"sonnet",
),
("claude:sonnet", ActiveProvider::Claude, "claude:", "sonnet"),
(
"anthropic:sonnet",
ActiveProvider::Claude,
"anthropic:",
"sonnet",
),
("openai:gpt-5", ActiveProvider::OpenAI, "openai:", "gpt-5"),
(
"openai-oauth:gpt-5",
ActiveProvider::OpenAI,
"openai-oauth:",
"gpt-5",
),
(
"openai-api:gpt-5",
ActiveProvider::OpenAI,
"openai-api:",
"gpt-5",
),
(
"copilot:gpt-5",
ActiveProvider::Copilot,
"copilot:",
"gpt-5",
),
(
"antigravity:default",
ActiveProvider::Antigravity,
"antigravity:",
"default",
),
(
"gemini:gemini-2.5-pro",
ActiveProvider::Gemini,
"gemini:",
"gemini-2.5-pro",
),
(
"cursor:composer-1.5",
ActiveProvider::Cursor,
"cursor:",
"composer-1.5",
),
(
"bedrock:anthropic.claude",
ActiveProvider::Bedrock,
"bedrock:",
"anthropic.claude",
),
(
"openrouter:meta/llama",
ActiveProvider::OpenRouter,
"openrouter:",
"meta/llama",
),
] {
let (provider, prefix, model) = explicit_model_provider_prefix(raw).unwrap();
assert_eq!(provider, expected_provider, "{raw}");
assert_eq!(prefix, expected_prefix, "{raw}");
assert_eq!(model, expected_model, "{raw}");
}
assert_eq!(explicit_model_provider_prefix("unknown:sonnet"), None);
}
#[test]
fn dedupes_model_routes_by_route_identity() {
let routes = vec![
ModelRoute {
model: "m".to_string(),
provider: "p".to_string(),
api_method: "a".to_string(),
available: true,
detail: String::new(),
cheapness: None,
},
ModelRoute {
model: "m".to_string(),
provider: "p".to_string(),
api_method: "a".to_string(),
available: false,
detail: "duplicate".to_string(),
cheapness: None,
},
ModelRoute {
model: "m".to_string(),
provider: "p".to_string(),
api_method: "b".to_string(),
available: true,
detail: String::new(),
cheapness: None,
},
];
let deduped = dedupe_model_routes(routes);
assert_eq!(deduped.len(), 2);
assert_eq!(deduped[0].detail, "");
}
#[test]
fn dedupes_openai_compatible_generic_and_profile_aliases() {
let routes = vec![
ModelRoute {
model: "qwen".to_string(),
provider: "Cerebras".to_string(),
api_method: "openai-compatible".to_string(),
available: true,
detail: "generic transport".to_string(),
cheapness: None,
},
ModelRoute {
model: "qwen".to_string(),
provider: "Cerebras".to_string(),
api_method: "openai-compatible:cerebras".to_string(),
available: true,
detail: "profile transport".to_string(),
cheapness: None,
},
ModelRoute {
model: "qwen".to_string(),
provider: "OtherDirect".to_string(),
api_method: "openai-compatible:other".to_string(),
available: true,
detail: "different provider".to_string(),
cheapness: None,
},
ModelRoute {
model: "qwen".to_string(),
provider: "Cerebras".to_string(),
api_method: "openai-compatible:cerebras-alt".to_string(),
available: true,
detail: "distinct profile route".to_string(),
cheapness: None,
},
];
let deduped = dedupe_model_routes(routes);
assert_eq!(deduped.len(), 3);
let cerebras = deduped
.iter()
.find(|route| route.provider == "Cerebras")
.expect("Cerebras route remains");
assert_eq!(cerebras.api_method, "openai-compatible:cerebras");
assert_eq!(cerebras.detail, "profile transport");
assert!(deduped.iter().any(|route| {
route.provider == "Cerebras" && route.api_method == "openai-compatible:cerebras-alt"
}));
}
/// State-space equivalence: the bucketed O(n) dedupe must produce exactly
/// the same output (content and order) as the original O(n^2) reference for
/// a pseudo-random mix of providers/models/api-methods, including the fuzzy
/// generic-vs-profile openai-compatible collisions.
#[test]
fn bucketed_dedupe_matches_reference() {
let providers = ["Anthropic", "OpenAI", "Cerebras", "auto"];
let models = ["m1", "m2", "m3", "qwen", "claude-x"];
let api_methods = [
"claude-oauth",
"claude-api",
"openrouter",
"openai-compatible",
"openai-compatible:cerebras",
"openai-compatible:other",
];
// Deterministic pseudo-random stream, dense enough to hit every
// provider/model/api-method combination and repeated duplicates.
let mut seed = 0x9e37_79b9_u64;
let mut routes = Vec::new();
for i in 0..600 {
seed = seed
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
let p = providers[(seed >> 7) as usize % providers.len()];
let m = models[(seed >> 17) as usize % models.len()];
let a = api_methods[(seed >> 27) as usize % api_methods.len()];
routes.push(ModelRoute {
model: m.to_string(),
provider: p.to_string(),
api_method: a.to_string(),
available: seed & 1 == 0,
detail: format!("route-{i}"),
cheapness: None,
});
}
let expected = dedupe_model_routes_reference(routes.clone());
let actual = dedupe_model_routes(routes);
assert_eq!(actual, expected);
}
#[test]
fn auto_default_prefers_copilot_zero_mode() {
let provider = auto_default_provider(ProviderAvailability {
openai: true,
copilot: true,
copilot_premium_zero: true,
..ProviderAvailability::default()
});
assert_eq!(provider, ActiveProvider::Copilot);
}
#[test]
fn fallback_sequence_keeps_active_first() {
let sequence = fallback_sequence(ActiveProvider::OpenRouter);
assert_eq!(sequence.first(), Some(&ActiveProvider::OpenRouter));
assert!(sequence.contains(&ActiveProvider::Claude));
assert!(sequence.contains(&ActiveProvider::Cursor));
}
}
+113
View File
@@ -0,0 +1,113 @@
//! Shared transport-level error classification for provider runtimes.
//!
//! Every provider (Anthropic, OpenAI, Gemini, Cursor, ...) needs to decide
//! whether a request failure is a transient transport fault worth retrying on
//! a fresh connection, or a real error to surface. Keeping the classifier here
//! ensures all providers recognize the same fault vocabulary.
/// Whether an error message describes a transient transport-level fault
/// (connection reset, DNS hiccup, TLS teardown, HTTP/2 stream error, ...)
/// that is likely to succeed on retry with a fresh connection.
pub fn is_transient_transport_error(error_str: &str) -> bool {
let lower = error_str.to_ascii_lowercase();
lower.contains("connection reset")
|| lower.contains("connection closed")
|| lower.contains("connection refused")
|| lower.contains("connection aborted")
|| lower.contains("broken pipe")
|| lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("operation timed out")
|| lower.contains("error decoding")
|| lower.contains("error reading")
|| lower.contains("unexpected eof")
// rustls reports an abrupt TCP close mid-stream as "peer closed
// connection without sending TLS close_notify" (its docs URL spells
// it "unexpected-eof", which the space-separated marker above does
// not match).
|| lower.contains("close_notify")
|| lower.contains("peer closed connection")
|| lower.contains("tls handshake eof")
// reqwest/hyper wrap connect-phase failures as "client error
// (Connect)" and connection-level faults as "connection error: ...".
|| lower.contains("client error (connect)")
|| lower.contains("connection error")
// hyper h1: connection closed while a response was still incomplete.
|| lower.contains("incomplete message")
|| lower.contains("request or response body error")
|| lower.contains("badrecordmac")
|| lower.contains("bad_record_mac")
|| lower.contains("fatal alert: badrecordmac")
|| lower.contains("fatal alert: bad_record_mac")
|| lower.contains("received fatal alert: badrecordmac")
|| lower.contains("received fatal alert: bad_record_mac")
|| lower.contains("decryption failed or bad record mac")
|| lower.contains("temporary failure in name resolution")
|| lower.contains("failed to lookup address information")
|| lower.contains("dns error")
|| lower.contains("name or service not known")
|| lower.contains("no route to host")
|| lower.contains("network is unreachable")
|| lower.contains("host is unreachable")
// HTTP/2 transport faults on reused/multiplexed connections. These are
// transient: a fresh connection on retry typically succeeds. Seen as
// "http2 error: stream error received: unspecific protocol error detected"
// or RST_STREAM / GOAWAY frames from the server or an intermediary.
|| lower.contains("http2 error")
|| lower.contains("stream error")
|| lower.contains("protocol error")
|| lower.contains("refused_stream")
|| lower.contains("refused stream")
|| lower.contains("enhance_your_calm")
|| lower.contains("goaway")
|| lower.contains("go away")
|| lower.contains("sendrequest")
}
#[cfg(test)]
mod tests {
use super::is_transient_transport_error;
#[test]
fn http2_stream_protocol_error_is_transient() {
// Exact shape reqwest/h2 surfaces for a reset on a reused HTTP/2 connection.
let msg = "error sending request for url (https://api.anthropic.com/v1/messages): \
client error (SendRequest): http2 error: stream error received: \
unspecific protocol error detected";
assert!(is_transient_transport_error(msg));
}
#[test]
fn http2_goaway_and_refused_stream_are_transient() {
assert!(is_transient_transport_error("http2 error: GOAWAY received"));
assert!(is_transient_transport_error("stream error: REFUSED_STREAM"));
}
#[test]
fn auth_errors_are_not_transient() {
assert!(!is_transient_transport_error("401 unauthorized"));
assert!(!is_transient_transport_error("invalid x-api-key"));
}
/// Real transport-error shapes harvested from ~/.jcode/logs.
#[test]
fn real_world_transport_errors_are_transient() {
let real_errors = [
"client error (Connect): dns error: failed to lookup address information: \
Name or service not known",
"client error (SendRequest): http2 error: keep-alive timed out: operation timed out",
"client error (SendRequest): connection error: peer closed connection without \
sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof",
"client error (Connect): operation timed out",
"client error (SendRequest): connection error: timed out",
"client error (Connect): tls handshake eof",
"error decoding response body: request or response body error: operation timed out",
];
for error in real_errors {
assert!(
is_transient_transport_error(error),
"should be transient: {error}"
);
}
}
}