From d18bb6e512a176f3e685054647296613d4ee8b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EB=8C=80=ED=9D=AC?= Date: Wed, 22 Apr 2026 17:33:26 +0900 Subject: [PATCH] refactor(providers): extract http_status module and rename handle_status_openai_compat (#8620) Signed-off-by: DaeHee Lee --- crates/goose/src/providers/anthropic.rs | 4 +- crates/goose/src/providers/chatgpt_codex.rs | 4 +- crates/goose/src/providers/databricks.rs | 4 +- crates/goose/src/providers/githubcopilot.rs | 4 +- crates/goose/src/providers/google.rs | 4 +- crates/goose/src/providers/http_status.rs | 115 ++++++++++++++++++ crates/goose/src/providers/kimicode.rs | 6 +- crates/goose/src/providers/mod.rs | 1 + crates/goose/src/providers/nanogpt.rs | 4 +- crates/goose/src/providers/ollama.rs | 4 +- crates/goose/src/providers/openai.rs | 6 +- .../goose/src/providers/openai_compatible.rs | 114 ++--------------- crates/goose/src/providers/openrouter.rs | 4 +- crates/goose/src/providers/tetrate.rs | 4 +- 14 files changed, 149 insertions(+), 129 deletions(-) create mode 100644 crates/goose/src/providers/http_status.rs diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 01bcb0401a..2a97b2ade7 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -15,7 +15,7 @@ use super::formats::anthropic::{ create_request, response_to_streaming_message, thinking_type, ThinkingType, }; use super::inventory::{config_secret_value, serialize_string_map, InventoryIdentityInput}; -use super::openai_compatible::handle_status_openai_compat; +use super::openai_compatible::handle_status; use super::openai_compatible::map_http_error_to_provider_error; use super::retry::ProviderRetry; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -322,7 +322,7 @@ impl Provider for AnthropicProvider { request = request.header(key, value)?; } let resp = request.response_post(&payload).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 05c6255350..2aca9799c4 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -5,7 +5,7 @@ use crate::providers::api_client::AuthProvider; use crate::providers::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use crate::providers::errors::ProviderError; use crate::providers::formats::openai_responses::responses_api_to_streaming_message; -use crate::providers::openai_compatible::handle_status_openai_compat; +use crate::providers::openai_compatible::handle_status; use crate::providers::retry::ProviderRetry; use crate::session_context::SESSION_ID_HEADER; use anyhow::{anyhow, Result}; @@ -928,7 +928,7 @@ impl ChatGptCodexProvider { .await .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; - handle_status_openai_compat(response).await + handle_status(response).await } } diff --git a/crates/goose/src/providers/databricks.rs b/crates/goose/src/providers/databricks.rs index 3dd342210e..5fc7b09ac5 100644 --- a/crates/goose/src/providers/databricks.rs +++ b/crates/goose/src/providers/databricks.rs @@ -22,7 +22,7 @@ use super::formats::openai_responses::{ }; use super::oauth; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + handle_response_openai_compat, handle_status, map_http_error_to_provider_error, stream_openai_compat, }; use super::retry::ProviderRetry; @@ -396,7 +396,7 @@ impl Provider for DatabricksProvider { .api_client .response_post(Some(session_id), &path, &payload_clone) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/githubcopilot.rs b/crates/goose/src/providers/githubcopilot.rs index 34211075f6..c65de1637e 100644 --- a/crates/goose/src/providers/githubcopilot.rs +++ b/crates/goose/src/providers/githubcopilot.rs @@ -1,7 +1,7 @@ use crate::config::paths::Paths; use crate::providers::api_client::{ApiClient, AuthMethod}; use crate::providers::oauth_device_flow::{run_device_flow, DeviceFlowConfig, RequestEncoding}; -use crate::providers::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use crate::providers::openai_compatible::{handle_status, stream_openai_compat}; use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use axum::http; @@ -434,7 +434,7 @@ impl Provider for GithubCopilotProvider { .with_retry(|| async { let mut payload_clone = payload.clone(); let resp = self.post(Some(session_id), &mut payload_clone).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/google.rs b/crates/goose/src/providers/google.rs index 1dbc533591..2824bf1a45 100644 --- a/crates/goose/src/providers/google.rs +++ b/crates/goose/src/providers/google.rs @@ -1,7 +1,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::MessageStream; use super::errors::ProviderError; -use super::openai_compatible::handle_status_openai_compat; +use super::openai_compatible::handle_status; use super::retry::ProviderRetry; use super::utils::RequestLog; use crate::conversation::message::Message; @@ -101,7 +101,7 @@ impl GoogleProvider { .api_client .response_post(session_id, &path, payload) .await?; - handle_status_openai_compat(response).await + handle_status(response).await } } diff --git a/crates/goose/src/providers/http_status.rs b/crates/goose/src/providers/http_status.rs new file mode 100644 index 0000000000..fe2751c28e --- /dev/null +++ b/crates/goose/src/providers/http_status.rs @@ -0,0 +1,115 @@ +//! Format-agnostic HTTP status → `ProviderError` mapping. +//! +//! Used by providers regardless of their wire format (OpenAI, Anthropic, +//! Google, etc.). Parses both `{"error":{"message":"..."}}` and +//! `{"message":"..."}` error shapes. + +use reqwest::{Response, StatusCode}; +use serde_json::Value; + +use super::errors::ProviderError; + +fn check_context_length_exceeded(text: &str) -> bool { + let check_phrases = [ + "too long", + "context length", + "context_length_exceeded", + "reduce the length", + "token count", + "exceeds", + "exceed context limit", + "input length", + "max_tokens", + "decrease input length", + "context limit", + "maximum prompt length", + ]; + let text_lower = text.to_lowercase(); + check_phrases + .iter() + .any(|phrase| text_lower.contains(phrase)) +} + +pub fn map_http_error_to_provider_error( + status: StatusCode, + payload: Option, +) -> ProviderError { + let extract_message = || -> String { + payload + .as_ref() + .and_then(|p| { + p.get("error") + .and_then(|e| e.get("message")) + .or_else(|| p.get("message")) + .and_then(|m| m.as_str()) + .map(String::from) + }) + .unwrap_or_else(|| payload.as_ref().map(|p| p.to_string()).unwrap_or_default()) + }; + + let error = match status { + StatusCode::OK => unreachable!("Should not call this function with OK status"), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::Authentication(format!( + "Authentication failed. Status: {}. Response: {}", + status, + extract_message() + )), + StatusCode::NOT_FOUND => { + ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message())) + } + StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted { + details: extract_message(), + top_up_url: None, + }, + StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), + StatusCode::BAD_REQUEST => { + let payload_str = extract_message(); + if check_context_length_exceeded(&payload_str) { + ProviderError::ContextLengthExceeded(payload_str) + } else { + ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) + } + } + StatusCode::TOO_MANY_REQUESTS => ProviderError::RateLimitExceeded { + details: extract_message(), + retry_delay: None, + }, + _ if status.is_server_error() => { + ProviderError::ServerError(format!("Server error ({}): {}", status, extract_message())) + } + _ => ProviderError::RequestFailed(format!( + "Request failed with status {}: {}", + status, + extract_message() + )), + }; + + if !status.is_success() { + tracing::warn!( + "Provider request failed with status: {}. Payload: {:?}. Returning error: {:?}", + status, + payload, + error + ); + } + + error +} + +pub async fn handle_status(response: Response) -> Result { + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let payload = serde_json::from_str::(&body).ok(); + return Err(map_http_error_to_provider_error(status, payload)); + } + Ok(response) +} + +pub async fn handle_response(response: Response) -> Result { + let response = handle_status(response).await?; + + response.json::().await.map_err(|e| { + ProviderError::RequestFailed(format!("Response body is not valid JSON: {}", e)) + }) +} diff --git a/crates/goose/src/providers/kimicode.rs b/crates/goose/src/providers/kimicode.rs index 2187d739d1..2a46e14f8a 100644 --- a/crates/goose/src/providers/kimicode.rs +++ b/crates/goose/src/providers/kimicode.rs @@ -22,7 +22,7 @@ use super::formats::anthropic::{create_request, response_to_streaming_message}; use super::oauth_device_flow::{ refresh_device_flow_token, run_device_flow, DeviceFlowConfig, DeviceFlowTokens, RequestEncoding, }; -use super::openai_compatible::handle_status_openai_compat; +use super::openai_compatible::handle_status; use super::retry::ProviderRetry; use super::utils::RequestLog; use crate::conversation::message::Message; @@ -403,7 +403,7 @@ impl Provider for KimiCodeProvider { let response = self .with_retry(|| async { let resp = self.post(Some(session_id), &payload).await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -454,7 +454,7 @@ impl Provider for KimiCodeProvider { .send() .await .map_err(|e| ProviderError::RequestFailed(e.to_string()))?; - let resp = handle_status_openai_compat(resp).await?; + let resp = handle_status(resp).await?; let parsed: ModelsResp = resp.json().await.map_err(|e| { ProviderError::RequestFailed(format!("/v1/models body is not valid JSON: {}", e)) diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index 2cc132771b..6781c2a986 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -28,6 +28,7 @@ pub mod gemini_cli; pub mod gemini_oauth; pub mod githubcopilot; pub mod google; +pub mod http_status; mod init; pub mod inventory; pub mod kimicode; diff --git a/crates/goose/src/providers/nanogpt.rs b/crates/goose/src/providers/nanogpt.rs index 7de1a69b1e..fa2bb386fd 100644 --- a/crates/goose/src/providers/nanogpt.rs +++ b/crates/goose/src/providers/nanogpt.rs @@ -1,7 +1,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; use super::utils::{ImageFormat, RequestLog}; use crate::conversation::message::Message; @@ -191,7 +191,7 @@ impl Provider for NanoGptProvider { .api_client .response_post(Some(session_id), "chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/ollama.rs b/crates/goose/src/providers/ollama.rs index fd23767f72..e29901d6f3 100644 --- a/crates/goose/src/providers/ollama.rs +++ b/crates/goose/src/providers/ollama.rs @@ -2,7 +2,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; use super::inventory::InventoryIdentityInput; -use super::openai_compatible::handle_status_openai_compat; +use super::openai_compatible::handle_status; use super::retry::{ProviderRetry, RetryConfig}; use super::utils::{ImageFormat, RequestLog}; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -324,7 +324,7 @@ impl Provider for OllamaProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/openai.rs b/crates/goose/src/providers/openai.rs index f3174ae1be..5eef3baded 100644 --- a/crates/goose/src/providers/openai.rs +++ b/crates/goose/src/providers/openai.rs @@ -9,7 +9,7 @@ use super::formats::openai_responses::{ }; use super::inventory::{config_secret_value, InventoryIdentityInput}; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, + handle_response_openai_compat, handle_status, stream_openai_compat, }; use super::retry::ProviderRetry; use super::utils::ImageFormat; @@ -579,7 +579,7 @@ impl Provider for OpenAiProvider { &payload_clone, ) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -644,7 +644,7 @@ impl Provider for OpenAiProvider { .api_client .response_post(Some(session_id), &self.base_path, &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/openai_compatible.rs b/crates/goose/src/providers/openai_compatible.rs index 2250728438..1028e5079e 100644 --- a/crates/goose/src/providers/openai_compatible.rs +++ b/crates/goose/src/providers/openai_compatible.rs @@ -1,7 +1,9 @@ use anyhow::Error; use async_stream::try_stream; use futures::TryStreamExt; -use reqwest::{Response, StatusCode}; +use reqwest::Response; +#[cfg(test)] +use reqwest::StatusCode; use serde_json::Value; use tokio::pin; use tokio_stream::StreamExt; @@ -117,7 +119,7 @@ impl Provider for OpenAiCompatibleProvider { .api_client .response_post(Some(session_id), &completions_path, &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { @@ -128,110 +130,12 @@ impl Provider for OpenAiCompatibleProvider { } } -fn check_context_length_exceeded(text: &str) -> bool { - let check_phrases = [ - "too long", - "context length", - "context_length_exceeded", - "reduce the length", - "token count", - "exceeds", - "exceed context limit", - "input length", - "max_tokens", - "decrease input length", - "context limit", - "maximum prompt length", - ]; - let text_lower = text.to_lowercase(); - check_phrases - .iter() - .any(|phrase| text_lower.contains(phrase)) -} +// Re-exported from the dedicated `http_status` module — these helpers are +// format-agnostic and used across all provider families. +pub use super::http_status::{handle_response, handle_status, map_http_error_to_provider_error}; -pub fn map_http_error_to_provider_error( - status: StatusCode, - payload: Option, -) -> ProviderError { - let extract_message = || -> String { - payload - .as_ref() - .and_then(|p| { - p.get("error") - .and_then(|e| e.get("message")) - .or_else(|| p.get("message")) - .and_then(|m| m.as_str()) - .map(String::from) - }) - .unwrap_or_else(|| payload.as_ref().map(|p| p.to_string()).unwrap_or_default()) - }; - - let error = match status { - StatusCode::OK => unreachable!("Should not call this function with OK status"), - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderError::Authentication(format!( - "Authentication failed. Status: {}. Response: {}", - status, - extract_message() - )), - StatusCode::NOT_FOUND => { - ProviderError::RequestFailed(format!("Resource not found (404): {}", extract_message())) - } - StatusCode::PAYMENT_REQUIRED => ProviderError::CreditsExhausted { - details: extract_message(), - top_up_url: None, - }, - StatusCode::PAYLOAD_TOO_LARGE => ProviderError::ContextLengthExceeded(extract_message()), - StatusCode::BAD_REQUEST => { - let payload_str = extract_message(); - if check_context_length_exceeded(&payload_str) { - ProviderError::ContextLengthExceeded(payload_str) - } else { - ProviderError::RequestFailed(format!("Bad request (400): {}", payload_str)) - } - } - StatusCode::TOO_MANY_REQUESTS => ProviderError::RateLimitExceeded { - details: extract_message(), - retry_delay: None, - }, - _ if status.is_server_error() => { - ProviderError::ServerError(format!("Server error ({}): {}", status, extract_message())) - } - _ => ProviderError::RequestFailed(format!( - "Request failed with status {}: {}", - status, - extract_message() - )), - }; - - if !status.is_success() { - tracing::warn!( - "Provider request failed with status: {}. Payload: {:?}. Returning error: {:?}", - status, - payload, - error - ); - } - - error -} - -pub async fn handle_status_openai_compat(response: Response) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - let payload = serde_json::from_str::(&body).ok(); - return Err(map_http_error_to_provider_error(status, payload)); - } - Ok(response) -} - -pub async fn handle_response_openai_compat(response: Response) -> Result { - let response = handle_status_openai_compat(response).await?; - - response.json::().await.map_err(|e| { - ProviderError::RequestFailed(format!("Response body is not valid JSON: {}", e)) - }) -} +// Legacy alias kept for callers that haven't migrated their import path yet. +pub use super::http_status::handle_response as handle_response_openai_compat; pub fn stream_openai_compat( response: Response, diff --git a/crates/goose/src/providers/openrouter.rs b/crates/goose/src/providers/openrouter.rs index 1b00d7d9ba..08b8689b99 100644 --- a/crates/goose/src/providers/openrouter.rs +++ b/crates/goose/src/providers/openrouter.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; -use super::openai_compatible::{handle_status_openai_compat, stream_openai_compat}; +use super::openai_compatible::{handle_status, stream_openai_compat}; use super::retry::ProviderRetry; use super::utils::{ImageFormat, RequestLog}; use crate::conversation::message::Message; @@ -291,7 +291,7 @@ impl Provider for OpenRouterProvider { .api_client .response_post(Some(session_id), "api/v1/chat/completions", &payload) .await?; - handle_status_openai_compat(resp).await + handle_status(resp).await }) .await .inspect_err(|e| { diff --git a/crates/goose/src/providers/tetrate.rs b/crates/goose/src/providers/tetrate.rs index 98aefefd0d..810246168f 100644 --- a/crates/goose/src/providers/tetrate.rs +++ b/crates/goose/src/providers/tetrate.rs @@ -2,7 +2,7 @@ use super::api_client::{ApiClient, AuthMethod}; use super::base::{ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata}; use super::errors::ProviderError; use super::openai_compatible::{ - handle_response_openai_compat, handle_status_openai_compat, map_http_error_to_provider_error, + handle_response_openai_compat, handle_status, map_http_error_to_provider_error, stream_openai_compat, }; use super::retry::ProviderRetry; @@ -155,7 +155,7 @@ impl Provider for TetrateProvider { .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; - let resp = handle_status_openai_compat(resp) + let resp = handle_status(resp) .await .map_err(Self::enrich_credits_error)?;