diff --git a/MIGRATING.md b/MIGRATING.md index 1b91b6d62..175a55c92 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -796,6 +796,43 @@ handed back a silently short list. ## 0.41 → next +### `AuthError::Http` carries `http_client::Error`, and `Authenticator::auth_context` takes the transport + +`rig::providers::copilot::auth::AuthError` / `rig::providers::chatgpt::auth::AuthError` +(`providers::internal::auth::AuthError`) wrapped a raw `reqwest::Error`. The +OAuth/device-code flows now run through the client's own `HttpClientExt` +transport instead of an ad-hoc `reqwest::Client`, so the variant carries the +transport-agnostic `http_client::Error`: a non-success response is one of its +status-bearing variants, a response-less failure is `Instance`. + +```rust +// before +Err(AuthError::Http(e)) => e.status() + +// after +Err(AuthError::Http(e)) => match e { + http_client::Error::InvalidStatusCode(status) + | http_client::Error::InvalidStatusCodeWithMessage(status, _) + | http_client::Error::InvalidStatusCodeWithDetails { status, .. } => Some(status), + _ => None, +} +``` + +`Authenticator::auth_context()` accordingly takes the transport to use: +`auth.auth_context(client.http_client()).await`. The provider clients' +`authorize()` helpers are unchanged. `Client::http_client()` is new: it +borrows the transport a `Client` sends through. + +### `http_client::ReqwestClient` / `from_reqwest` live in a reqwest-only module + +Both still resolve at `rig::http_client::ReqwestClient` and +`rig::http_client::from_reqwest`; they are re-exported from the bundled +reqwest transport module, which is now the only place rig-core names a reqwest +type. `http_client::Error::non_success_with_details(status, headers, body)` is +the new transport-agnostic constructor for the headers-preserving non-success +error — custom `HttpClientExt` implementations should build their errors with +it so `non_success_headers()` keeps working for retry policies. + ### `VectorStoreError::ReqwestError` is now `VectorStoreError::Http(http_client::Error)` The variant carried a raw `reqwest::Error`, which tied rig-core's public diff --git a/crates/rig-core/src/client/mod.rs b/crates/rig-core/src/client/mod.rs index 1cf207c86..5c5e6e9e7 100644 --- a/crates/rig-core/src/client/mod.rs +++ b/crates/rig-core/src/client/mod.rs @@ -492,7 +492,7 @@ pub(crate) use impl_provider_client; /// `new` is pinned to `H = reqwest::Client` so the call site infers without an explicit `H` /// annotation. Callers who want a different backend should go through [`Client::builder`] and /// chain [`ClientBuilder::http_client`] before [`ClientBuilder::build`]. -// bevy-prep: this reqwest-pinned construction surface (together with `builder()`'s inference +// This reqwest-pinned construction surface (together with `builder()`'s inference // anchor and `ClientBuilder::build`'s default backend) relocates to the `rig` facade in the // transport-crate split. impl Client @@ -524,6 +524,13 @@ impl Client { &self.ext } + /// The HTTP transport this client sends through, for callers that must + /// talk to an absolute URL outside the provider's API base (OAuth/device + /// flows) with the same transport. + pub fn http_client(&self) -> &H { + &self.http_client + } + /// Reuse this client's base URL, headers, and HTTP backend with a different extension. pub fn with_ext(self, new_ext: NewExt) -> Client { Client { diff --git a/crates/rig-core/src/http_client/mod.rs b/crates/rig-core/src/http_client/mod.rs index 188208e67..3ccb83704 100644 --- a/crates/rig-core/src/http_client/mod.rs +++ b/crates/rig-core/src/http_client/mod.rs @@ -2,14 +2,13 @@ use crate::http_client::sse::BoxedStream; use bytes::Bytes; pub use http::{HeaderMap, HeaderValue, Method, Request, Response, Uri, request::Builder}; use http::{HeaderName, StatusCode}; -use reqwest::Body; pub mod multipart; +mod reqwest_transport; pub mod retry; pub mod sse; use crate::wasm_compat::*; pub use multipart::MultipartForm; -pub use reqwest::Client as ReqwestClient; -use std::pin::Pin; +pub use reqwest_transport::{ReqwestClient, from_reqwest}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -68,6 +67,18 @@ impl Error { } } + /// Build the headers-preserving non-success error from a failed + /// response's parts. Transports call this with the status, headers and + /// body they read off the wire, so provider layers can recover transport + /// metadata — request ids, rate-limit headers — from the error (rig#2314). + pub fn non_success_with_details(status: StatusCode, headers: HeaderMap, body: String) -> Self { + Self::InvalidStatusCodeWithDetails { + status, + body, + headers: Box::new(headers), + } + } + /// Returns the failed response's headers, when this error preserved them. /// /// Rig's bundled HTTP clients capture the full [`HeaderMap`] whenever a @@ -110,27 +121,10 @@ pub(crate) fn instance_error(error } #[cfg(target_family = "wasm")] -fn instance_error(error: E) -> Error { +pub(crate) fn instance_error(error: E) -> Error { Error::Instance(error.into()) } -async fn non_success_status_error(response: reqwest::Response) -> Error { - let status = response.status(); - // Preserve the failed response's headers: provider layers read their - // request-id contract off them (rig#2314). The Display is identical to - // the header-less variant, so surfaced error text is unchanged. - let headers = Box::new(response.headers().clone()); - let body = response - .text() - .await - .unwrap_or_else(|error| format!("failed to read error response body: {error}")); - Error::InvalidStatusCodeWithDetails { - status, - body, - headers, - } -} - pub type LazyBytes = WasmBoxedFuture<'static, Result>; pub type LazyBody = WasmBoxedFuture<'static, Result>; @@ -145,27 +139,6 @@ impl From for Bytes { } } -impl From for Body { - fn from(_: NoBody) -> Self { - reqwest::Body::default() - } -} - -/// Map a transport-level `reqwest::Error` onto the transport-agnostic -/// [`Error`]. -/// -/// A failure that carries a status (an `error_for_status` rejection) keeps -/// it as [`Error::InvalidStatusCode`] so provider retry and error-inspection -/// paths can still read the code; a response-less failure (connect, decode, -/// timeout) becomes [`Error::Instance`]. -// bevy-prep: moves to `rig-reqwest` in the transport-crate split. -pub fn from_reqwest(err: reqwest::Error) -> Error { - match err.status() { - Some(status) => Error::InvalidStatusCode(status), - None => Error::Instance(Box::new(err)), - } -} - pub async fn text(response: Response>>) -> Result { let text = response.into_body().await?; Ok(String::from(String::from_utf8_lossy(&text))) @@ -217,176 +190,10 @@ pub trait HttpClientExt: WasmCompatSend + WasmCompatSync { T: Into + WasmCompatSend; } -async fn into_lazy_response(response: reqwest::Response) -> Result>> -where - U: From, - U: WasmCompatSend + 'static, -{ - if !response.status().is_success() { - return Err(non_success_status_error(response).await); - } - - let mut res = Response::builder().status(response.status()); - - if let Some(headers) = res.headers_mut() { - *headers = response.headers().clone(); - } - - let body: LazyBody = Box::pin(async { - let bytes = response.bytes().await.map_err(instance_error)?; - Ok(U::from(bytes)) - }); - - res.body(body).map_err(Error::Protocol) -} - -macro_rules! impl_http_client_ext { - ($(#[$attribute:meta])* $client:ty) => { - $(#[$attribute])* - impl HttpClientExt for $client { - fn send( - &self, - req: Request, - ) -> impl Future>>> + WasmCompatSend + 'static - where - T: Into, - U: From + WasmCompatSend + 'static, - { - let (parts, body) = req.into_parts(); - let req = self - .request(parts.method, parts.uri.to_string()) - .headers(parts.headers) - .body(body.into()); - - async move { - let response = req.send().await.map_err(instance_error)?; - into_lazy_response(response).await - } - } - - fn send_multipart( - &self, - req: Request, - ) -> impl Future>>> + WasmCompatSend + 'static - where - U: From, - U: WasmCompatSend + 'static, - { - let (parts, body) = req.into_parts(); - let body = reqwest::multipart::Form::from(body); - - let req = self - .request(parts.method, parts.uri.to_string()) - .headers(parts.headers) - .multipart(body); - - async move { - let response = req.send().await.map_err(instance_error)?; - into_lazy_response(response).await - } - } - - fn send_streaming( - &self, - req: Request, - ) -> impl Future> + WasmCompatSend - where - T: Into + WasmCompatSend, - { - let (parts, body) = req.into_parts(); - - let client = self.clone(); - - async move { - let req = self - .request(parts.method, parts.uri.to_string()) - .headers(parts.headers) - .body(body.into()) - .build() - .map_err(|error| Error::Instance(error.into()))?; - let response: reqwest::Response = - client.execute(req).await.map_err(instance_error)?; - if !response.status().is_success() { - return Err(non_success_status_error(response).await); - } - - #[cfg(not(target_family = "wasm"))] - let mut res = Response::builder() - .status(response.status()) - .version(response.version()); - - #[cfg(target_family = "wasm")] - let mut res = Response::builder().status(response.status()); - - if let Some(hs) = res.headers_mut() { - *hs = response.headers().clone(); - } - - use futures::StreamExt; - - let mapped_stream: Pin< - Box>>, - > = Box::pin( - response - .bytes_stream() - .map(|chunk| chunk.map_err(|e| Error::Instance(Box::new(e)))), - ); - - res.body(mapped_stream).map_err(Error::Protocol) - } - } - } - }; -} - -impl_http_client_ext!(reqwest::Client); - -impl_http_client_ext!( - #[cfg(feature = "reqwest-middleware")] - #[cfg_attr(docsrs, doc(cfg(feature = "reqwest-middleware")))] - reqwest_middleware::ClientWithMiddleware -); - #[cfg(test)] mod non_success_header_tests { use super::*; - /// rig#2210: the bundled transport's own error constructor is where the - /// headers are captured, so drive it with a real `reqwest::Response`. - #[tokio::test] - async fn non_success_status_error_preserves_response_headers() { - let response = http::Response::builder() - .status(StatusCode::TOO_MANY_REQUESTS) - .header("retry-after", "20") - .header("x-ratelimit-remaining", "0") - .body(r#"{"error":{"message":"rate limited"}}"#) - .expect("valid response"); - - let error = non_success_status_error(reqwest::Response::from(response)).await; - - assert_eq!( - error.non_success_status(), - Some(StatusCode::TOO_MANY_REQUESTS) - ); - assert_eq!( - error.non_success_body(), - Some(r#"{"error":{"message":"rate limited"}}"#) - ); - let headers = error - .non_success_headers() - .expect("headers captured at error construction"); - assert_eq!( - headers.get("retry-after").and_then(|v| v.to_str().ok()), - Some("20") - ); - assert_eq!( - headers - .get("x-ratelimit-remaining") - .and_then(|v| v.to_str().ok()), - Some("0") - ); - } - /// `None` means "not captured" and must not be confused with an empty map: /// every other shape of this error reports it. #[test] diff --git a/crates/rig-core/src/http_client/multipart.rs b/crates/rig-core/src/http_client/multipart.rs index af1a759d8..9f1915e1f 100644 --- a/crates/rig-core/src/http_client/multipart.rs +++ b/crates/rig-core/src/http_client/multipart.rs @@ -5,14 +5,14 @@ use std::borrow::Cow; /// A generic multipart form part that can represent text or binary data #[derive(Clone, Debug)] pub struct Part { - name: String, - content: PartContent, - filename: Option, - content_type: Option, + pub(crate) name: String, + pub(crate) content: PartContent, + pub(crate) filename: Option, + pub(crate) content_type: Option, } #[derive(Clone, Debug)] -enum PartContent { +pub(crate) enum PartContent { Text(String), Binary(Bytes), } @@ -69,7 +69,7 @@ impl Part { /// Generic multipart form data container #[derive(Clone, Debug, Default)] pub struct MultipartForm { - parts: Vec, + pub(crate) parts: Vec, boundary: Option, } @@ -183,37 +183,6 @@ impl MultipartForm { } } -impl From for reqwest::multipart::Form { - fn from(value: MultipartForm) -> Self { - let mut form = reqwest::multipart::Form::new(); - - for part in value.parts { - match part.content { - PartContent::Text(text) => { - form = form.text(part.name, text); - } - PartContent::Binary(bytes) => { - let mut req_part = if let Some(content_type) = part.content_type.as_ref() { - reqwest::multipart::Part::bytes(bytes.to_vec()) - .mime_str(content_type.as_ref()) - .unwrap_or_else(|_| reqwest::multipart::Part::bytes(bytes.to_vec())) - } else { - reqwest::multipart::Part::bytes(bytes.to_vec()) - }; - - if let Some(filename) = part.filename { - req_part = req_part.file_name(filename); - } - - form = form.part(part.name, req_part); - } - } - } - - form - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/rig-core/src/http_client/reqwest_transport.rs b/crates/rig-core/src/http_client/reqwest_transport.rs new file mode 100644 index 000000000..f01f8fa11 --- /dev/null +++ b/crates/rig-core/src/http_client/reqwest_transport.rs @@ -0,0 +1,366 @@ +//! The bundled `reqwest` transport: [`HttpClientExt`] for [`reqwest::Client`] +//! (and, behind the `reqwest-middleware` feature, for +//! [`reqwest_middleware::ClientWithMiddleware`]), plus the glue that maps +//! reqwest's types onto the transport-agnostic ones in the parent module. +//! +//! This module is the only place in rig-core that names a reqwest type in +//! non-test code; it is the interim home of everything that moves to a +//! dedicated reqwest transport crate in the transport-crate split. + +use super::{ + Error, HttpClientExt, LazyBody, MultipartForm, NoBody, Request, Response, Result, + StreamingResponse, instance_error, + multipart::{Part, PartContent}, +}; +use crate::wasm_compat::*; +use bytes::Bytes; +use std::pin::Pin; + +pub use reqwest::Client as ReqwestClient; + +impl From for reqwest::Body { + fn from(_: NoBody) -> Self { + reqwest::Body::default() + } +} + +/// Map a transport-level `reqwest::Error` onto the transport-agnostic +/// [`Error`]. +/// +/// A failure that carries a status (an `error_for_status` rejection) keeps +/// it as [`Error::InvalidStatusCode`] so provider retry and error-inspection +/// paths can still read the code; a response-less failure (connect, decode, +/// timeout) becomes [`Error::Instance`]. +pub fn from_reqwest(err: reqwest::Error) -> Error { + match err.status() { + Some(status) => Error::InvalidStatusCode(status), + None => Error::Instance(Box::new(err)), + } +} + +/// Read the status, headers and body off a failed `reqwest::Response` and +/// build the headers-preserving non-success error (rig#2314). +async fn non_success_status_error(response: reqwest::Response) -> Error { + let status = response.status(); + let headers = response.headers().clone(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("failed to read error response body: {error}")); + Error::non_success_with_details(status, headers, body) +} + +async fn into_lazy_response(response: reqwest::Response) -> Result>> +where + U: From, + U: WasmCompatSend + 'static, +{ + if !response.status().is_success() { + return Err(non_success_status_error(response).await); + } + + let mut res = Response::builder().status(response.status()); + + if let Some(headers) = res.headers_mut() { + *headers = response.headers().clone(); + } + + let body: LazyBody = Box::pin(async { + let bytes = response.bytes().await.map_err(instance_error)?; + Ok(U::from(bytes)) + }); + + res.body(body).map_err(Error::Protocol) +} + +/// Convert an already-sent streaming response into the transport-agnostic +/// [`StreamingResponse`], rejecting non-success statuses with the +/// headers-preserving error. +async fn into_streaming_response(response: reqwest::Response) -> Result { + if !response.status().is_success() { + return Err(non_success_status_error(response).await); + } + + #[cfg(not(target_family = "wasm"))] + let mut res = Response::builder() + .status(response.status()) + .version(response.version()); + + #[cfg(target_family = "wasm")] + let mut res = Response::builder().status(response.status()); + + if let Some(hs) = res.headers_mut() { + *hs = response.headers().clone(); + } + + use futures::StreamExt; + + let mapped_stream: Pin>>> = Box::pin( + response + .bytes_stream() + .map(|chunk| chunk.map_err(|e| Error::Instance(Box::new(e)))), + ); + + res.body(mapped_stream).map_err(Error::Protocol) +} + +impl From for reqwest::multipart::Form { + fn from(value: MultipartForm) -> Self { + let mut form = reqwest::multipart::Form::new(); + + for Part { + name, + content, + filename, + content_type, + } in value.parts + { + match content { + PartContent::Text(text) => { + form = form.text(name, text); + } + PartContent::Binary(bytes) => { + let mut req_part = if let Some(content_type) = content_type.as_ref() { + reqwest::multipart::Part::bytes(bytes.to_vec()) + .mime_str(content_type.as_ref()) + .unwrap_or_else(|_| reqwest::multipart::Part::bytes(bytes.to_vec())) + } else { + reqwest::multipart::Part::bytes(bytes.to_vec()) + }; + + if let Some(filename) = filename { + req_part = req_part.file_name(filename); + } + + form = form.part(name, req_part); + } + } + } + + form + } +} + +/// The one request-driving routine both reqwest-flavoured clients share: +/// `reqwest::Client` and `ClientWithMiddleware` expose the same +/// `request(..) -> RequestBuilder` / `execute(..)` surface but are unrelated +/// types, so the shared code is written once against a tiny private trait. +trait ReqwestLike: Clone + WasmCompatSend + WasmCompatSync + 'static { + type Builder: RequestBuilderLike; + fn request_builder(&self, method: http::Method, url: String) -> Self::Builder; +} + +trait RequestBuilderLike: Sized + WasmCompatSend + 'static { + fn with_headers(self, headers: http::HeaderMap) -> Self; + fn with_body(self, body: reqwest::Body) -> Self; + fn with_multipart(self, form: reqwest::multipart::Form) -> Self; + fn send_request(self) -> impl Future> + WasmCompatSend; +} + +impl ReqwestLike for reqwest::Client { + type Builder = reqwest::RequestBuilder; + fn request_builder(&self, method: http::Method, url: String) -> Self::Builder { + self.request(method, url) + } +} + +impl RequestBuilderLike for reqwest::RequestBuilder { + fn with_headers(self, headers: http::HeaderMap) -> Self { + self.headers(headers) + } + fn with_body(self, body: reqwest::Body) -> Self { + self.body(body) + } + fn with_multipart(self, form: reqwest::multipart::Form) -> Self { + self.multipart(form) + } + async fn send_request(self) -> Result { + self.send().await.map_err(instance_error) + } +} + +#[cfg(feature = "reqwest-middleware")] +impl ReqwestLike for reqwest_middleware::ClientWithMiddleware { + type Builder = reqwest_middleware::RequestBuilder; + fn request_builder(&self, method: http::Method, url: String) -> Self::Builder { + self.request(method, url) + } +} + +#[cfg(feature = "reqwest-middleware")] +impl RequestBuilderLike for reqwest_middleware::RequestBuilder { + fn with_headers(self, headers: http::HeaderMap) -> Self { + self.headers(headers) + } + fn with_body(self, body: reqwest::Body) -> Self { + self.body(body) + } + fn with_multipart(self, form: reqwest::multipart::Form) -> Self { + self.multipart(form) + } + async fn send_request(self) -> Result { + self.send().await.map_err(instance_error) + } +} + +fn send_via( + client: &C, + req: Request, +) -> impl Future>>> + WasmCompatSend + 'static +where + C: ReqwestLike, + T: Into, + U: From + WasmCompatSend + 'static, +{ + let (parts, body) = req.into_parts(); + let req = client + .request_builder(parts.method, parts.uri.to_string()) + .with_headers(parts.headers) + .with_body(body.into().into()); + + async move { into_lazy_response(req.send_request().await?).await } +} + +fn send_multipart_via( + client: &C, + req: Request, +) -> impl Future>>> + WasmCompatSend + 'static +where + C: ReqwestLike, + U: From + WasmCompatSend + 'static, +{ + let (parts, body) = req.into_parts(); + let req = client + .request_builder(parts.method, parts.uri.to_string()) + .with_headers(parts.headers) + .with_multipart(reqwest::multipart::Form::from(body)); + + async move { into_lazy_response(req.send_request().await?).await } +} + +fn send_streaming_via( + client: &C, + req: Request, +) -> impl Future> + WasmCompatSend +where + C: ReqwestLike, + T: Into + WasmCompatSend, +{ + let (parts, body) = req.into_parts(); + let req = client + .request_builder(parts.method, parts.uri.to_string()) + .with_headers(parts.headers) + .with_body(body.into().into()); + + async move { into_streaming_response(req.send_request().await?).await } +} + +impl HttpClientExt for reqwest::Client { + fn send( + &self, + req: Request, + ) -> impl Future>>> + WasmCompatSend + 'static + where + T: Into, + U: From + WasmCompatSend + 'static, + { + send_via(self, req) + } + + fn send_multipart( + &self, + req: Request, + ) -> impl Future>>> + WasmCompatSend + 'static + where + U: From + WasmCompatSend + 'static, + { + send_multipart_via(self, req) + } + + fn send_streaming( + &self, + req: Request, + ) -> impl Future> + WasmCompatSend + where + T: Into + WasmCompatSend, + { + send_streaming_via(self, req) + } +} + +#[cfg(feature = "reqwest-middleware")] +#[cfg_attr(docsrs, doc(cfg(feature = "reqwest-middleware")))] +impl HttpClientExt for reqwest_middleware::ClientWithMiddleware { + fn send( + &self, + req: Request, + ) -> impl Future>>> + WasmCompatSend + 'static + where + T: Into, + U: From + WasmCompatSend + 'static, + { + send_via(self, req) + } + + fn send_multipart( + &self, + req: Request, + ) -> impl Future>>> + WasmCompatSend + 'static + where + U: From + WasmCompatSend + 'static, + { + send_multipart_via(self, req) + } + + fn send_streaming( + &self, + req: Request, + ) -> impl Future> + WasmCompatSend + where + T: Into + WasmCompatSend, + { + send_streaming_via(self, req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::StatusCode; + + /// rig#2210: the bundled transport's own error constructor is where the + /// headers are captured, so drive it with a real `reqwest::Response`. + #[tokio::test] + async fn non_success_status_error_preserves_response_headers() { + let response = http::Response::builder() + .status(StatusCode::TOO_MANY_REQUESTS) + .header("retry-after", "20") + .header("x-ratelimit-remaining", "0") + .body(r#"{"error":{"message":"rate limited"}}"#) + .expect("valid response"); + + let error = non_success_status_error(reqwest::Response::from(response)).await; + + assert_eq!( + error.non_success_status(), + Some(StatusCode::TOO_MANY_REQUESTS) + ); + assert_eq!( + error.non_success_body(), + Some(r#"{"error":{"message":"rate limited"}}"#) + ); + let headers = error + .non_success_headers() + .expect("headers captured at error construction"); + assert_eq!( + headers.get("retry-after").and_then(|v| v.to_str().ok()), + Some("20") + ); + assert_eq!( + headers + .get("x-ratelimit-remaining") + .and_then(|v| v.to_str().ok()), + Some("0") + ); + } +} diff --git a/crates/rig-core/src/http_client/sse.rs b/crates/rig-core/src/http_client/sse.rs index 581ff9e93..d5ace66c2 100644 --- a/crates/rig-core/src/http_client/sse.rs +++ b/crates/rig-core/src/http_client/sse.rs @@ -362,7 +362,7 @@ fn check_response( }; let content_type = - if let Some(content_type) = response.headers().get(&reqwest::header::CONTENT_TYPE) { + if let Some(content_type) = response.headers().get(&http::header::CONTENT_TYPE) { content_type } else if allow_missing_content_type { return Ok(response); diff --git a/crates/rig-core/src/providers/chatgpt/auth/mod.rs b/crates/rig-core/src/providers/chatgpt/auth/mod.rs index c400bff17..7a25e606e 100644 --- a/crates/rig-core/src/providers/chatgpt/auth/mod.rs +++ b/crates/rig-core/src/providers/chatgpt/auth/mod.rs @@ -1,5 +1,6 @@ //! Shared ChatGPT authentication types and target-specific dispatch. +use crate::http_client::HttpClientExt; use futures::lock::Mutex; use std::fmt; use std::path::PathBuf; @@ -78,7 +79,12 @@ impl Authenticator { } } - pub async fn auth_context(&self) -> Result { + /// Resolve the access token, refreshing or signing in through `http` — + /// the client's own transport — when the cache is stale. + pub async fn auth_context(&self, http: &H) -> Result + where + H: HttpClientExt, + { match &self.source { AuthSource::AccessToken { access_token, @@ -87,7 +93,7 @@ impl Authenticator { access_token: access_token.clone(), account_id: account_id.clone(), }), - AuthSource::OAuth => self.platform.lock().await.auth_context_oauth().await, + AuthSource::OAuth => self.platform.lock().await.auth_context_oauth(http).await, } } } diff --git a/crates/rig-core/src/providers/chatgpt/auth/native.rs b/crates/rig-core/src/providers/chatgpt/auth/native.rs index 3f563f8ac..390c87d3e 100644 --- a/crates/rig-core/src/providers/chatgpt/auth/native.rs +++ b/crates/rig-core/src/providers/chatgpt/auth/native.rs @@ -1,11 +1,15 @@ //! Native ChatGPT OAuth and token cache implementation. use super::{AuthContext, AuthError, DeviceCodeHandler, DeviceCodePrompt}; +use crate::http_client::HttpClientExt; +use crate::providers::internal::auth::{request, send_json}; use crate::providers::internal::device_auth::{ emit_device_code_prompt, read_json_record, token_expired, write_json_record, }; use base64::Engine; use base64::prelude::BASE64_URL_SAFE_NO_PAD; +use bytes::Bytes; +use http::Method; use serde::{Deserialize, Deserializer, Serialize}; use std::path::PathBuf; @@ -81,7 +85,10 @@ impl PlatformAuthenticator { } } - pub(super) async fn auth_context_oauth(&self) -> Result { + pub(super) async fn auth_context_oauth(&self, http: &H) -> Result + where + H: HttpClientExt, + { let mut record: AuthRecord = read_json_record(self.auth_file.as_deref())?; if let Some(access_token) = record.access_token.clone() @@ -103,7 +110,7 @@ impl PlatformAuthenticator { } if let Some(refresh_token) = record.refresh_token.clone() { - match self.refresh_tokens(&refresh_token).await { + match self.refresh_tokens(http, &refresh_token).await { Ok(refreshed) => { write_json_record(self.auth_file.as_deref(), &refreshed)?; return Ok(AuthContext { @@ -123,7 +130,7 @@ impl PlatformAuthenticator { )); } - let fresh = self.login_device_flow().await?; + let fresh = self.login_device_flow(http).await?; write_json_record(self.auth_file.as_deref(), &fresh)?; Ok(AuthContext { access_token: fresh.access_token.unwrap_or_default(), @@ -131,16 +138,19 @@ impl PlatformAuthenticator { }) } - async fn login_device_flow(&self) -> Result { - let client = reqwest::Client::new(); - let device = client - .post(CHATGPT_DEVICE_CODE_URL) - .json(&serde_json::json!({ "client_id": CHATGPT_CLIENT_ID })) - .send() - .await? - .error_for_status()? - .json::() - .await?; + async fn login_device_flow(&self, http: &H) -> Result + where + H: HttpClientExt, + { + let device: DeviceCodeResponse = send_json( + http, + request(Method::POST, CHATGPT_DEVICE_CODE_URL) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Bytes::from(serde_json::to_vec( + &serde_json::json!({ "client_id": CHATGPT_CLIENT_ID }), + )?)), + ) + .await?; emit_device_code_prompt( self.device_code_handler.0.as_ref(), @@ -163,30 +173,39 @@ impl PlatformAuthenticator { )); } - let response = client - .post(CHATGPT_DEVICE_TOKEN_URL) - .json(&serde_json::json!({ - "device_auth_id": device.device_auth_id, - "user_code": device.user_code, - })) - .send() - .await?; - - if response.status().is_success() { - let token_response = response.json::().await?; - break token_response; - } - - let status = response.status(); - if status.as_u16() == 403 || status.as_u16() == 404 { - crate::wasm_compat::sleep(std::time::Duration::from_secs(interval)).await; - continue; + let poll = send_json::<_, DeviceTokenResponse>( + http, + request(Method::POST, CHATGPT_DEVICE_TOKEN_URL) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Bytes::from(serde_json::to_vec(&serde_json::json!({ + "device_auth_id": device.device_auth_id, + "user_code": device.user_code, + }))?)), + ) + .await; + + match poll { + Ok(token_response) => break token_response, + // Still pending: the endpoint answers 403/404 until the user + // completes authorization. + Err(AuthError::Http(err)) + if matches!( + err.non_success_status().map(|status| status.as_u16()), + Some(403 | 404) + ) => + { + crate::wasm_compat::sleep(std::time::Duration::from_secs(interval)).await; + continue; + } + Err(AuthError::Http(err)) if err.non_success_status().is_some() => { + let status = err.non_success_status().unwrap_or_default(); + let text = err.non_success_body().unwrap_or_default(); + return Err(AuthError::Message(format!( + "ChatGPT device authorization failed: {status} {text}" + ))); + } + Err(err) => return Err(err), } - - let text = response.text().await.unwrap_or_default(); - return Err(AuthError::Message(format!( - "ChatGPT device authorization failed: {status} {text}" - ))); }; let redirect_uri = format!("{CHATGPT_AUTH_BASE}/deviceauth/callback"); @@ -201,24 +220,28 @@ impl PlatformAuthenticator { .extend_pairs(form) .finish(); - let tokens = client - .post(CHATGPT_OAUTH_TOKEN_URL) - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(body) - .send() - .await? - .error_for_status()? - .json::() - .await?; + let tokens: OAuthTokenResponse = send_json( + http, + request(Method::POST, CHATGPT_OAUTH_TOKEN_URL) + .header( + http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Bytes::from(body)), + ) + .await?; Ok(build_auth_record(tokens, None)) } - async fn refresh_tokens(&self, refresh_token: &str) -> Result { - let client = reqwest::Client::new(); + async fn refresh_tokens( + &self, + http: &H, + refresh_token: &str, + ) -> Result + where + H: HttpClientExt, + { let form = [ ("client_id", CHATGPT_CLIENT_ID), ("grant_type", "refresh_token"), @@ -230,29 +253,27 @@ impl PlatformAuthenticator { .extend_pairs(form) .finish(); - let response = client - .post(CHATGPT_OAUTH_TOKEN_URL) - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(body) - .send() - .await - .map_err(AuthError::from) - .map_err(RefreshTokensError::Auth)?; - - let status = response.status(); - if status.is_success() { - let tokens = response - .json::() - .await - .map_err(AuthError::from) - .map_err(RefreshTokensError::Auth)?; - return Ok(build_auth_record(tokens, Some(refresh_token.to_owned()))); - } + let response = send_json::<_, OAuthTokenResponse>( + http, + request(Method::POST, CHATGPT_OAUTH_TOKEN_URL) + .header( + http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Bytes::from(body)), + ) + .await; - let body = response.text().await.unwrap_or_default(); + let (status, body) = match response { + Ok(tokens) => { + return Ok(build_auth_record(tokens, Some(refresh_token.to_owned()))); + } + Err(AuthError::Http(err)) if err.non_success_status().is_some() => ( + err.non_success_status().unwrap_or_default(), + err.non_success_body().unwrap_or_default().to_owned(), + ), + Err(err) => return Err(RefreshTokensError::Auth(err)), + }; let oauth_error = serde_json::from_str::(&body).ok(); if should_reauthenticate_after_refresh( status, @@ -315,18 +336,15 @@ fn decode_jwt_claims(token: &str) -> serde_json::Value { .unwrap_or(serde_json::Value::Null) } -fn should_reauthenticate_after_refresh( - status: reqwest::StatusCode, - error_code: Option<&str>, -) -> bool { +fn should_reauthenticate_after_refresh(status: http::StatusCode, error_code: Option<&str>) -> bool { matches!( status, - reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNAUTHORIZED + http::StatusCode::BAD_REQUEST | http::StatusCode::UNAUTHORIZED ) && matches!(error_code, Some("invalid_grant")) } fn format_refresh_error( - status: reqwest::StatusCode, + status: http::StatusCode, oauth_error: Option<&OAuthErrorResponse>, body: &str, ) -> String { @@ -390,7 +408,8 @@ mod tests { PlatformAuthenticator, build_auth_record, format_refresh_error, should_reauthenticate_after_refresh, }; - use reqwest::StatusCode; + use crate::test_utils::RecordingHttpClient; + use http::StatusCode; #[test] fn device_code_response_accepts_numeric_interval() { @@ -448,7 +467,7 @@ mod tests { async fn noninteractive_oauth_requires_sign_in_instead_of_device_flow() { let auth = PlatformAuthenticator::new(None, DeviceCodeHandler::default(), false); let err = auth - .auth_context_oauth() + .auth_context_oauth(&RecordingHttpClient::new("")) .await .expect_err("missing cached auth should not start device flow") .to_string(); diff --git a/crates/rig-core/src/providers/chatgpt/auth/wasm.rs b/crates/rig-core/src/providers/chatgpt/auth/wasm.rs index 9f634a98a..9f6603751 100644 --- a/crates/rig-core/src/providers/chatgpt/auth/wasm.rs +++ b/crates/rig-core/src/providers/chatgpt/auth/wasm.rs @@ -1,6 +1,7 @@ //! WASM ChatGPT auth implementation. use super::{AuthContext, AuthError, DeviceCodeHandler}; +use crate::http_client::HttpClientExt; use std::path::PathBuf; #[derive(Debug, Clone, Default)] @@ -15,7 +16,10 @@ impl PlatformAuthenticator { Self } - pub(super) async fn auth_context_oauth(&self) -> Result { + pub(super) async fn auth_context_oauth(&self, _http: &H) -> Result + where + H: HttpClientExt, + { Err(AuthError::Message( "ChatGPT OAuth is not supported on wasm targets".into(), )) diff --git a/crates/rig-core/src/providers/chatgpt/mod.rs b/crates/rig-core/src/providers/chatgpt/mod.rs index 056e46987..8da91d4d3 100644 --- a/crates/rig-core/src/providers/chatgpt/mod.rs +++ b/crates/rig-core/src/providers/chatgpt/mod.rs @@ -325,7 +325,7 @@ pub struct ResponsesCompletionModel { impl ResponsesCompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { pub fn new(client: Client, model: impl Into) -> Self { Self { @@ -486,7 +486,7 @@ where .client .ext() .auth - .auth_context() + .auth_context(self.client.http_client()) .await .map_err(|err| CompletionError::ProviderError(err.to_string()))?; @@ -549,14 +549,18 @@ where H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { pub async fn authorize(&self) -> Result<(), auth::AuthError> { - self.ext().auth.auth_context().await.map(|_| ()) + self.ext() + .auth + .auth_context(self.http_client()) + .await + .map(|_| ()) } } impl crate::client::ConstructCompletionModel> for ResponsesCompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { fn construct(client: &Client, model: String) -> Self { Self::new(client.clone(), model) @@ -566,7 +570,7 @@ where impl completion::CompletionModel for ResponsesCompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { async fn completion( &self, @@ -599,7 +603,7 @@ where impl ResponsesCompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { /// Open a stream normalized to rig's terminal record. /// @@ -639,7 +643,7 @@ where .client .ext() .auth - .auth_context() + .auth_context(self.client.http_client()) .await .map_err(|err| CompletionError::ProviderError(err.to_string()))?; diff --git a/crates/rig-core/src/providers/copilot/auth/mod.rs b/crates/rig-core/src/providers/copilot/auth/mod.rs index 58936291f..072f7b29d 100644 --- a/crates/rig-core/src/providers/copilot/auth/mod.rs +++ b/crates/rig-core/src/providers/copilot/auth/mod.rs @@ -1,3 +1,4 @@ +use crate::http_client::HttpClientExt; use futures::lock::Mutex; use std::fmt; use std::path::PathBuf; @@ -77,7 +78,12 @@ impl Authenticator { } } - pub async fn auth_context(&self) -> Result { + /// Resolve the API key (and optional API base), refreshing or signing in + /// through `http` — the client's own transport — when the cache is stale. + pub async fn auth_context(&self, http: &H) -> Result + where + H: HttpClientExt, + { match &self.source { AuthSource::ApiKey(api_key) => Ok(AuthContext { api_key: api_key.clone(), @@ -87,10 +93,10 @@ impl Authenticator { self.platform .lock() .await - .auth_context_with_github_access_token(access_token) + .auth_context_with_github_access_token(http, access_token) .await } - AuthSource::OAuth => self.platform.lock().await.auth_context_oauth().await, + AuthSource::OAuth => self.platform.lock().await.auth_context_oauth(http).await, } } } diff --git a/crates/rig-core/src/providers/copilot/auth/native.rs b/crates/rig-core/src/providers/copilot/auth/native.rs index f0bfaf0ef..cb4015a97 100644 --- a/crates/rig-core/src/providers/copilot/auth/native.rs +++ b/crates/rig-core/src/providers/copilot/auth/native.rs @@ -1,7 +1,11 @@ use super::{AuthContext, AuthError, DeviceCodeHandler, DeviceCodePrompt}; +use crate::http_client::HttpClientExt; +use crate::providers::internal::auth::{request, send_json}; use crate::providers::internal::device_auth::{ emit_device_code_prompt, ensure_parent_dir, read_json_record, token_expired, write_json_record, }; +use bytes::Bytes; +use http::Method; use serde::{Deserialize, Serialize}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::PathBuf; @@ -72,7 +76,10 @@ impl PlatformAuthenticator { } } - pub(super) async fn auth_context_oauth(&self) -> Result { + pub(super) async fn auth_context_oauth(&self, http: &H) -> Result + where + H: HttpClientExt, + { let record: ApiKeyRecord = read_json_record(self.api_key_file.as_deref())?; let cached_access_token = self.read_access_token().ok().flatten(); let api_base = record.api_base(); @@ -91,14 +98,14 @@ impl PlatformAuthenticator { from_cache: true, } } else { - self.access_token().await? + self.access_token(http).await? }; - let record = match self.refresh_api_key(&access_token.token).await { + let record = match self.refresh_api_key(http, &access_token.token).await { Ok(record) => record.bind_to_bootstrap_token(&access_token.token), Err(err) if access_token.from_cache && should_retry_with_fresh_access_token(&err) => { self.clear_access_token()?; - let fresh_access_token = self.reauthenticate_access_token().await?; - self.refresh_api_key(&fresh_access_token) + let fresh_access_token = self.reauthenticate_access_token(http).await?; + self.refresh_api_key(http, &fresh_access_token) .await? .bind_to_bootstrap_token(&fresh_access_token) } @@ -112,10 +119,14 @@ impl PlatformAuthenticator { }) } - pub(super) async fn auth_context_with_github_access_token( + pub(super) async fn auth_context_with_github_access_token( &self, + http: &H, access_token: &str, - ) -> Result { + ) -> Result + where + H: HttpClientExt, + { let record: ApiKeyRecord = read_json_record(self.api_key_file.as_deref())?; let api_base = record.api_base(); if record.can_reuse_for_bootstrap_token(access_token) @@ -128,7 +139,7 @@ impl PlatformAuthenticator { } let record = self - .refresh_api_key(access_token) + .refresh_api_key(http, access_token) .await? .bind_to_bootstrap_token(access_token); let api_base = record.api_base(); @@ -139,7 +150,10 @@ impl PlatformAuthenticator { }) } - async fn access_token(&self) -> Result { + async fn access_token(&self, http: &H) -> Result + where + H: HttpClientExt, + { if let Some(token) = self.read_access_token()? { return Ok(AccessTokenState { token, @@ -147,7 +161,7 @@ impl PlatformAuthenticator { }); } - self.reauthenticate_access_token() + self.reauthenticate_access_token(http) .await .map(|token| AccessTokenState { token, @@ -155,26 +169,26 @@ impl PlatformAuthenticator { }) } - async fn login_device_flow(&self) -> Result { - let client = reqwest::Client::new(); + async fn login_device_flow(&self, http: &H) -> Result + where + H: HttpClientExt, + { let body = url::form_urlencoded::Serializer::new(String::new()) .append_pair("client_id", GITHUB_CLIENT_ID) .append_pair("scope", "read:user") .finish(); - let device = client - .post(GITHUB_DEVICE_CODE_URL) - .header(reqwest::header::ACCEPT, "application/json") - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(body) - .send() - .await? - .error_for_status()? - .json::() - .await?; + let device: DeviceCodeResponse = send_json( + http, + request(Method::POST, GITHUB_DEVICE_CODE_URL) + .header(http::header::ACCEPT, "application/json") + .header( + http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Bytes::from(body)), + ) + .await?; emit_device_code_prompt( self.device_code_handler.0.as_ref(), @@ -201,19 +215,17 @@ impl PlatformAuthenticator { .append_pair("grant_type", "urn:ietf:params:oauth:grant-type:device_code") .finish(); - let response = client - .post(GITHUB_ACCESS_TOKEN_URL) - .header(reqwest::header::ACCEPT, "application/json") - .header( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded", - ) - .body(body) - .send() - .await? - .error_for_status()? - .json::() - .await?; + let response: AccessTokenResponse = send_json( + http, + request(Method::POST, GITHUB_ACCESS_TOKEN_URL) + .header(http::header::ACCEPT, "application/json") + .header( + http::header::CONTENT_TYPE, + "application/x-www-form-urlencoded", + ) + .body(Bytes::from(body)), + ) + .await?; if let Some(access_token) = response.access_token { return Ok(access_token); @@ -232,23 +244,25 @@ impl PlatformAuthenticator { )) } - async fn refresh_api_key(&self, access_token: &str) -> Result { - let client = reqwest::Client::new(); - let response = client - .get(GITHUB_API_KEY_URL) - .header(reqwest::header::ACCEPT, "application/json") - .header("editor-version", super::super::EDITOR_VERSION) - .header("editor-plugin-version", super::super::EDITOR_PLUGIN_VERSION) - .header("user-agent", super::super::USER_AGENT) - .header( - reqwest::header::AUTHORIZATION, - format!("token {access_token}"), - ) - .send() - .await? - .error_for_status()? - .json::() - .await?; + async fn refresh_api_key( + &self, + http: &H, + access_token: &str, + ) -> Result + where + H: HttpClientExt, + { + let response: ApiKeyRecord = send_json( + http, + request(Method::GET, GITHUB_API_KEY_URL) + .header(http::header::ACCEPT, "application/json") + .header("editor-version", super::super::EDITOR_VERSION) + .header("editor-plugin-version", super::super::EDITOR_PLUGIN_VERSION) + .header("user-agent", super::super::USER_AGENT) + .header(http::header::AUTHORIZATION, format!("token {access_token}")) + .body(Bytes::new()), + ) + .await?; if response.token.is_none() { return Err(AuthError::Message( @@ -300,14 +314,17 @@ impl PlatformAuthenticator { } } - async fn reauthenticate_access_token(&self) -> Result { + async fn reauthenticate_access_token(&self, http: &H) -> Result + where + H: HttpClientExt, + { if !self.allow_device_flow { return Err(AuthError::Message( "GitHub Copilot sign-in required. Reconnect Copilot in Settings before using this provider." .into(), )); } - let token = self.login_device_flow().await?; + let token = self.login_device_flow(http).await?; self.write_access_token(&token)?; Ok(token) } @@ -401,15 +418,17 @@ fn format_oauth_error(prefix: &str, error: &str, description: Option<&str>) -> S fn should_retry_with_fresh_access_token(err: &AuthError) -> bool { match err { - AuthError::Http(err) => should_retry_with_fresh_access_token_status(err.status()), + AuthError::Http(err) => { + should_retry_with_fresh_access_token_status(err.non_success_status()) + } _ => false, } } -fn should_retry_with_fresh_access_token_status(status: Option) -> bool { +fn should_retry_with_fresh_access_token_status(status: Option) -> bool { matches!( status, - Some(reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN) + Some(http::StatusCode::UNAUTHORIZED | http::StatusCode::FORBIDDEN) ) } @@ -420,7 +439,8 @@ mod tests { next_poll_interval_seconds, normalize_poll_interval_seconds, should_retry_with_fresh_access_token_status, }; - use reqwest::StatusCode; + use crate::test_utils::RecordingHttpClient; + use http::StatusCode; #[test] fn api_key_record_parses_dynamic_api_base() { @@ -445,7 +465,7 @@ mod tests { async fn noninteractive_oauth_requires_sign_in_instead_of_device_flow() { let auth = PlatformAuthenticator::new(None, None, DeviceCodeHandler::default(), false); let err = auth - .auth_context_oauth() + .auth_context_oauth(&RecordingHttpClient::new("")) .await .expect_err("missing cached auth should not start device flow") .to_string(); diff --git a/crates/rig-core/src/providers/copilot/auth/wasm.rs b/crates/rig-core/src/providers/copilot/auth/wasm.rs index d87fe7106..950cb5a9d 100644 --- a/crates/rig-core/src/providers/copilot/auth/wasm.rs +++ b/crates/rig-core/src/providers/copilot/auth/wasm.rs @@ -1,4 +1,7 @@ use super::{AuthContext, AuthError, DeviceCodeHandler}; +use crate::http_client::HttpClientExt; +use crate::providers::internal::auth::{request, send_json}; +use http::Method; use serde::Deserialize; use std::path::PathBuf; @@ -28,31 +31,34 @@ impl PlatformAuthenticator { Self } - pub(super) async fn auth_context_oauth(&self) -> Result { + pub(super) async fn auth_context_oauth(&self, _http: &H) -> Result + where + H: HttpClientExt, + { Err(AuthError::Message( "GitHub Copilot OAuth is not supported on wasm targets".into(), )) } - pub(super) async fn auth_context_with_github_access_token( + pub(super) async fn auth_context_with_github_access_token( &self, + http: &H, access_token: &str, - ) -> Result { - let response = reqwest::Client::new() - .get(GITHUB_API_KEY_URL) - .header(reqwest::header::ACCEPT, "application/json") - .header("editor-version", super::super::EDITOR_VERSION) - .header("editor-plugin-version", super::super::EDITOR_PLUGIN_VERSION) - .header("user-agent", super::super::USER_AGENT) - .header( - reqwest::header::AUTHORIZATION, - format!("token {access_token}"), - ) - .send() - .await? - .error_for_status()? - .json::() - .await?; + ) -> Result + where + H: HttpClientExt, + { + let response: ApiKeyRecord = send_json( + http, + request(Method::GET, GITHUB_API_KEY_URL) + .header(http::header::ACCEPT, "application/json") + .header("editor-version", super::super::EDITOR_VERSION) + .header("editor-plugin-version", super::super::EDITOR_PLUGIN_VERSION) + .header("user-agent", super::super::USER_AGENT) + .header(http::header::AUTHORIZATION, format!("token {access_token}")) + .body(bytes::Bytes::new()), + ) + .await?; let Some(api_key) = response.token.filter(|token| !token.trim().is_empty()) else { return Err(AuthError::Message( diff --git a/crates/rig-core/src/providers/copilot/mod.rs b/crates/rig-core/src/providers/copilot/mod.rs index efa8e46d4..2f94a0e4d 100644 --- a/crates/rig-core/src/providers/copilot/mod.rs +++ b/crates/rig-core/src/providers/copilot/mod.rs @@ -368,7 +368,11 @@ where H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { pub async fn authorize(&self) -> Result<(), auth::AuthError> { - self.ext().auth.auth_context().await.map(|_| ()) + self.ext() + .auth + .auth_context(self.http_client()) + .await + .map(|_| ()) } } @@ -677,7 +681,7 @@ pub struct CompletionModel { impl CompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { pub fn new(client: Client, model: impl Into) -> Self { Self { @@ -723,7 +727,7 @@ where self.client .ext() .auth - .auth_context() + .auth_context(self.client.http_client()) .await .map_err(|err| CompletionError::ProviderError(err.to_string())) } @@ -1054,7 +1058,7 @@ where impl crate::client::ConstructCompletionModel> for CompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { fn construct(client: &Client, model: String) -> Self { Self::new(client.clone(), model) @@ -1064,7 +1068,7 @@ where impl completion::CompletionModel for CompletionModel where Client: HttpClientExt + Clone + Debug + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { async fn completion( &self, @@ -1170,7 +1174,7 @@ where impl EmbeddingModel where Client: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { /// Perform the request and return Copilot's native response instead of /// the normalized [`embeddings::EmbeddingResponse`]. Same request, @@ -1207,7 +1211,7 @@ where .client .ext() .auth - .auth_context() + .auth_context(self.client.http_client()) .await .map_err(|err| EmbeddingError::ProviderError(err.to_string()))?; @@ -1295,7 +1299,7 @@ where impl embeddings::EmbeddingModel for EmbeddingModel where Client: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { fn max_documents(&self) -> usize { 1024 @@ -1333,7 +1337,7 @@ where impl crate::client::ConstructEmbeddingModel> for EmbeddingModel where Client: HttpClientExt + Clone + Debug + WasmCompatSend + WasmCompatSync + 'static, - H: Clone + WasmCompatSend + WasmCompatSync + 'static, + H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { fn construct(client: &Client, model: String, ndims: Option) -> Self { let dims = ndims.unwrap_or(match model.as_str() { @@ -1393,11 +1397,15 @@ where H: HttpClientExt + Clone + WasmCompatSend + WasmCompatSync + 'static, { async fn list_all(&self) -> Result { - let auth = self.client.ext().auth.auth_context().await.map_err(|err| { - ModelListingError::AuthError { + let auth = self + .client + .ext() + .auth + .auth_context(self.client.http_client()) + .await + .map_err(|err| ModelListingError::AuthError { message: err.to_string(), - } - })?; + })?; let headers = default_headers(&auth.api_key, "user", false, CopilotIntent::Panel); let req = apply_headers( diff --git a/crates/rig-core/src/providers/internal/auth.rs b/crates/rig-core/src/providers/internal/auth.rs index 601d12f6c..35dd0e46c 100644 --- a/crates/rig-core/src/providers/internal/auth.rs +++ b/crates/rig-core/src/providers/internal/auth.rs @@ -1,6 +1,7 @@ //! Authentication error shared by the OAuth-capable providers (ChatGPT, //! Copilot). Re-exported from each provider's `auth` module as `AuthError`. +use crate::http_client::{self, HttpClientExt}; use std::sync::Arc; /// Device authorization details surfaced to a provider callback. @@ -44,8 +45,49 @@ pub enum AuthError { Io(#[from] std::io::Error), #[error(transparent)] Json(#[from] serde_json::Error), + /// The HTTP transport failed. Non-success responses arrive as the + /// status-bearing [`http_client::Error`] variants (so the status is still + /// inspectable); response-less failures as [`http_client::Error::Instance`]. #[error(transparent)] - Http(#[from] reqwest::Error), + Http(#[from] http_client::Error), +} + +/// Build a request to an auth endpoint. Auth flows talk to fixed, absolute +/// URLs (GitHub / OpenAI auth hosts), not the provider's API base, so they +/// drive the transport directly instead of going through the provider client. +pub(crate) fn request(method: http::Method, url: &str) -> http::request::Builder { + http::Request::builder().method(method).uri(url) +} + +/// Send `req` through the transport and decode a JSON body. +/// +/// A non-success status surfaces as `AuthError::Http` carrying the +/// transport's status-bearing error (the equivalent of reqwest's +/// `error_for_status`), so callers that need to branch on a status — device +/// flows polling for authorization — read it off the error. +pub(crate) async fn send_json( + http: &H, + req: http::Result>, +) -> Result +where + H: HttpClientExt, + T: serde::de::DeserializeOwned, +{ + let bytes = send_bytes(http, req).await?; + Ok(serde_json::from_slice(&bytes)?) +} + +/// Send `req` through the transport and return the raw success body. +pub(crate) async fn send_bytes( + http: &H, + req: http::Result>, +) -> Result +where + H: HttpClientExt, +{ + let req = req.map_err(http_client::Error::Protocol)?; + let response = http.send::<_, bytes::Bytes>(req).await?; + Ok(response.into_body().await?) } /// Platform config directory used for on-disk OAuth/token caches