Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions crates/oxide-code/src/client/anthropic/betas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,19 @@ mod tests {
assert!(with_1m.contains(&CONTEXT_1M_BETA_HEADER));
}

#[test]
fn compute_betas_opus_4_8_matches_opus_4_7_header_order() {
// 4.8 shares 4.7's capability flags, so the fingerprinted header sequence must be
// byte-identical (3P gateways reject mismatches).
assert_eq!(
compute_betas("claude-opus-4-8", &api_key(), true, false),
compute_betas("claude-opus-4-7", &api_key(), true, false),
);
let with_1m = compute_betas("claude-opus-4-8[1m]", &api_key(), true, false);
assert!(with_1m.contains(&CONTEXT_1M_BETA_HEADER));
assert!(with_1m.contains(&EFFORT_BETA_HEADER));
}

#[test]
fn compute_betas_structured_outputs_gated_by_model_capability() {
assert_eq!(
Expand Down
15 changes: 7 additions & 8 deletions crates/oxide-code/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::tui::theme::{self, Theme};
use crate::util::env;
use crate::util::path::expand_user;

const DEFAULT_MODEL: &str = "claude-opus-4-7[1m]";
const DEFAULT_MODEL: &str = "claude-opus-4-8[1m]";
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const AUTO_COMPACTION_OUTPUT_RESERVE_CAP: u32 = 20_000;
const AUTO_COMPACTION_BUFFER_TOKENS: u32 = 13_000;
Expand Down Expand Up @@ -879,15 +879,15 @@ mod tests {

#[tokio::test]
async fn load_defaults_apply_when_no_config_and_no_env() {
// Opus 4.7 supports `xhigh`; `effort` / `max_tokens` derive from that ceiling.
// Opus 4.8 defaults to `high` (mirroring Claude Code); `max_tokens` derives from that.
let dir = tempfile::tempdir().unwrap();
let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load())
.await
.unwrap();
assert_eq!(config.model, DEFAULT_MODEL);
assert_eq!(config.base_url, DEFAULT_BASE_URL);
assert_eq!(config.max_tokens, 64_000);
assert_eq!(config.effort, Some(Effort::Xhigh));
assert_eq!(config.max_tokens, 32_000);
assert_eq!(config.effort, Some(Effort::High));
assert_eq!(config.prompt_cache_ttl, PromptCacheTtl::OneHour);
assert!(config.compaction.auto.enabled);
assert_eq!(
Expand Down Expand Up @@ -1505,12 +1505,11 @@ mod tests {
// ── Config::load / effort resolution ──

#[tokio::test]
async fn load_effort_default_follows_model_ceiling() {
async fn load_effort_defaults_to_per_model_value() {
// 4.7 ships xhigh, 4.8 rides the high default; no-effort models stay unset.
for (model, expected) in [
("claude-opus-4-7", Some(Effort::Xhigh)),
("claude-opus-4-6", Some(Effort::High)),
("claude-sonnet-4-6", Some(Effort::High)),
("claude-sonnet-4-5", None),
("claude-opus-4-8", Some(Effort::High)),
("claude-haiku-4-5", None),
] {
let dir = tempfile::tempdir().unwrap();
Expand Down
140 changes: 67 additions & 73 deletions crates/oxide-code/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::borrow::Cow;
use crate::config::Effort;

pub(crate) use pricing::TokenCostRates;
use pricing::{HAIKU_RATES, OPUS_4_1_RATES, OPUS_4_5_PLUS_RATES, SONNET_RATES};
use pricing::{HAIKU_RATES, OPUS_4_5_PLUS_RATES, SONNET_RATES};

// ── ModelInfo ──

Expand Down Expand Up @@ -43,6 +43,10 @@ pub(crate) struct Capabilities {
pub(crate) context_1m: bool,
/// `output_config.effort` levels accepted upstream. Empty when the model rejects `effort`.
pub(crate) supported_efforts: &'static [Effort],
/// Effort sent when the user picks none. Mirrors Claude Code's per-model stock default, which
/// differs from a model's ceiling (Opus 4.7 ships `xhigh`, Opus 4.8 rides the `high` default).
/// `None` when the model rejects `effort`.
pub(crate) default_effort: Option<Effort>,
/// `structured-outputs-2025-12-15` beta.
pub(crate) structured_outputs: bool,
}
Expand All @@ -51,6 +55,26 @@ pub(crate) struct Capabilities {

/// Most-specific substring first.
pub(crate) const MODELS: &[ModelInfo] = &[
ModelInfo {
id_substr: "claude-opus-4-8",
display_name: "Claude Opus 4.8",
cutoff: Some("January 2026"),
capabilities: Capabilities {
interleaved_thinking: true,
context_management: true,
context_1m: true,
supported_efforts: &[
Effort::Low,
Effort::Medium,
Effort::High,
Effort::Xhigh,
Effort::Max,
],
default_effort: Some(Effort::High),
structured_outputs: true,
},
cost_rates: Some(OPUS_4_5_PLUS_RATES),
},
ModelInfo {
id_substr: "claude-opus-4-7",
display_name: "Claude Opus 4.7",
Expand All @@ -66,6 +90,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[
Effort::Xhigh,
Effort::Max,
],
default_effort: Some(Effort::Xhigh),
structured_outputs: true,
},
cost_rates: Some(OPUS_4_5_PLUS_RATES),
Expand All @@ -79,6 +104,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[
context_management: true,
context_1m: true,
supported_efforts: &[Effort::Low, Effort::Medium, Effort::High, Effort::Max],
default_effort: Some(Effort::High),
structured_outputs: true,
},
cost_rates: Some(OPUS_4_5_PLUS_RATES),
Expand All @@ -92,6 +118,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[
context_management: true,
context_1m: true,
supported_efforts: &[Effort::Low, Effort::Medium, Effort::High],
default_effort: Some(Effort::High),
structured_outputs: true,
},
cost_rates: Some(SONNET_RATES),
Expand All @@ -105,6 +132,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[
context_management: true,
context_1m: false,
supported_efforts: &[],
default_effort: None,
structured_outputs: true,
},
cost_rates: Some(OPUS_4_5_PLUS_RATES),
Expand All @@ -118,6 +146,7 @@ pub(crate) const MODELS: &[ModelInfo] = &[
context_management: true,
context_1m: true,
supported_efforts: &[],
default_effort: None,
structured_outputs: true,
},
cost_rates: Some(SONNET_RATES),
Expand All @@ -132,23 +161,11 @@ pub(crate) const MODELS: &[ModelInfo] = &[
context_management: true,
context_1m: false,
supported_efforts: &[],
default_effort: None,
structured_outputs: true,
},
cost_rates: Some(HAIKU_RATES),
},
ModelInfo {
id_substr: "claude-opus-4-1",
display_name: "Claude Opus 4.1",
cutoff: Some("January 2025"),
capabilities: Capabilities {
interleaved_thinking: true,
context_management: true,
context_1m: false,
supported_efforts: &[],
structured_outputs: true,
},
cost_rates: Some(OPUS_4_1_RATES),
},
];

impl Capabilities {
Expand All @@ -171,21 +188,11 @@ impl Capabilities {
.find(|&level| level <= pick)
}

/// Default tier when the user hasn't picked one. `Max` is opt-in, so the implicit ceiling is
/// the highest non-`Max` supported level.
pub(crate) fn default_effort(self) -> Option<Effort> {
self.supported_efforts
.iter()
.copied()
.rev()
.find(|&level| level != Effort::Max)
}

/// Clamps `pick` when present, otherwise falls back to [`Self::default_effort`].
/// Clamps `pick` when present, otherwise falls back to the model's stock `default_effort`.
pub(crate) fn resolve_effort(self, pick: Option<Effort>) -> Option<Effort> {
match pick {
Some(p) => self.clamp_effort(p),
None => self.default_effort(),
None => self.default_effort,
}
}
}
Expand Down Expand Up @@ -279,10 +286,10 @@ mod tests {

#[test]
fn capability_flags_match_upstream_substring_predicates() {
// Locks substring-derived flags to upstream's `modelSupports*` predicates. Opus 4.7
// postdates the predicate set and is skipped.
// Locks substring-derived flags to upstream's `modelSupports*` predicates. Opus 4.7 and
// 4.8 postdate the predicate set and are skipped.
for info in MODELS {
if info.id_substr == "claude-opus-4-7" {
if matches!(info.id_substr, "claude-opus-4-7" | "claude-opus-4-8") {
continue;
}
let m = info.id_substr;
Expand All @@ -306,37 +313,38 @@ mod tests {
}

#[test]
fn opus_4_7_uniquely_supports_xhigh() {
// Upstream predates 4.7; pin so a future "alignment" edit doesn't strip our caps.
let caps = lookup("claude-opus-4-7").unwrap().capabilities;
assert!(caps.interleaved_thinking);
assert!(caps.context_management);
assert!(caps.context_1m);
assert!(caps.accepts_effort(Effort::Xhigh));
assert!(caps.accepts_effort(Effort::Max));
assert!(caps.structured_outputs);
fn opus_4_7_and_4_8_support_the_full_effort_ladder() {
// Upstream predates 4.7 / 4.8; pin so a future "alignment" edit doesn't strip our caps.
for id in ["claude-opus-4-7", "claude-opus-4-8"] {
let caps = lookup(id).unwrap().capabilities;
assert!(caps.interleaved_thinking, "{id}");
assert!(caps.context_management, "{id}");
assert!(caps.context_1m, "{id}");
assert!(caps.accepts_effort(Effort::Xhigh), "{id}");
assert!(caps.accepts_effort(Effort::Max), "{id}");
assert!(caps.structured_outputs, "{id}");
}

for other in [
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
"claude-opus-4-1",
] {
assert!(
!lookup(other)
.unwrap()
.capabilities
.accepts_effort(Effort::Xhigh),
"{other} must not accept Xhigh — it 400s on non-4.7",
"{other} must not accept Xhigh — it 400s on pre-4.7 models",
);
}
}

#[test]
fn effort_max_is_opus_only() {
for supported in ["claude-opus-4-7", "claude-opus-4-6"] {
for supported in ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6"] {
assert!(
lookup(supported)
.unwrap()
Expand Down Expand Up @@ -378,6 +386,7 @@ mod tests {
#[test]
fn structured_outputs_flag_tracks_upstream_allowlist() {
for supported in [
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
Expand Down Expand Up @@ -455,20 +464,16 @@ mod tests {
// ── Capabilities::default_effort ──

#[test]
fn default_effort_picks_highest_supported_tier_when_user_has_no_pick() {
// Opus 4.7: full ladder → xhigh.
let opus_4_7 = lookup("claude-opus-4-7").unwrap().capabilities;
assert_eq!(opus_4_7.default_effort(), Some(Effort::Xhigh));

// Opus 4.6 / Sonnet 4.6: effort but no xhigh → high.
for id in ["claude-opus-4-6", "claude-sonnet-4-6"] {
let caps = lookup(id).unwrap().capabilities;
assert_eq!(caps.default_effort(), Some(Effort::High), "{id}");
fn default_effort_stays_within_supported_efforts() {
// Each row's stock default must be a tier the model accepts (or `None` when it rejects
// effort), so resolve_effort never emits a level the API would 400 on.
for info in MODELS {
let caps = info.capabilities;
match caps.default_effort {
Some(level) => assert!(caps.accepts_effort(level), "{}", info.id_substr),
None => assert!(!caps.has_effort(), "{}", info.id_substr),
}
}

// No effort tier at all → None.
let haiku_4_5 = lookup("claude-haiku-4-5").unwrap().capabilities;
assert_eq!(haiku_4_5.default_effort(), None);
}

// ── Capabilities::resolve_effort ──
Expand All @@ -493,11 +498,13 @@ mod tests {
}

#[test]
fn resolve_effort_falls_back_to_model_default_when_pick_is_none() {
fn resolve_effort_falls_back_to_per_model_default_when_pick_is_none() {
// 4.7 and 4.8 share the full ladder but default differently: 4.7 ships xhigh, 4.8 rides
// the high default (mirrors Claude Code).
let opus_4_7 = lookup("claude-opus-4-7").unwrap().capabilities;
assert_eq!(opus_4_7.resolve_effort(None), Some(Effort::Xhigh));
let sonnet_4_6 = lookup("claude-sonnet-4-6").unwrap().capabilities;
assert_eq!(sonnet_4_6.resolve_effort(None), Some(Effort::High));
let opus_4_8 = lookup("claude-opus-4-8").unwrap().capabilities;
assert_eq!(opus_4_8.resolve_effort(None), Some(Effort::High));
}

#[test]
Expand Down Expand Up @@ -540,6 +547,7 @@ mod tests {
"claude-sonnet-4",
"claude-haiku-4",
"claude-opus-4-20250514",
"claude-opus-4-1",
"gpt-4",
] {
assert!(lookup(unknown).is_none(), "{unknown} must not resolve");
Expand Down Expand Up @@ -589,20 +597,6 @@ mod tests {
assert!((one_hour - 40.5).abs() < 1e-9);
}

#[test]
fn token_cost_rates_for_opus_4_1_uses_older_pricing() {
let rates = token_cost_rates_for("claude-opus-4-1").unwrap();
let cost = rates.estimate_usd(
1_000_000,
1_000_000,
1_000_000,
1_000_000,
PromptCacheTtl::FiveMin,
);

assert!((cost - 110.25).abs() < 1e-9);
}

#[test]
fn token_cost_rates_for_unknown_model_is_absent() {
assert!(token_cost_rates_for("claude-future-9").is_none());
Expand All @@ -613,13 +607,13 @@ mod tests {
#[test]
fn display_name_known_plain_id_renders_row_label() {
for (id, expected) in [
("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-sonnet-4-6", "Claude Sonnet 4.6"),
("claude-opus-4-5", "Claude Opus 4.5"),
("claude-sonnet-4-5", "Claude Sonnet 4.5"),
("claude-haiku-4-5", "Claude Haiku 4.5"),
("claude-opus-4-1", "Claude Opus 4.1"),
] {
assert_eq!(display_name(id), expected, "{id}");
}
Expand Down Expand Up @@ -649,10 +643,10 @@ mod tests {
#[test]
fn short_display_name_strips_claude_family_prefix() {
for (id, expected) in [
("claude-opus-4-8", "Opus 4.8"),
("claude-opus-4-7", "Opus 4.7"),
("claude-sonnet-4-6", "Sonnet 4.6"),
("claude-haiku-4-5", "Haiku 4.5"),
("claude-opus-4-1", "Opus 4.1"),
] {
assert_eq!(short_display_name(id), expected, "{id}");
}
Expand Down
8 changes: 0 additions & 8 deletions crates/oxide-code/src/model/pricing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ pub(super) const OPUS_4_5_PLUS_RATES: TokenCostRates = TokenCostRates {
output: 25.0,
};

pub(super) const OPUS_4_1_RATES: TokenCostRates = TokenCostRates {
input: 15.0,
cache_write_5m: 18.75,
cache_write_1h: 30.0,
cache_read: 1.50,
output: 75.0,
};

pub(super) const SONNET_RATES: TokenCostRates = TokenCostRates {
input: 3.0,
cache_write_5m: 3.75,
Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/src/prompt/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ mod tests {
#[test]
fn knowledge_cutoff_known_models() {
for (id, expected) in [
("claude-opus-4-8", "January 2026"),
("claude-opus-4-7", "January 2026"),
("claude-sonnet-4-6", "August 2025"),
("claude-opus-4-6", "May 2025"),
Expand Down
Loading