Skip to content
Closed
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
85 changes: 81 additions & 4 deletions crates/goose-providers/src/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::request_log::{start_log, LoggerHandleExt};
use anyhow::Result;
use async_stream::try_stream;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::TryStreamExt;
use reqwest::StatusCode;
use serde_json::Value;
Expand All @@ -30,6 +31,8 @@ pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5";
pub const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5";
const ANTHROPIC_KNOWN_MODELS: &[&str] = &[
"claude-opus-5",
"claude-sonnet-5",
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
// Claude 4.6 models
Expand Down Expand Up @@ -239,12 +242,29 @@ impl AnthropicProvider {
)
})?;

let mut models: Vec<String> = arr
let mut models: Vec<(String, Option<DateTime<Utc>>)> = arr
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(str::to_string))
.filter_map(|m| {
let id = m.get("id").and_then(|v| v.as_str())?.to_string();
// Parse created_at into an instant so mixed UTC offsets / fractional seconds
// compare chronologically (lexical RFC 3339 order is not chronological order).
let created_at = m
.get("created_at")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
Some((id, created_at))
})
.collect();
models.sort();
Ok(models)
// Newest first; entries without a parseable date sort last, then by id so the
// ordering stays deterministic.
models.sort_by(|a, b| match (a.1, b.1) {
(Some(da), Some(db)) => db.cmp(&da).then_with(|| a.0.cmp(&b.0)),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.0.cmp(&b.0),
});
Ok(models.into_iter().map(|(id, _)| id).collect())
}
}

Expand Down Expand Up @@ -439,3 +459,60 @@ pub fn from_declarative_config(
.skip_canonical_filtering(config.skip_canonical_filtering)
.format_options(format_options))
}

#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn provider_for(uri: &str) -> AnthropicProvider {
let auth = AuthMethod::ApiKey {
header_name: "x-api-key".to_string(),
key: "test-key".to_string(),
};
let api_client = ApiClient::new_with_tls(uri.to_string(), auth, None)
.unwrap()
.with_header("anthropic-version", ANTHROPIC_API_VERSION)
.unwrap();
AnthropicProviderBuilder::new(api_client).build()
}

#[tokio::test]
async fn fetch_models_from_api_sorts_newest_first() {
let server = MockServer::start().await;
let body = serde_json::json!({
"data": [
{"id": "claude-opus-4-7", "created_at": "2026-04-14T00:00:00Z"},
{"id": "claude-opus-5", "created_at": "2026-07-24T00:00:00Z"},
{"id": "claude-sonnet-5", "created_at": "2026-06-29T00:00:00Z"},
// Lexically sorts after opus-5, but +02:00 makes it 2026-07-23T22:00:00Z —
// chronologically earlier, so it must land below opus-5.
{"id": "claude-offset", "created_at": "2026-07-24T00:00:00+02:00"},
{"id": "legacy-no-date"}
],
"has_more": false
});
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(body))
.mount(&server)
.await;

let models = provider_for(&server.uri())
.fetch_supported_models()
.await
.unwrap();

assert_eq!(
models,
vec![
"claude-opus-5".to_string(),
"claude-offset".to_string(),
"claude-sonnet-5".to_string(),
"claude-opus-4-7".to_string(),
"legacy-no-date".to_string(),
]
);
}
}
57 changes: 56 additions & 1 deletion crates/goose/src/providers/anthropic_def.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use goose_providers::{
api_client::{ApiClient, AuthMethod, TlsConfig},
base::ProviderDescriptor,
};
use url::Url;

pub struct AnthropicProviderDef;

Expand Down Expand Up @@ -39,6 +40,12 @@ async fn from_env(
.get_param("ANTHROPIC_HOST")
.unwrap_or_else(|_| "https://api.anthropic.com".to_string());

// Only trust the raw /v1/models list (skipping canonical capability filtering) for the
// first-party Anthropic endpoint, whose models are all chat/tool-capable. A custom
// ANTHROPIC_HOST may front an Anthropic-compatible proxy that also serves non-chat models,
// so keep the canonical filter there to avoid surfacing models unusable in agent sessions.
let is_first_party_host = is_first_party_anthropic_host(&host);

let auth = AuthMethod::ApiKey {
header_name: "x-api-key".to_string(),
key: api_key,
Expand All @@ -48,7 +55,25 @@ async fn from_env(
.with_request_builder(crate::session_context::session_id_request_builder())
.with_header("anthropic-version", ANTHROPIC_API_VERSION)?;

Ok(AnthropicProviderBuilder::new(api_client).build())
// Anthropic's /v1/models is the authoritative list for a first-party key, so trust it
// rather than filtering through the bundled canonical registry, which lags new releases
// (e.g. Opus 5) and would silently drop models the key can actually use.
Ok(AnthropicProviderBuilder::new(api_client)
.skip_canonical_filtering(is_first_party_host)
.build())
}

/// Compare against the official endpoint on normalized components rather than raw text, so
/// equivalent spellings (`https://API.ANTHROPIC.COM`, `https://api.anthropic.com:443/`) are
/// recognized as first party.
fn is_first_party_anthropic_host(host: &str) -> bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this? Couldn't we check for the specific endpoint we use in our direct-to-anthropic provider?

@kojiromike kojiromike Jul 31, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's pretty much obsolete anyway.

Since #10756 resolved many of the same things as this PR, I think it's OK to close it outright. I can open a followup ticket to address the missing models in ANTHROPIC_KNOWN_MODELS.

let Ok(url) = Url::parse(host) else {
return false;
};
url.scheme() == "https"
&& url.host_str() == Some("api.anthropic.com")
&& url.port_or_known_default() == Some(443)
&& matches!(url.path(), "" | "/")
}

pub fn from_custom_config(
Expand Down Expand Up @@ -183,4 +208,34 @@ mod tests {
base_declarative_config(vec![ModelInfo::new("m1".to_string(), 200000)], Some(false));
assert_eq!(built_timeout(config), std::time::Duration::from_secs(600));
}

#[test]
fn first_party_host_detection_normalizes_equivalent_urls() {
for host in [
"https://api.anthropic.com",
"https://api.anthropic.com/",
"https://API.ANTHROPIC.COM",
"https://api.anthropic.com:443",
"https://API.Anthropic.Com:443/",
] {
assert!(
is_first_party_anthropic_host(host),
"{host} should be recognized as the first-party endpoint"
);
}

for host in [
"http://api.anthropic.com",
"https://api.anthropic.com:8443",
"https://api.anthropic.com/v1/proxy",
"https://proxy.example.com",
"https://api.anthropic.com.evil.example",
"not a url",
] {
assert!(
!is_first_party_anthropic_host(host),
"{host} should not be treated as the first-party endpoint"
);
}
}
}