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
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:
@@ -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}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"]));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user