Skip to content
Merged
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
23 changes: 17 additions & 6 deletions crates/goose-provider-types/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use crate::request_log::LogError;

#[derive(Error, Debug, Clone, PartialEq)]
pub enum ProviderError {
#[error("Provider is not configured")]
NotConfigured,

#[error("Authentication error: {0}")]
Authentication(String),

Expand Down Expand Up @@ -59,6 +62,7 @@ impl ProviderError {

pub fn telemetry_type(&self) -> &'static str {
match self {
ProviderError::NotConfigured => "not_configured",
ProviderError::Authentication(_) => "auth",
ProviderError::ContextLengthExceeded(_) => "context_length",
ProviderError::RateLimitExceeded { .. } => "rate_limit",
Expand Down Expand Up @@ -131,16 +135,23 @@ fn provider_error_from_reqwest(error: &reqwest::Error) -> ProviderError {

impl From<anyhow::Error> for ProviderError {
fn from(error: anyhow::Error) -> Self {
if let Some(provider_error) = error.downcast_ref::<ProviderError>() {
if let Some(provider_error) = error
.chain()
.find_map(|cause| cause.downcast_ref::<ProviderError>())
{
return provider_error.clone();
}
if let Some(reqwest_err) = error.downcast_ref::<reqwest::Error>() {
if let Some(reqwest_err) = error
.chain()
.find_map(|cause| cause.downcast_ref::<reqwest::Error>())
{
return provider_error_from_reqwest(reqwest_err);
}
if error
.downcast_ref::<tokio::time::error::Elapsed>()
.is_some()
{
if error.chain().any(|cause| {
cause
.downcast_ref::<tokio::time::error::Elapsed>()
.is_some()
}) {
return ProviderError::NetworkError(
"Request timed out — check your network connection and try again.".to_string(),
);
Expand Down
19 changes: 15 additions & 4 deletions crates/goose/src/acp/server/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,10 +478,21 @@ impl GooseAcpAgent {
.create_provider(&req.provider_id, Vec::new(), None)
.await
.internal_err_ctx("Failed to initialize provider")?;
let models = provider
.fetch_supported_models()
.await
.internal_err_ctx("Failed to fetch provider supported models")?;
let models = match provider.fetch_supported_models().await {
Ok(models) => models,
Err(goose_providers::errors::ProviderError::Authentication(error)) => {
return Err(agent_client_protocol::Error::auth_required().data(error));
}
Err(goose_providers::errors::ProviderError::NotConfigured) => {
return Err(agent_client_protocol::Error::invalid_params()

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.

Maybe the AuthRequired variant?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You mean add Authenitcated error as an arm here? technically in our spreak they are different things, so mapping one on the other doesn't seem precise. I'll add Authenticated though so the client can distinguish

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

did that

.data(format!("Provider is not configured: {}", req.provider_id)));
}
Err(error) => {
return Err(agent_client_protocol::Error::internal_error().data(format!(
"Failed to fetch provider supported models: {error}"
)));
}
};

Ok(ProviderSupportedModelsListResponse {
provider_id: req.provider_id,
Expand Down
3 changes: 3 additions & 0 deletions crates/goose/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,9 @@ async fn try_other_providers(

fn describe_error(e: &ProviderError) -> String {
match e {
ProviderError::NotConfigured => {
"Provider is not configured. Run `goose configure` to set it up.".to_string()
}
ProviderError::Authentication(_) => {
"Authentication failed — check your API key. Run `goose configure` to update it."
.to_string()
Expand Down
139 changes: 110 additions & 29 deletions crates/goose/src/providers/githubcopilot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use crate::providers::openai_compatible::{
handle_status, stream_openai_compat, stream_responses_compat,
};
use crate::providers::private_file::write_private_file;
use anyhow::{anyhow, Context, Result};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use axum::http;
use chrono::{DateTime, Utc};
Expand Down Expand Up @@ -288,7 +288,7 @@ impl GithubCopilotProvider {
.map_err(|e| e.into())
}

async fn get_api_info(&self) -> Result<(String, String)> {
async fn get_api_info(&self) -> Result<(String, String), ProviderError> {
let guard = self.mu.lock().await;

if let Some(state) = guard.borrow().as_ref() {
Expand All @@ -306,53 +306,64 @@ impl GithubCopilotProvider {
}
}

let config = Config::global();
let github_token = match config.get_secret::<String>("GITHUB_COPILOT_TOKEN") {
Ok(token) => token,
Err(ConfigError::NotFound(_)) => return Err(ProviderError::NotConfigured),
Err(error) => return Err(ProviderError::ExecutionError(error.to_string())),
};

const MAX_ATTEMPTS: i32 = 3;
let mut last_error = None;
for attempt in 0..MAX_ATTEMPTS {
tracing::trace!("attempt {} to refresh api info", attempt + 1);
let info = match self.refresh_api_info().await {
let info = match self.refresh_api_info(&github_token).await {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return auth_required for revoked Copilot tokens

When the saved GITHUB_COPILOT_TOKEN has been revoked or expires, this new model-discovery path calls refresh_api_info(&github_token), but that helper still uses error_for_status()?, which is converted by ProviderError::from(reqwest::Error) into RequestFailed for 401/403 responses rather than Authentication. As a result, on_list_provider_supported_models falls through to internal_error instead of the new auth_required branch, so clients cannot trigger the explicit sign-in flow for stale Copilot credentials; classify 401/403 from the Copilot token endpoint as ProviderError::Authentication before returning it.

Useful? React with 👍 / 👎.

Ok(data) => data,
Err(err) => {
tracing::warn!("failed to refresh api info: {}", err);
last_error = Some(err);
continue;
}
};
let expires_at = Utc::now() + chrono::Duration::seconds(info.refresh_in);
let new_state = CopilotState { info, expires_at };
self.cache.save(&new_state).await?;
self.cache
.save(&new_state)
.await
.map_err(ProviderError::from)?;
guard.replace(Some(new_state.clone()));
return Ok((new_state.info.endpoints.api, new_state.info.token));
}
Err(anyhow!("failed to get api info after 3 attempts"))
Err(last_error.unwrap())
}

async fn refresh_api_info(&self) -> Result<CopilotTokenInfo> {
let config = Config::global();
let token = match config.get_secret::<String>("GITHUB_COPILOT_TOKEN") {
Ok(token) => token,
Err(err) => match err {
ConfigError::NotFound(_) => {
let token = self
.get_access_token()
.await
.context("unable to login into github")?;
config.set_secret("GITHUB_COPILOT_TOKEN", &token)?;
token
}
_ => return Err(err.into()),
},
};
let resp = self
async fn refresh_api_info(
&self,
github_token: &str,
) -> Result<CopilotTokenInfo, ProviderError> {
let response = self
.client
.get(&self.urls.copilot_token_url)
.headers(self.get_github_headers())
.header(http::header::AUTHORIZATION, format!("bearer {}", &token))
.header(
http::header::AUTHORIZATION,
format!("bearer {github_token}"),
)
.send()
.await?
.error_for_status()?
.text()
.await?;
if matches!(
response.status(),
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
) {
return Err(ProviderError::Authentication(format!(
"GitHub Copilot token request failed ({})",
response.status()
)));
}
let resp = response.error_for_status()?.text().await?;
tracing::trace!("copilot token response: {}", resp);
let info: CopilotTokenInfo = serde_json::from_str(&resp)?;
let info: CopilotTokenInfo = serde_json::from_str(&resp)
.map_err(|error| ProviderError::RequestFailed(error.to_string()))?;
Ok(info)
}

Expand Down Expand Up @@ -659,8 +670,8 @@ impl Provider for GithubCopilotProvider {
async fn configure_oauth(&self) -> Result<(), ProviderError> {
let config = Config::global();

if config.get_secret::<String>("GITHUB_COPILOT_TOKEN").is_ok() {
match self.refresh_api_info().await {
if let Ok(github_token) = config.get_secret::<String>("GITHUB_COPILOT_TOKEN") {
match self.refresh_api_info(&github_token).await {
Ok(_) => return Ok(()),
Err(_) => {
tracing::debug!("Existing token is invalid, starting OAuth flow");
Expand Down Expand Up @@ -720,6 +731,8 @@ fn promote_tool_choice(response: Value) -> Value {
mod tests {
use super::*;
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[cfg(unix)]
#[tokio::test]
Expand Down Expand Up @@ -757,6 +770,74 @@ mod tests {
assert_eq!(saved.info.token, "copilot-secret");
}

#[tokio::test]
async fn get_api_info_uses_valid_cache_without_github_token() {
let directory = tempfile::tempdir().unwrap();
let cache = DiskCache {
cache_path: directory.path().join("info.json"),
};
let state = CopilotState {
expires_at: Utc::now() + chrono::Duration::minutes(10),
info: CopilotTokenInfo {
token: "copilot-secret".to_string(),
expires_at: 1,
refresh_in: 600,
endpoints: CopilotTokenEndpoints {
api: "https://api.githubcopilot.com".to_string(),
_extra: HashMap::new(),
},
_extra: HashMap::new(),
},
};
cache.save(&state).await.unwrap();
let provider = GithubCopilotProvider {
client: Client::new(),
cache,
mu: tokio::sync::Mutex::new(RefCell::new(None)),
urls: GithubCopilotUrls::new("github.com", None),
client_id: DEFAULT_GITHUB_COPILOT_CLIENT_ID.to_string(),
name: GITHUB_COPILOT_PROVIDER_NAME.to_string(),
tls_config: None,
};

let (endpoint, token) = provider.get_api_info().await.unwrap();

assert_eq!(endpoint, "https://api.githubcopilot.com");
assert_eq!(token, "copilot-secret");
}

#[tokio::test]
async fn refresh_api_info_returns_authentication_for_rejected_token() {
for status in [401, 403] {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/copilot-token"))
.respond_with(ResponseTemplate::new(status))
.mount(&server)
.await;
let directory = tempfile::tempdir().unwrap();
let provider = GithubCopilotProvider {
client: Client::new(),
cache: DiskCache {
cache_path: directory.path().join("info.json"),
},
mu: tokio::sync::Mutex::new(RefCell::new(None)),
urls: GithubCopilotUrls {
device_code_url: String::new(),
access_token_url: String::new(),
copilot_token_url: format!("{}/copilot-token", server.uri()),
},
client_id: DEFAULT_GITHUB_COPILOT_CLIENT_ID.to_string(),
name: GITHUB_COPILOT_PROVIDER_NAME.to_string(),
tls_config: None,
};

let error = provider.refresh_api_info("rejected").await.unwrap_err();

assert!(matches!(error, ProviderError::Authentication(_)));
}
}

#[test]
fn responses_models_routed_correctly() {
assert!(is_openai_responses_model("gpt-5.5"));
Expand Down
38 changes: 32 additions & 6 deletions crates/goose/src/providers/inventory/registrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use crate::providers::gemini_oauth::TokenCache as GeminiOAuthTokenCache;
use crate::providers::google::{GOOGLE_API_HOST, GOOGLE_PROVIDER_NAME};
use crate::providers::huggingface::HuggingFaceProvider;
use crate::providers::huggingface_auth;
use crate::providers::kimicode::KIMI_CONFIGURED_MARKER;
use crate::providers::kimicode;
use crate::providers::ollama::OLLAMA_PROVIDER_NAME;
use crate::providers::openai::{OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_PROVIDER_NAME};
use crate::providers::pi_acp::{PI_ACP_BINARY, PI_ACP_PROVIDER_NAME};
Expand Down Expand Up @@ -196,11 +196,7 @@ pub fn refresh_only() -> InventoryRegistration {
}

pub fn kimi_code_inventory() -> InventoryRegistration {
refresh_only().with_configured(|| {
Config::global()
.get_param::<bool>(KIMI_CONFIGURED_MARKER)
.unwrap_or(false)
})
refresh_only().with_configured(kimicode::has_configured_token)
}

pub fn chatgpt_codex_inventory() -> InventoryRegistration {
Expand Down Expand Up @@ -325,4 +321,34 @@ mod tests {

assert!(configured());
}

#[test]
#[serial_test::serial]
fn kimi_code_inventory_configured_uses_token_cache() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().to_string_lossy().to_string();
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(root_path.as_str()))]);

let registration = kimi_code_inventory();
let configured = registration
.configured
.expect("Kimi Code should define configured resolver");

assert!(!configured());

let cache_path = Paths::in_config_dir("kimicode/token.json");
std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
std::fs::write(
cache_path,
serde_json::to_string(&serde_json::json!({
"access_token": "access",
"refresh_token": "refresh",
"expires_at": (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
}))
.unwrap(),
)
.unwrap();

assert!(configured());
}
}
Loading
Loading