From 13954aadca796259809e3553c75b614ff2dd4424 Mon Sep 17 00:00:00 2001 From: Revantark Date: Sun, 11 Oct 2026 14:51:19 +0530 Subject: [PATCH 1/5] add chatgpt provider --- CHANGELOG.md | 9 + Cargo.lock | 77 ++- Cargo.toml | 1 + README.md | 4 + crates/alan/src/core/skills.rs | 2 +- crates/alan/src/login_overlay.rs | 122 +++- crates/alan/src/main.rs | 5 +- crates/llm/src/apis/chat_completions/mod.rs | 142 ++--- crates/llm/src/apis/interactions/codec.rs | 7 +- crates/llm/src/apis/interactions/mod.rs | 238 +++---- crates/llm/src/apis/mod.rs | 3 + crates/llm/src/apis/responses/codec.rs | 540 ++++++++++++++++ crates/llm/src/apis/responses/mod.rs | 663 ++++++++++++++++++++ crates/llm/src/apis/stream.rs | 319 ++++++++++ crates/llm/src/extensions.rs | 15 + crates/llm/src/lib.rs | 4 +- crates/llm/src/transport.rs | 58 +- crates/providers/Cargo.toml | 6 +- crates/providers/src/auth.rs | 44 +- crates/providers/src/chatgpt.rs | 643 +++++++++++++++++++ crates/providers/src/credentials.rs | 209 +++++- crates/providers/src/deepseek.rs | 5 +- crates/providers/src/google.rs | 2 + crates/providers/src/lib.rs | 12 +- crates/providers/src/model.rs | 40 +- crates/providers/src/oauth.rs | 528 ++++++++++++++++ crates/providers/src/openrouter.rs | 2 + crates/providers/src/provider.rs | 26 + crates/providers/src/zai.rs | 2 + 29 files changed, 3432 insertions(+), 296 deletions(-) create mode 100644 crates/llm/src/apis/responses/codec.rs create mode 100644 crates/llm/src/apis/responses/mod.rs create mode 100644 crates/llm/src/apis/stream.rs create mode 100644 crates/providers/src/chatgpt.rs create mode 100644 crates/providers/src/oauth.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ea9b95..ed3d8f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- ChatGPT subscription provider (`chatgpt.com` Codex backend, OpenAI Responses + wire protocol) with browser sign-in: OAuth PKCE on a fixed loopback port, + automatic token refresh, and credentials stored with owner-only permissions. + **Unofficial client, personal use only**: it calls a backend OpenAI has not + published, so access depends on OpenAI's terms and may stop working at any + time. + ## 0.1.5 - 2026-10-07 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5b50479..97eb364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -295,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core", ] @@ -400,6 +409,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -486,6 +504,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "darling" version = "0.24.0" @@ -554,6 +582,16 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -778,6 +816,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -1763,13 +1811,17 @@ name = "providers" version = "0.1.0" dependencies = [ "async-trait", + "base64", "futures-util", "llm", "reqwest", "serde", "serde_json", + "sha2", "thiserror", "tokio", + "tracing", + "uuid", ] [[package]] @@ -2317,6 +2369,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2892,6 +2955,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.9.0" @@ -2968,6 +3037,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" diff --git a/Cargo.toml b/Cargo.toml index 5b12106..8acf135 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ llm = { path = "crates/llm" } reqwest = { version = "0.13", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10" strum = { version = "0.28", features = ["derive"] } thiserror = "2" tokio = { version = "1.53", features = ["full"] } diff --git a/README.md b/README.md index fc80da1..4445625 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,10 @@ Alan currently supports: - **OpenRouter** - **DeepSeek** - **Zai** (beta) +- **ChatGPT** (beta, unofficial): signs in with your own ChatGPT subscription + account through the browser. OpenAI has not published this backend, so + access depends on OpenAI's terms and may stop working at any time. Personal + use only; not for hosted or multi-user apps. Use the `/login` command to sign in to any of the available providers. diff --git a/crates/alan/src/core/skills.rs b/crates/alan/src/core/skills.rs index 50c4016..976fbbf 100644 --- a/crates/alan/src/core/skills.rs +++ b/crates/alan/src/core/skills.rs @@ -18,7 +18,7 @@ use std::path::{Path, PathBuf}; /// Maximum characters of instructions kept per skill. A skill whose /// body is longer is skipped entirely. -const MAX_INSTRUCTIONS: usize = 25_000; +const MAX_INSTRUCTIONS: usize = 50_000; /// Maximum characters kept for a description. Descriptions are popup text, not /// instructions, so a long one is a sign of a malformed file. diff --git a/crates/alan/src/login_overlay.rs b/crates/alan/src/login_overlay.rs index 73b78b3..8abcc7b 100644 --- a/crates/alan/src/login_overlay.rs +++ b/crates/alan/src/login_overlay.rs @@ -30,6 +30,14 @@ enum LoginState { auth_method: providers::AuthMethod, input: String, }, + /// A browser sign-in is running; the URL is shown in case the + /// browser did not open. + AwaitingBrowser { + provider_id: ProviderId, + url: String, + /// Provider's own sign-in notice, repeated under the URL when set. + notice: Option<&'static str>, + }, Validating { message: String, }, @@ -41,6 +49,8 @@ pub struct LoginOverlay { providers: Arc, credentials: Arc, state: LoginState, + /// The running browser sign-in, cancellable with Esc. + browser_task: Option, } impl LoginOverlay { @@ -60,6 +70,7 @@ impl LoginOverlay { providers: list, selected: 0, }, + browser_task: None, } } @@ -90,6 +101,8 @@ impl LoginOverlay { let auth_result = match auth_method { AuthMethod::ApiKey => providers::AuthResult::ApiKey(std::mem::take(input)), + // Browser sign-in never reaches the key-input path. + AuthMethod::OAuth => return, }; let Some(provider) = self.providers.get(provider_id) else { @@ -115,6 +128,8 @@ impl LoginOverlay { return Err(alan_tui::TaskError(e.into())); } } + // Browser sign-in stores its credentials in its own task. + AuthResult::OAuth => {} } Ok(()) @@ -139,6 +154,10 @@ impl LoginOverlay { } fn dismiss(&mut self, cx: &mut Context<'_, Self, AlanAction>) { + // Free the fixed redirect port if a browser sign-in is still waiting. + if let Some(task) = self.browser_task.take() { + task.cancel(); + } cx.close_overlay(); } @@ -168,20 +187,94 @@ impl LoginOverlay { return; } - self.state = LoginState::Prompting { - provider_id, - auth_method: auth_method.unwrap(), - input: String::new(), - }; + match auth_method.unwrap() { + AuthMethod::ApiKey => { + self.state = LoginState::Prompting { + provider_id, + auth_method: AuthMethod::ApiKey, + input: String::new(), + }; + } + AuthMethod::OAuth => self.start_browser_sign_in(provider_id, cx), + } cx.notify(); } LoginState::Prompting { .. } => self.submit_input(cx), - LoginState::Validating { .. } => {} + LoginState::AwaitingBrowser { .. } | LoginState::Validating { .. } => {} LoginState::Success | LoginState::Error(_) => { self.dismiss(cx); } } } + + /// Run the OAuth browser sign-in through the provider's own flow as a + /// two-stage background task: bind the redirect port and show the URL + /// first, then open the browser and wait for the callback. + fn start_browser_sign_in( + &mut self, + provider_id: ProviderId, + cx: &mut Context<'_, Self, AlanAction>, + ) { + let Some(provider) = self.providers.get(&provider_id) else { + self.state = LoginState::Error("Provider not found".into()); + cx.notify(); + return; + }; + let notice = provider.sign_in_notice(); + self.state = LoginState::Validating { + message: match notice { + Some(notice) => format!("Starting sign-in…\n{notice}"), + None => "Starting sign-in…".to_string(), + }, + }; + + let phase_one_provider = Arc::clone(&provider); + let task = cx.spawn( + async move { + phase_one_provider + .start_sign_in() + .await + .map_err(|error| alan_tui::TaskError(error.into())) + }, + move |result, overlay: &mut Self, cx| { + match result { + Ok(start) => { + overlay.state = LoginState::AwaitingBrowser { + provider_id: provider_id.clone(), + url: start.url.clone(), + notice, + }; + let credentials = Arc::clone(&overlay.credentials); + let future = async move { + let credential = provider + .finish_sign_in(start) + .await + .map_err(|error| alan_tui::TaskError(error.into()))?; + credentials + .put(&provider_id, credential) + .await + .map_err(|error| alan_tui::TaskError(error.into()))?; + Ok(()) + }; + let task = cx.spawn(future, |result, overlay: &mut Self, cx| { + overlay.browser_task = None; + match result { + Ok(()) => overlay.state = LoginState::Success, + Err(error) => overlay.state = LoginState::Error(error.to_string()), + } + cx.notify(); + }); + overlay.browser_task = Some(task); + } + Err(error) => { + overlay.state = LoginState::Error(error.to_string()); + } + } + cx.notify(); + }, + ); + self.browser_task = Some(task); + } } impl Component for LoginOverlay { @@ -291,6 +384,9 @@ fn render_login(frame: &mut Frame, area: Rect, state: &LoginState) { }, ), LoginState::Validating { .. } => ("Signing in…".to_owned(), " Esc close "), + LoginState::AwaitingBrowser { .. } => { + ("Waiting for browser sign-in…".to_owned(), " Esc cancel ") + } LoginState::Success => ("Signed in".to_owned(), " Enter / Esc close "), LoginState::Error(_) => ("Sign-in failed".to_owned(), " Enter / Esc close "), }; @@ -385,6 +481,15 @@ fn render_login(frame: &mut Frame, area: Rect, state: &LoginState) { ); frame.set_cursor_position((content.x + 3 + visible as u16, content.y)); } + LoginState::AwaitingBrowser { url, notice, .. } => { + let message = match notice { + Some(notice) => { + format!("Complete the sign-in in your browser, or open:\n{url}\n\n{notice}") + } + None => format!("Complete the sign-in in your browser, or open:\n{url}"), + }; + render_message(frame, content, &message, muted); + } LoginState::Validating { message } => render_message(frame, content, message, muted), LoginState::Success => render_message( frame, @@ -466,6 +571,11 @@ mod tests { LoginState::Validating { message: "Validating credentials…".into(), }, + LoginState::AwaitingBrowser { + provider_id: ProviderId("chatgpt".into()), + url: "https://auth.openai.com/oauth/authorize?x=1".into(), + notice: Some("Unofficial client. Personal use only."), + }, LoginState::Success, LoginState::Error("Could not authenticate. Check your API key.".into()), ]; diff --git a/crates/alan/src/main.rs b/crates/alan/src/main.rs index 59be857..b2a194d 100644 --- a/crates/alan/src/main.rs +++ b/crates/alan/src/main.rs @@ -22,8 +22,8 @@ use agent::{Agent, SessionManager, ToolPermissionManager, default_tools}; use alan_tui::Runtime; use llm::ReasoningEffort; use providers::{ - DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, OpenRouterProvider, - Provider, ProviderRegistry, ZaiProvider, bind_model, + ChatGptProvider, DeepSeekProvider, FileCredentialStore, LocalProvider, ModelOptions, + OpenRouterProvider, Provider, ProviderRegistry, ZaiProvider, bind_model, }; use std::path::PathBuf; use std::sync::Arc; @@ -155,6 +155,7 @@ fn build_providers( // Arc::new(GoogleProvider::from_store(Arc::clone(credential_store)).build()?), Arc::new(OpenRouterProvider::from_store(Arc::clone(credential_store)).build()?), Arc::new(DeepSeekProvider::from_store(Arc::clone(credential_store)).build()?), + Arc::new(ChatGptProvider::from_store(Arc::clone(credential_store)).build()), Arc::clone(local_provider) as Arc, ]) } diff --git a/crates/llm/src/apis/chat_completions/mod.rs b/crates/llm/src/apis/chat_completions/mod.rs index 3e81c95..9989bda 100644 --- a/crates/llm/src/apis/chat_completions/mod.rs +++ b/crates/llm/src/apis/chat_completions/mod.rs @@ -7,12 +7,10 @@ pub use codec::{ stop_reason_for_finish_reason, usage_from_wire, }; -use crate::apis::sse::SseDecoder; +use super::stream::{StreamHandler, sse_events}; use crate::codec::{CodecChunk, LlmApiCodec}; -use crate::{HttpClient, LlmApi, LlmError, LlmEvent, LlmRequest, LlmStream, StopReason}; +use crate::{HttpClient, LlmApi, LlmError, LlmEvent, LlmRequest, LlmStream, StopReason, Usage}; use async_trait::async_trait; -use futures_util::{StreamExt, stream}; -use std::collections::VecDeque; use std::sync::Arc; const ENDPOINT: &str = "/chat/completions"; @@ -45,116 +43,82 @@ impl LlmApi for ChatCompletionsApi { async fn stream(&self, request: LlmRequest<'_>) -> Result { let body = self.codec.request(&request)?; let url = format!("{}{}", self.base_url.trim_end_matches('/'), ENDPOINT); - let response = self.http.post(&url, &body, request.credential).await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.map_err(LlmError::Transport)?; - return Err(LlmError::Http { - status: status.as_u16(), - body, - }); - } - - let state = StreamState::new(response.bytes_stream(), self.codec.clone()); - let output = stream::try_unfold(state, |mut state| async move { - let event = state.next_event().await?; - Ok::<_, LlmError>(event.map(|event| (event, state))) - }); + let response = self + .http + .post_stream(&url, &body, request.credential, &[]) + .await?; - Ok(Box::pin(output)) + Ok(sse_events( + response.bytes_stream(), + ChatHandler::new(self.codec.clone()), + )) } } -struct StreamState { - input: S, - decoder: SseDecoder, +/// Translates canonical `/chat/completions` chunks into [`LlmEvent`]s. +struct ChatHandler { codec: Arc, - pending: VecDeque, model: Option, stop_reason: Option, - usage: Option, + usage: Option, done: bool, } -impl StreamState -where - S: futures_util::Stream> + Unpin, -{ - fn new(input: S, codec: Arc) -> Self { +impl ChatHandler { + fn new(codec: Arc) -> Self { Self { - input, - decoder: SseDecoder::default(), codec, - pending: VecDeque::new(), model: None, stop_reason: None, usage: None, done: false, } } +} - async fn next_event(&mut self) -> Result, LlmError> { - loop { - if let Some(event) = self.pending.pop_front() { - return Ok(Some(event)); - } +impl StreamHandler for ChatHandler { + fn done(&self) -> bool { + self.done + } - if self.done { - return Ok(None); - } + fn frame(&mut self, data: &str) -> Result, LlmError> { + if data == "[DONE]" { + self.done = true; + return Ok(vec![LlmEvent::Done { + stop_reason: self.stop_reason.unwrap_or(StopReason::Stop), + usage: self.usage.clone(), + model: self.model.clone(), + }]); + } - match self.input.next().await { - Some(Ok(bytes)) => { - let payloads = self.decoder.push(&bytes)?; - self.add_payloads(payloads)?; - } - Some(Err(error)) => return Err(LlmError::Transport(error)), - None => { - let payloads = self.decoder.finish()?; - self.add_payloads(payloads)?; - if !self.done { - return Err(LlmError::InvalidResponse( - "stream ended without [DONE]".into(), - )); - } - } - } + let chunk: CodecChunk = self.codec.response(data)?; + if let Some(model) = chunk.model.clone() { + self.model = Some(model); + } + if let Some(usage) = chunk.usage.clone() { + self.usage = Some(usage); + } + if let Some(stop_reason) = + codec::stop_reason_for_finish_reason(chunk.finish_reason.as_deref()) + { + self.stop_reason = Some(stop_reason); } + // Emit usage *after* the chunk's text/reasoning/tool-call deltas + // so consumers see content before the summary. + let mut events = chunk.events; + if let Some(usage) = chunk.usage { + events.push(LlmEvent::Usage { usage }); + } + Ok(events) } - fn add_payloads(&mut self, payloads: Vec) -> Result<(), LlmError> { - for payload in payloads { - if payload == "[DONE]" { - self.done = true; - self.pending.push_back(LlmEvent::Done { - stop_reason: self.stop_reason.unwrap_or(StopReason::Stop), - usage: self.usage.clone(), - model: self.model.clone(), - }); - continue; - } - - let chunk: CodecChunk = self.codec.response(&payload)?; - if let Some(model) = chunk.model.clone() { - self.model = Some(model); - } - if let Some(usage) = &chunk.usage { - self.usage = Some(usage.clone()); - } - if let Some(stop_reason) = - codec::stop_reason_for_finish_reason(chunk.finish_reason.as_deref()) - { - self.stop_reason = Some(stop_reason); - } - // Emit usage *after* the chunk's text/reasoning/tool-call deltas - // so consumers see content before the summary. - let usage_event = chunk.usage.clone().map(|usage| LlmEvent::Usage { usage }); - self.pending.extend(chunk.events); - if let Some(event) = usage_event { - self.pending.push_back(event); - } + fn finish(&mut self) -> Result, LlmError> { + if self.done { + return Ok(Vec::new()); } - Ok(()) + Err(LlmError::InvalidResponse( + "stream ended without [DONE]".into(), + )) } } diff --git a/crates/llm/src/apis/interactions/codec.rs b/crates/llm/src/apis/interactions/codec.rs index 20723b2..5d2efd2 100644 --- a/crates/llm/src/apis/interactions/codec.rs +++ b/crates/llm/src/apis/interactions/codec.rs @@ -1,3 +1,4 @@ +use crate::apis::stream::WireError; use crate::{ ContentPart, LlmError, LlmRequest, Message, ReasoningEffort, Role, StopReason, ToolCall, ToolSpec, Usage, @@ -80,12 +81,6 @@ pub(crate) struct WireInteraction { pub(crate) usage: Option, } -#[derive(Debug, Deserialize)] -pub(crate) struct WireError { - pub(crate) code: Option, - pub(crate) message: Option, -} - #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub(crate) struct WireUsage { diff --git a/crates/llm/src/apis/interactions/mod.rs b/crates/llm/src/apis/interactions/mod.rs index 93533f4..88aa9ad 100644 --- a/crates/llm/src/apis/interactions/mod.rs +++ b/crates/llm/src/apis/interactions/mod.rs @@ -1,10 +1,8 @@ mod codec; -use crate::apis::sse::SseDecoder; +use super::stream::{StreamHandler, ToolIndices, sse_events, stream_error}; use crate::{Credential, HttpClient, LlmApi, LlmError, LlmEvent, LlmRequest, LlmStream, Usage}; use async_trait::async_trait; -use futures_util::{StreamExt, stream}; -use std::collections::{BTreeMap, VecDeque}; use std::sync::Arc; const ENDPOINT: &str = "/interactions"; @@ -41,175 +39,123 @@ impl LlmApi for InteractionsApi { other => other, }; - let response = self.http.post(&url, &body, credential).await?; - let status = response.status(); + let response = self.http.post_stream(&url, &body, credential, &[]).await?; - if !status.is_success() { - let body = response.text().await.map_err(LlmError::Transport)?; - return Err(LlmError::Http { - status: status.as_u16(), - body, - }); - } - - let state = StreamState::new(response.bytes_stream()); - let output = stream::try_unfold(state, |mut state| async move { - let event = state.next_event().await?; - Ok::<_, LlmError>(event.map(|event| (event, state))) - }); - - Ok(Box::pin(output)) + Ok(sse_events( + response.bytes_stream(), + InteractionsHandler::default(), + )) } } -struct StreamState { - input: S, - decoder: SseDecoder, - pending: VecDeque, +/// Translates Gemini Interactions stream events into [`LlmEvent`]s. +#[derive(Default)] +struct InteractionsHandler { model: Option, status: Option, usage: Option, done: bool, - /// Maps a `function_call` step index to the dense tool-call index the - /// response builder expects. - tool_indices: BTreeMap, - next_tool_index: usize, /// Whether any content (text/reasoning/tool) was emitted from deltas, used /// to decide whether the `interaction.completed` steps fallback is needed. emitted_content: bool, /// Thought signature streamed before a `function_call` step; attached to /// that call so it can be replayed verbatim in the next request. pending_signature: Option, + tool_indices: ToolIndices, } -impl StreamState -where - S: futures_util::Stream> + Unpin, -{ - fn new(input: S) -> Self { - Self { - input, - decoder: SseDecoder::default(), - pending: VecDeque::new(), - model: None, - status: None, - usage: None, - done: false, - tool_indices: BTreeMap::new(), - next_tool_index: 0, - emitted_content: false, - pending_signature: None, - } +impl StreamHandler for InteractionsHandler { + fn done(&self) -> bool { + self.done } - async fn next_event(&mut self) -> Result, LlmError> { - loop { - if let Some(event) = self.pending.pop_front() { - return Ok(Some(event)); - } - - if self.done { - return Ok(None); - } - - match self.input.next().await { - Some(Ok(bytes)) => { - let payloads = self.decoder.push(&bytes)?; - self.add_payloads(payloads)?; - } - Some(Err(error)) => return Err(LlmError::Transport(error)), - None => { - let payloads = self.decoder.finish()?; - self.add_payloads(payloads)?; - if !self.done { - if let Some(status) = self.status.clone() { - self.complete(&status); - } else { - return Err(LlmError::InvalidResponse( - "stream ended without completion event".into(), - )); - } - } - } + fn frame(&mut self, data: &str) -> Result, LlmError> { + let data = data.trim(); + if data.is_empty() { + return Ok(Vec::new()); + } + // The stream terminates with a non-JSON `[DONE]` sentinel; complete + // gracefully if no `interaction.completed` event was seen first. + if data == "[DONE]" { + if !self.done { + let status = self.status.clone().unwrap_or_else(|| "completed".into()); + return Ok(self.complete(&status)); } + return Ok(Vec::new()); } + // Only object frames carry events; skip stray non-object payloads. + if !data.starts_with('{') { + return Ok(Vec::new()); + } + self.handle_event(codec::deserialize_stream_event(data)?) } - fn add_payloads(&mut self, payloads: Vec) -> Result<(), LlmError> { - for payload in payloads { - let payload = payload.trim(); - if payload.is_empty() { - continue; - } - // The stream terminates with a non-JSON `[DONE]` sentinel; complete - // gracefully if no `interaction.completed` event was seen first. - if payload == "[DONE]" { - if !self.done { - let status = self.status.clone().unwrap_or_else(|| "completed".into()); - self.complete(&status); - } - continue; - } - // Only object frames carry events; skip stray non-object payloads. - if !payload.starts_with('{') { - continue; - } - let event = codec::deserialize_stream_event(payload)?; - self.handle_event(event)?; + fn finish(&mut self) -> Result, LlmError> { + if self.done { + return Ok(Vec::new()); + } + if let Some(status) = self.status.clone() { + return Ok(self.complete(&status)); } - Ok(()) + Err(LlmError::InvalidResponse( + "stream ended without completion event".into(), + )) } +} - fn handle_event(&mut self, event: codec::WireEvent) -> Result<(), LlmError> { +impl InteractionsHandler { + fn handle_event(&mut self, event: codec::WireEvent) -> Result, LlmError> { match event.event_type.as_str() { - "step.start" => self.handle_step_start(event), - "step.delta" => self.handle_step_delta(event), - "step.stop" => self.handle_step_stop(event), - "interaction.created" => self.handle_interaction_created(event), - "interaction.completed" => self.handle_interaction_completed(event), + "step.start" => Ok(self.handle_step_start(event)), + "step.delta" => Ok(self.handle_step_delta(event)), + "step.stop" => Ok(self.handle_step_stop(event)), + "interaction.created" => Ok(self.handle_interaction_created(event)), + "interaction.completed" => Ok(self.handle_interaction_completed(event)), "interaction.status_update" => { if let Some(status) = event.status { self.status = Some(status); } + Ok(Vec::new()) } - "error" => return Err(self.error_for(event.error)), - _ => {} + "error" => Err(stream_error("interactions", event.error, None)), + _ => Ok(Vec::new()), } - Ok(()) } - fn handle_step_start(&mut self, event: codec::WireEvent) { + fn handle_step_start(&mut self, event: codec::WireEvent) -> Vec { if let Some(step) = &event.step && step.kind == "function_call" { - let index = self.tool_index(event.index.unwrap_or(self.next_tool_index)); + let index = self.tool_indices.index(event.index); self.emitted_content = true; let signature = self.pending_signature.take(); - self.pending.push_back(LlmEvent::ToolCallDelta { + return vec![LlmEvent::ToolCallDelta { index, id: step.id.clone(), name: step.name.clone(), arguments: String::new(), signature, - }); + }]; } + Vec::new() } - fn handle_step_delta(&mut self, event: codec::WireEvent) { + fn handle_step_delta(&mut self, event: codec::WireEvent) -> Vec { + let mut events = Vec::new(); if let Some(usage) = event.metadata.and_then(|metadata| metadata.total_usage) { let usage = codec::usage_from_wire(&usage); self.usage = Some(usage.clone()); - self.pending.push_back(LlmEvent::Usage { usage }); + events.push(LlmEvent::Usage { usage }); } let Some(delta) = event.delta else { - return; + return events; }; match delta.kind.as_str() { "text" => { if let Some(text) = delta.text.filter(|text| !text.is_empty()) { self.emitted_content = true; - self.pending.push_back(LlmEvent::TextDelta { text }); + events.push(LlmEvent::TextDelta { text }); } } "thought_summary" => { @@ -219,7 +165,7 @@ where .filter(|text| !text.is_empty()) { self.emitted_content = true; - self.pending.push_back(LlmEvent::ReasoningDelta { + events.push(LlmEvent::ReasoningDelta { reasoning: text.clone(), details: vec![serde_json::json!({ "type": "thought_summary", @@ -229,9 +175,9 @@ where } } "arguments_delta" => { - let index = self.tool_index(event.index.unwrap_or(self.next_tool_index)); + let index = self.tool_indices.index(event.index); self.emitted_content = true; - self.pending.push_back(LlmEvent::ToolCallDelta { + events.push(LlmEvent::ToolCallDelta { index, id: None, name: None, @@ -246,17 +192,19 @@ where } _ => {} } + events } - fn handle_step_stop(&mut self, event: codec::WireEvent) { + fn handle_step_stop(&mut self, event: codec::WireEvent) -> Vec { if let Some(usage) = event.usage { let usage = codec::usage_from_wire(&usage); self.usage = Some(usage.clone()); - self.pending.push_back(LlmEvent::Usage { usage }); + return vec![LlmEvent::Usage { usage }]; } + Vec::new() } - fn handle_interaction_created(&mut self, event: codec::WireEvent) { + fn handle_interaction_created(&mut self, event: codec::WireEvent) -> Vec { if let Some(interaction) = event.interaction { if interaction.model.is_some() { self.model = interaction.model; @@ -265,9 +213,10 @@ where self.status = interaction.status; } } + Vec::new() } - fn handle_interaction_completed(&mut self, event: codec::WireEvent) { + fn handle_interaction_completed(&mut self, event: codec::WireEvent) -> Vec { let interaction = event.interaction; let status = interaction .as_ref() @@ -284,32 +233,34 @@ where } } + let mut events = Vec::new(); if !self.emitted_content && let Some(interaction) = &interaction { - self.emit_steps(interaction); + self.emit_steps(interaction, &mut events); } - self.complete(&status); + events.extend(self.complete(&status)); + events } /// Fallback for providers that send a non-streamed body inside /// `interaction.completed` instead of `step.delta` frames. - fn emit_steps(&mut self, interaction: &codec::WireInteraction) { + fn emit_steps(&mut self, interaction: &codec::WireInteraction, events: &mut Vec) { for step in &interaction.steps { match step.kind.as_str() { "model_output" => { for content in &step.content { if let Some(text) = content.text.clone().filter(|text| !text.is_empty()) { self.emitted_content = true; - self.pending.push_back(LlmEvent::TextDelta { text }); + events.push(LlmEvent::TextDelta { text }); } } } "function_call" => { - let index = self.tool_index(self.next_tool_index); + let index = self.tool_indices.allocate(); self.emitted_content = true; - self.pending.push_back(LlmEvent::ToolCallDelta { + events.push(LlmEvent::ToolCallDelta { index, id: step.id.clone(), name: step.name.clone(), @@ -326,44 +277,21 @@ where } } - fn complete(&mut self, status: &str) { + fn complete(&mut self, status: &str) -> Vec { + let mut events = Vec::new(); if let Some(usage) = &self.usage { - self.pending.push_back(LlmEvent::Usage { + events.push(LlmEvent::Usage { usage: usage.clone(), }); } self.done = true; self.status = Some(status.to_owned()); - self.pending.push_back(LlmEvent::Done { + events.push(LlmEvent::Done { stop_reason: codec::stop_reason_for_status(Some(status)), usage: self.usage.clone(), model: self.model.clone(), }); - } - - fn tool_index(&mut self, step_index: usize) -> usize { - if let Some(index) = self.tool_indices.get(&step_index) { - return *index; - } - let index = self.next_tool_index; - self.next_tool_index += 1; - self.tool_indices.insert(step_index, index); - index - } - - fn error_for(&self, error: Option) -> LlmError { - let (code, message) = match error { - Some(error) => ( - error - .code - .as_ref() - .and_then(|code| code.as_str().map(str::to_owned)) - .unwrap_or_else(|| "error".into()), - error.message.unwrap_or_default(), - ), - None => ("error".into(), String::new()), - }; - LlmError::InvalidResponse(format!("interactions stream error ({code}): {message}")) + events } } diff --git a/crates/llm/src/apis/mod.rs b/crates/llm/src/apis/mod.rs index 6919813..e4942be 100644 --- a/crates/llm/src/apis/mod.rs +++ b/crates/llm/src/apis/mod.rs @@ -1,7 +1,10 @@ mod sse; +mod stream; pub mod chat_completions; pub mod interactions; +pub mod responses; pub use chat_completions::ChatCompletionsApi; pub use interactions::InteractionsApi; +pub use responses::ResponsesApi; diff --git a/crates/llm/src/apis/responses/codec.rs b/crates/llm/src/apis/responses/codec.rs new file mode 100644 index 0000000..0f3c867 --- /dev/null +++ b/crates/llm/src/apis/responses/codec.rs @@ -0,0 +1,540 @@ +use crate::apis::stream::WireError; +use crate::{ContentPart, LlmError, LlmRequest, Message, ReasoningEffort, Role, ToolSpec, Usage}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Requested on every call so reasoning items carry `encrypted_content` and +/// can be replayed in later turns. +pub(crate) const REASONING_INCLUDE: &str = "reasoning.encrypted_content"; + +/// Request body for `POST {base}/responses`. +/// +/// The backend requires stateless requests: `store` must be `false`, +/// `previous_response_id`/`conversation` must be absent, and the full history +/// is resent every turn. +#[derive(Serialize)] +struct Request<'a> { + model: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + instructions: Option, + input: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + tools: Vec, + stream: bool, + store: bool, + include: Vec<&'static str>, + /// Omitted entirely when the effort is `None`. + #[serde(skip_serializing_if = "Option::is_none")] + reasoning: Option, +} + +#[derive(Serialize)] +struct ReasoningConfig { + effort: &'static str, +} + +#[derive(Debug, Deserialize)] +pub(crate) struct WireEvent { + #[serde(rename = "type")] + pub(crate) kind: String, + #[serde(default)] + pub(crate) message: Option, + #[serde(default)] + pub(crate) delta: Option, + #[serde(default)] + pub(crate) arguments: Option, + #[serde(default)] + pub(crate) output_index: Option, + #[serde(default)] + pub(crate) item: Option, + #[serde(default)] + pub(crate) response: Option, + #[serde(default)] + pub(crate) error: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub(crate) struct WireResponse { + #[serde(default)] + pub(crate) model: Option, + #[serde(default)] + pub(crate) usage: Option, + #[serde(default)] + pub(crate) output: Vec, + #[serde(default)] + pub(crate) error: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub(crate) struct WireUsage { + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + input_tokens_details: Option, + output_tokens_details: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct InputTokensDetails { + cached_tokens: Option, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct OutputTokensDetails { + reasoning_tokens: Option, +} + +pub(crate) fn serialize_request(request: &LlmRequest<'_>) -> Result { + let instructions = instructions(request.messages); + // Tool outputs are paired with calls by `call_id`; an output without a + // matching earlier call is a caller bug and must surface, not be dropped. + let call_ids: HashSet<&str> = request + .messages + .iter() + .filter_map(|message| message.tool_calls.as_ref()) + .flatten() + .map(|call| call.id.as_str()) + .collect(); + let mut input = Vec::new(); + for message in request.messages { + input.extend(message_items(message, &call_ids)?); + } + let tools = request + .tools + .iter() + .map(wire_tool) + .collect::, LlmError>>()?; + + let body = Request { + model: request.model_id, + instructions, + input, + tools, + stream: true, + store: false, + include: vec![REASONING_INCLUDE], + reasoning: reasoning_config(request.reasoning_effort), + }; + serde_json::to_string(&body).map_err(LlmError::Serialization) +} + +pub(crate) fn deserialize_stream_event(body: &str) -> Result { + serde_json::from_str(body).map_err(LlmError::Serialization) +} + +pub(crate) fn usage_from_wire(usage: &WireUsage) -> Usage { + Usage { + input_tokens: usage.input_tokens.unwrap_or(0), + output_tokens: usage.output_tokens.unwrap_or(0), + total_tokens: usage.total_tokens, + cached_tokens: usage + .input_tokens_details + .as_ref() + .and_then(|details| details.cached_tokens), + reasoning_tokens: usage + .output_tokens_details + .as_ref() + .and_then(|details| details.reasoning_tokens), + ..Usage::default() + } +} + +/// Levels above `high` collapse onto it; which levels the backend accepts is +/// unconfirmed. +fn reasoning_config(effort: ReasoningEffort) -> Option { + let effort = match effort { + ReasoningEffort::None => return None, + ReasoningEffort::Minimal => "minimal", + ReasoningEffort::Low => "low", + ReasoningEffort::Medium => "medium", + ReasoningEffort::High | ReasoningEffort::XHigh | ReasoningEffort::Max => "high", + }; + Some(ReasoningConfig { effort }) +} + +fn instructions(messages: &[Message]) -> Option { + let joined = messages + .iter() + .filter(|message| message.role == Role::System) + .filter_map(|message| message.content.as_deref()) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!joined.is_empty()).then_some(joined) +} + +fn message_items( + message: &Message, + call_ids: &HashSet<&str>, +) -> Result, LlmError> { + match message.role { + Role::System => Ok(Vec::new()), + Role::User => Ok(vec![user_item(message)?]), + Role::Assistant => Ok(assistant_items(message)), + Role::Tool => tool_output_item(message, call_ids).map(|item| vec![item]), + } +} + +fn user_item(message: &Message) -> Result { + let mut content = Vec::new(); + if let Some(parts) = &message.content_parts { + for part in parts { + match part { + ContentPart::Text { text } => content.push(input_text(text)), + ContentPart::Image { .. } => { + return Err(LlmError::InvalidResponse( + "images are not supported by the ChatGPT provider".into(), + )); + } + } + } + } + if content.is_empty() { + let text = message.content.as_deref().unwrap_or_default(); + content.push(input_text(text)); + } + Ok(serde_json::json!({ + "type": "message", + "role": "user", + "content": content, + })) +} + +fn input_text(text: &str) -> serde_json::Value { + serde_json::json!({ "type": "input_text", "text": text }) +} + +/// Assistant turns replay as: reasoning items (verbatim, original position), +/// then function calls, then the text message. Reasoning entries that are not +/// Responses reasoning items (e.g. summaries captured from other providers) +/// are skipped; the recorded `encrypted_content` is preserved untouched. +fn assistant_items(message: &Message) -> Vec { + let mut items = Vec::new(); + if let Some(details) = &message.reasoning_details { + for detail in details { + if detail.get("type").and_then(serde_json::Value::as_str) == Some("reasoning") { + items.push(detail.clone()); + } + } + } + + let calls = message.tool_calls.as_deref().unwrap_or(&[]); + let text = message_text(message); + if !items.is_empty() && calls.is_empty() && text.is_none() { + // A reasoning item directly before the next user message is rejected + // by the backend (HTTP 400); without other content in this turn there + // is nothing to keep it attached to, so drop it. + items.clear(); + } + + for call in calls { + items.push(serde_json::json!({ + "type": "function_call", + "call_id": call.id, + "name": call.name, + "arguments": call.arguments, + })); + } + if let Some(text) = text { + items.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "content": [{ "type": "output_text", "text": text }], + })); + } + items +} + +fn tool_output_item( + message: &Message, + call_ids: &HashSet<&str>, +) -> Result { + let call_id = message + .tool_call_id + .as_deref() + .ok_or_else(|| LlmError::InvalidResponse("tool result is missing tool_call_id".into()))?; + if !call_ids.contains(call_id) { + return Err(LlmError::InvalidResponse(format!( + "tool result references unknown tool call {call_id}" + ))); + } + Ok(serde_json::json!({ + "type": "function_call_output", + "call_id": call_id, + "output": message.content.clone().unwrap_or_default(), + })) +} + +fn message_text(message: &Message) -> Option { + if let Some(parts) = &message.content_parts { + let joined = parts + .iter() + .filter_map(|part| match part { + ContentPart::Text { text } => Some(text.as_str()), + ContentPart::Image { .. } => None, + }) + .collect::>() + .join("\n"); + return (!joined.is_empty()).then_some(joined); + } + message + .content + .as_deref() + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +fn wire_tool(tool: &ToolSpec) -> Result { + match tool { + ToolSpec::Function(tool) => Ok(serde_json::json!({ + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "strict": false, + })), + // The provider advertises no server tools. + ToolSpec::Server(tool) => Err(LlmError::InvalidResponse(format!( + "server tools are not supported by the ChatGPT provider: {}", + tool.kind + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Message, RequestOptions, ToolCall, ToolDefinition}; + + fn body_for(messages: &[Message]) -> serde_json::Value { + let options = RequestOptions::default(); + let request = LlmRequest { + model_id: "gpt-test", + messages, + tools: &[], + options: &options, + credential: None, + reasoning_effort: ReasoningEffort::None, + extensions: crate::Extensions::default(), + }; + let json = serialize_request(&request).unwrap(); + serde_json::from_str(&json).unwrap() + } + + fn request_with( + messages: &[Message], + tools: &[ToolSpec], + effort: ReasoningEffort, + ) -> Result { + let options = RequestOptions::default(); + let request = LlmRequest { + model_id: "gpt-test", + messages, + tools, + options: &options, + credential: None, + reasoning_effort: effort, + extensions: crate::Extensions::default(), + }; + serialize_request(&request) + } + + fn reasoning_item(id: &str, encrypted: &str) -> serde_json::Value { + serde_json::json!({ + "type": "reasoning", + "id": id, + "summary": [], + "encrypted_content": encrypted, + }) + } + + #[test] + fn body_requires_stateless_shape() { + let messages = [Message::user("hello")]; + let body = body_for(&messages); + + assert_eq!(body["model"], "gpt-test"); + assert_eq!(body["store"], false); + assert_eq!(body["stream"], true); + assert_eq!( + body["include"], + serde_json::json!(["reasoning.encrypted_content"]) + ); + // Stateless requests must not reference prior server-side turns. + assert!(body.get("previous_response_id").is_none()); + assert!(body.get("conversation").is_none()); + // `tools` is omitted entirely when there are none. + assert!(body.get("tools").is_none()); + // Effort `None` omits the reasoning block entirely. + assert!(body.get("reasoning").is_none()); + } + + #[test] + fn system_prompt_maps_to_instructions() { + let messages = [Message::system("be terse"), Message::user("hello")]; + let body = body_for(&messages); + + assert_eq!(body["instructions"], "be terse"); + let input = body["input"].as_array().unwrap(); + assert_eq!(input.len(), 1); + assert_eq!(input[0]["type"], "message"); + assert_eq!(input[0]["role"], "user"); + assert_eq!(input[0]["content"][0]["type"], "input_text"); + assert_eq!(input[0]["content"][0]["text"], "hello"); + } + + #[test] + fn assistant_history_replays_with_reasoning_first() { + let messages = [ + Message::user("hi"), + Message::assistant_with_reasoning( + Some("done".into()), + None, + vec![reasoning_item("rs_1", "ENC")], + ), + Message::user("more"), + ]; + let body = body_for(&messages); + let input = body["input"].as_array().unwrap(); + + assert_eq!(input.len(), 4); + assert_eq!(input[1]["type"], "reasoning"); + assert_eq!(input[1]["id"], "rs_1"); + assert_eq!(input[1]["encrypted_content"], "ENC"); + assert_eq!(input[1]["summary"], serde_json::json!([])); + assert_eq!(input[2]["type"], "message"); + assert_eq!(input[2]["role"], "assistant"); + assert_eq!(input[2]["content"][0]["type"], "output_text"); + assert_eq!(input[2]["content"][0]["text"], "done"); + assert_eq!(input[3]["type"], "message"); + assert_eq!(input[3]["role"], "user"); + } + + #[test] + fn reasoning_is_dropped_when_it_would_precede_a_user_message() { + let messages = [ + Message::user("hi"), + Message::assistant_with_reasoning(None, None, vec![reasoning_item("rs_1", "ENC")]), + Message::user("more"), + ]; + let body = body_for(&messages); + let input = body["input"].as_array().unwrap(); + + assert_eq!(input.len(), 2); + assert_eq!(input[0]["type"], "message"); + assert_eq!(input[1]["type"], "message"); + } + + #[test] + fn tool_calls_map_to_function_call_items() { + let messages = [ + Message::user("run it"), + Message::assistant_with_tool_calls_and_reasoning( + None, + vec![ToolCall { + id: "call_1".into(), + name: "bash".into(), + arguments: "{\"command\":\"ls\"}".into(), + signature: None, + }], + None, + vec![reasoning_item("rs_1", "ENC")], + ), + ]; + let body = body_for(&messages); + let input = body["input"].as_array().unwrap(); + + assert_eq!(input.len(), 3); + assert_eq!(input[1]["type"], "reasoning"); + assert_eq!(input[2]["type"], "function_call"); + assert_eq!(input[2]["call_id"], "call_1"); + assert_eq!(input[2]["name"], "bash"); + // Arguments stay a JSON-encoded string, never parsed into an object. + assert_eq!(input[2]["arguments"], "{\"command\":\"ls\"}"); + } + + #[test] + fn tool_results_map_to_function_call_output_items() { + let messages = [ + Message::user("run it"), + Message::assistant_with_tool_calls_and_reasoning( + None, + vec![ToolCall { + id: "call_1".into(), + name: "bash".into(), + arguments: "{}".into(), + signature: None, + }], + None, + Vec::new(), + ), + Message::tool_result("output", "call_1"), + ]; + let body = body_for(&messages); + let input = body["input"].as_array().unwrap(); + + assert_eq!(input.len(), 3); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[2]["call_id"], "call_1"); + assert_eq!(input[2]["output"], "output"); + } + + #[test] + fn unpaired_tool_result_is_an_error() { + let messages = [Message::tool_result("orphan", "missing_call")]; + let error = request_with(&messages, &[], ReasoningEffort::None).unwrap_err(); + + assert!(error.to_string().contains("missing_call")); + } + + #[test] + fn function_tools_serialize_with_strict_false() { + let tools = [ToolSpec::Function(ToolDefinition { + name: "bash".into(), + description: "run a command".into(), + parameters: serde_json::json!({"type": "object"}), + })]; + let messages = [Message::user("run it")]; + let json = request_with(&messages, &tools, ReasoningEffort::None).unwrap(); + let body: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(body["tools"][0]["type"], "function"); + assert_eq!(body["tools"][0]["name"], "bash"); + assert_eq!(body["tools"][0]["strict"], false); + } + + #[test] + fn server_tools_are_rejected() { + let tools = [ToolSpec::Server(crate::ServerTool { + kind: "web_search".into(), + })]; + let messages = [Message::user("run it")]; + let error = request_with(&messages, &tools, ReasoningEffort::None).unwrap_err(); + + assert!(error.to_string().contains("web_search")); + } + + #[test] + fn reasoning_effort_maps_to_reasoning_object() { + let messages = [Message::user("hi")]; + let json = request_with(&messages, &[], ReasoningEffort::Medium).unwrap(); + let body: serde_json::Value = serde_json::from_str(&json).unwrap(); + + assert_eq!(body["reasoning"]["effort"], "medium"); + } + + #[test] + fn images_are_rejected() { + let messages = [Message::user_with_parts(vec![ContentPart::Image { + image_url: crate::ImageUrl { + url: "https://example.invalid/a.png".into(), + }, + }])]; + let error = request_with(&messages, &[], ReasoningEffort::None).unwrap_err(); + + assert!(error.to_string().contains("images are not supported")); + } +} diff --git a/crates/llm/src/apis/responses/mod.rs b/crates/llm/src/apis/responses/mod.rs new file mode 100644 index 0000000..7eb5c72 --- /dev/null +++ b/crates/llm/src/apis/responses/mod.rs @@ -0,0 +1,663 @@ +mod codec; + +use super::stream::{StreamHandler, ToolIndices, sse_events, stream_error}; +use crate::{ + Headers, HttpClient, LlmApi, LlmError, LlmEvent, LlmRequest, LlmStream, SessionId, StopReason, + Usage, +}; +use async_trait::async_trait; +use std::collections::HashSet; +use std::sync::Arc; + +const ENDPOINT: &str = "/responses"; + +/// OpenAI Responses wire client for the ChatGPT subscription backend. +/// +/// Stateless: every request resends the full history. The bearer token +/// arrives through [`LlmRequest`]'s `Credential::ApiKey`; backend headers are +/// attached per instance with [`ResponsesApi::with_header`]. +pub struct ResponsesApi { + base_url: String, + http: Arc, + headers: Vec<(String, String)>, +} + +impl ResponsesApi { + pub fn new(base_url: impl Into, http: Arc) -> Self { + Self { + base_url: base_url.into(), + http, + headers: Vec::new(), + } + } + + /// Attach a static header sent with every request from this instance. + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } +} + +#[async_trait] +impl LlmApi for ResponsesApi { + async fn stream(&self, request: LlmRequest<'_>) -> Result { + let body = codec::serialize_request(&request)?; + let url = format!("{}{}", self.base_url.trim_end_matches('/'), ENDPOINT); + let mut headers = self.headers.clone(); + if let Some(Headers(extra)) = request.extensions.get::() { + headers.extend(extra.iter().cloned()); + } + // The agent stamps each conversation with a canonical session id; + // send it as the backend's `session_id` header when present. + if let Some(session) = request.extensions.get::() + && !session.0.is_empty() + { + headers.push(("session_id".to_string(), session.0.clone())); + } + let response = self + .http + .post_stream(&url, &body, request.credential, &headers) + .await?; + + Ok(sse_events( + response.bytes_stream(), + ResponsesHandler::default(), + )) + } +} + +/// Translates Responses stream events into [`LlmEvent`]s. +#[derive(Default)] +struct ResponsesHandler { + model: Option, + usage: Option, + done: bool, + /// Output indexes whose function-call arguments were already emitted, so + /// a trailing `...arguments.done` does not duplicate them. + emitted_arguments: HashSet, + tool_indices: ToolIndices, +} + +impl StreamHandler for ResponsesHandler { + fn done(&self) -> bool { + self.done + } + + fn frame(&mut self, data: &str) -> Result, LlmError> { + let data = data.trim(); + if data.is_empty() || !data.starts_with('{') { + return Ok(Vec::new()); + } + self.handle_event(codec::deserialize_stream_event(data)?) + } + + fn finish(&mut self) -> Result, LlmError> { + // There is no [DONE] sentinel; the stream must end with a terminal + // event (`response.completed`, `.incomplete`, or `.failed`). + if self.done { + Ok(Vec::new()) + } else { + Err(LlmError::InvalidResponse( + "stream ended without a terminal event".into(), + )) + } + } +} + +impl ResponsesHandler { + fn handle_event(&mut self, event: codec::WireEvent) -> Result, LlmError> { + let mut events = Vec::new(); + match event.kind.as_str() { + "response.created" | "response.in_progress" => { + if let Some(model) = event.response.and_then(|response| response.model) { + self.model = Some(model); + } + } + "response.output_text.delta" => { + if let Some(text) = event.delta.filter(|text| !text.is_empty()) { + events.push(LlmEvent::TextDelta { text }); + } + } + "response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => { + if let Some(text) = event.delta.filter(|text| !text.is_empty()) { + events.push(LlmEvent::ReasoningDelta { + reasoning: text, + details: Vec::new(), + }); + } + } + "response.output_item.added" => self.handle_item_added(event, &mut events), + "response.function_call_arguments.delta" => { + if let (Some(output_index), Some(delta)) = (event.output_index, event.delta) + && !delta.is_empty() + { + let index = self.tool_indices.index(Some(output_index)); + // Keyed by the raw wire `output_index`, matching the + // `...arguments.done` check below — not the dense index + // this stream emits to the builder. + self.emitted_arguments.insert(output_index); + events.push(LlmEvent::ToolCallDelta { + index, + id: None, + name: None, + arguments: delta, + signature: None, + }); + } + } + "response.function_call_arguments.done" => { + if let (Some(output_index), Some(arguments)) = (event.output_index, event.arguments) + && !arguments.is_empty() + && !self.emitted_arguments.contains(&output_index) + { + let index = self.tool_indices.index(Some(output_index)); + events.push(LlmEvent::ToolCallDelta { + index, + id: None, + name: None, + arguments, + signature: None, + }); + } + } + "response.output_item.done" => self.handle_item_done(event, &mut events), + "response.completed" => { + let response = event.response.unwrap_or_default(); + self.complete(response, StopReason::Stop, &mut events); + } + "response.incomplete" => { + let response = event.response.unwrap_or_default(); + self.complete(response, StopReason::Length, &mut events); + } + "response.failed" => { + let response = event.response.unwrap_or_default(); + let error = response.error.or(event.error); + return Err(stream_error("responses", error, event.message)); + } + "error" => return Err(stream_error("responses", event.error, event.message)), + other => tracing::debug!(event = other, "ignoring responses stream event"), + } + Ok(events) + } + + fn handle_item_added(&mut self, event: codec::WireEvent, events: &mut Vec) { + let Some(output_index) = event.output_index else { + return; + }; + let Some(item) = event.item else { + return; + }; + if item.get("type").and_then(serde_json::Value::as_str) != Some("function_call") { + return; + } + let index = self.tool_indices.index(Some(output_index)); + let id = item + .get("call_id") + .and_then(serde_json::Value::as_str) + .or_else(|| item.get("id").and_then(serde_json::Value::as_str)) + .map(str::to_owned); + let name = item + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::to_owned); + // Non-streamed items may carry the full arguments on the item itself. + let arguments = item + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + if !arguments.is_empty() { + self.emitted_arguments.insert(output_index); + } + events.push(LlmEvent::ToolCallDelta { + index, + id, + name, + arguments, + signature: None, + }); + } + + fn handle_item_done(&mut self, event: codec::WireEvent, events: &mut Vec) { + let Some(item) = event.item else { + return; + }; + if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") { + return; + } + // Keep the full reasoning item (including `encrypted_content`) so the + // agent can replay it verbatim on the next request. + events.push(LlmEvent::ReasoningDelta { + reasoning: String::new(), + details: vec![item], + }); + } + + fn complete( + &mut self, + response: codec::WireResponse, + fallback: StopReason, + events: &mut Vec, + ) { + if response.model.is_some() { + self.model = response.model; + } + let usage = response.usage.map(|usage| codec::usage_from_wire(&usage)); + let stop_reason = if response.output.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("function_call") + }) { + StopReason::ToolUse + } else { + fallback + }; + + if let Some(usage) = &usage { + self.usage = Some(usage.clone()); + events.push(LlmEvent::Usage { + usage: usage.clone(), + }); + } + self.done = true; + events.push(LlmEvent::Done { + stop_reason, + usage: self.usage.clone(), + model: self.model.clone(), + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Message, RequestOptions, StopReason}; + use futures_util::StreamExt; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + fn user_request<'a>( + messages: &'a [Message], + options: &'a RequestOptions, + credential: &'a crate::Credential, + ) -> LlmRequest<'a> { + LlmRequest { + model_id: "gpt-test", + messages, + tools: &[], + options, + credential: Some(credential), + reasoning_effort: crate::ReasoningEffort::None, + extensions: crate::Extensions::default(), + } + } + + fn user_request_with_headers<'a>( + messages: &'a [Message], + options: &'a RequestOptions, + credential: &'a crate::Credential, + headers: Vec<(String, String)>, + ) -> LlmRequest<'a> { + let mut extensions = crate::Extensions::default(); + extensions.insert(crate::Headers(headers)); + LlmRequest { + model_id: "gpt-test", + messages, + tools: &[], + options, + credential: Some(credential), + reasoning_effort: crate::ReasoningEffort::None, + extensions, + } + } + + async fn serve(body: &'static str) -> (String, tokio::task::JoinHandle) { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let read = socket.read(&mut buffer).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") + && String::from_utf8_lossy(&request).contains("\"store\":false") + { + break; + } + } + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\n\r\n{body}" + ); + socket.write_all(response.as_bytes()).await.unwrap(); + String::from_utf8(request).unwrap() + }); + (format!("http://{address}"), server) + } + + async fn collect(api: &ResponsesApi, request: LlmRequest<'_>) -> crate::LlmResponse { + let mut events = api.stream(request).await.unwrap(); + let mut builder = crate::LlmResponseBuilder::new(); + while let Some(event) = events.next().await { + builder.apply(&event.unwrap()).unwrap(); + } + builder.finish().unwrap() + } + + /// Like [`collect`], but expects and returns the stream's error instead of + /// a response. + async fn collect_err(api: &ResponsesApi, request: LlmRequest<'_>) -> LlmError { + let mut events = match api.stream(request).await { + Ok(events) => events, + Err(error) => return error, + }; + let mut builder = crate::LlmResponseBuilder::new(); + while let Some(event) = events.next().await { + match event { + Ok(event) => { + if let Err(error) = builder.apply(&event) { + return error; + } + } + Err(error) => return error, + } + } + builder + .finish() + .expect_err("stream without a terminal event must not complete") + } + + #[tokio::test] + async fn streams_text_reasoning_and_usage() { + let body = concat!( + "data: {\"type\":\"response.created\",\"response\":{\"model\":\"gpt-served\"}}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hel\"}\n\n", + "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"thinking\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"lo\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"model\":\"gpt-served\",\"usage\":{\"input_tokens\":2,\"output_tokens\":3,\"input_tokens_details\":{\"cached_tokens\":1},\"output_tokens_details\":{\"reasoning_tokens\":7}}}}\n\n", + ); + let (url, server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + assert_eq!(response.text(), "hello"); + assert_eq!(response.reasoning.as_deref(), Some("thinking")); + assert_eq!(response.model.as_deref(), Some("gpt-served")); + assert_eq!(response.stop_reason, StopReason::Stop); + let usage = response.usage.unwrap(); + assert_eq!(usage.output_tokens, 3); + assert_eq!(usage.cached_tokens, Some(1)); + assert_eq!(usage.reasoning_tokens, Some(7)); + + let sent = server.await.unwrap(); + // The bearer token and content type must be present; provider-specific + // headers are attached per instance, not by the bare client. + assert!(sent.contains("authorization: Bearer token")); + assert!(sent.contains("content-type: application/json")); + } + + #[tokio::test] + async fn sends_instance_headers_verbatim() { + let body = "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n"; + let (url, server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())) + .with_header("ChatGPT-Account-Id", "acct-1") + .with_header("OpenAI-Beta", "responses=experimental") + .with_header("originator", "alan") + .with_header("session_id", "11111111-1111-1111-1111-111111111111") + .with_header("version", "0.1.0"); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + collect(&api, user_request(&messages, &options, &credential)).await; + + let sent = server.await.unwrap(); + assert!(sent.contains("chatgpt-account-id: acct-1")); + assert!(sent.contains("openai-beta: responses=experimental")); + assert!(sent.contains("originator: alan")); + assert!(sent.contains("session_id: 11111111-1111-1111-1111-111111111111")); + assert!(sent.contains("version: 0.1.0")); + } + + #[tokio::test] + async fn error_bodies_are_truncated() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let read = socket.read(&mut buffer).await.unwrap(); + assert!(read > 0); + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + let html = "".repeat(400); + let response = format!( + "HTTP/1.1 403 Forbidden\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{html}", + html.len() + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + let api = ResponsesApi::new(format!("http://{address}"), Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let error = match api + .stream(user_request(&messages, &options, &credential)) + .await + { + Err(error) => error, + Ok(_) => panic!("403 must produce an error"), + }; + server.await.unwrap(); + + let rendered = error.to_string(); + assert!(rendered.contains("403")); + assert!(rendered.contains("[truncated]")); + // Bounded, not a wall of HTML. + assert!( + rendered.len() < 1000, + "error message too long: {}", + rendered.len() + ); + } + + #[tokio::test] + async fn request_headers_merge_with_instance_headers() { + let body = "data: {\"type\":\"response.completed\",\"response\":{}}\n\n"; + let (url, server) = serve(body).await; + let api = + ResponsesApi::new(url, Arc::new(HttpClient::new())).with_header("originator", "alan"); + // The account id is unknown at construction; auth attaches it per + // request through the `Headers` extension. + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let request = user_request_with_headers( + &messages, + &options, + &credential, + vec![("ChatGPT-Account-Id".to_string(), "acct-9".to_string())], + ); + collect(&api, request).await; + + let sent = server.await.unwrap(); + assert!(sent.contains("chatgpt-account-id: acct-9")); + assert!(sent.contains("originator: alan")); + } + + #[tokio::test] + async fn streams_function_call_and_sets_tool_use() { + let body = concat!( + "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"bash\",\"arguments\":\"\"}}\n\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"{\\\"comm\"}\n\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":0,\"delta\":\"and\\\":\\\"ls\\\"}\"}\n\n", + "data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\"}]}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("run it")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + let calls: Vec<_> = response.tool_calls().collect(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].id, "call-1"); + assert_eq!(calls[0].name, "bash"); + assert_eq!(calls[0].arguments, "{\"command\":\"ls\"}"); + assert_eq!(response.stop_reason, StopReason::ToolUse); + } + + #[tokio::test] + async fn streamed_arguments_behind_a_reasoning_item_are_not_duplicated() { + // Real-session layout: the reasoning item occupies output index 0, + // so the function call sits at index 1. The `...arguments.done` + // event re-sends the full arguments; the emitted-arguments guard + // must key on the wire index, not the dense builder index, or the + // arguments get appended twice and the JSON gains trailing + // characters. + let body = concat!( + "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[],\"encrypted_content\":\"ENC\"}}\n\n", + "data: {\"type\":\"response.output_item.added\",\"output_index\":1,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"read_file\",\"arguments\":\"\"}}\n\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":1,\"delta\":\"{\\\"path\\\":\"}\n\n", + "data: {\"type\":\"response.function_call_arguments.delta\",\"output_index\":1,\"delta\":\"\\\"main.rs\\\"}\"}\n\n", + "data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":1,\"arguments\":\"{\\\"path\\\":\\\"main.rs\\\"}\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"reasoning\"},{\"type\":\"function_call\"}]}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("read the file")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + let calls: Vec<_> = response.tool_calls().collect(); + assert_eq!(calls.len(), 1); + // Exactly once — a second copy would break tool JSON parsing with + // "trailing characters". + assert_eq!(calls[0].arguments, "{\"path\":\"main.rs\"}"); + assert_eq!(response.reasoning_details.len(), 1); + } + + #[tokio::test] + async fn non_streamed_function_call_arguments_use_done_event() { + let body = concat!( + "data: {\"type\":\"response.output_item.added\",\"output_index\":0,\"item\":{\"type\":\"function_call\",\"call_id\":\"call-1\",\"name\":\"bash\"}}\n\n", + "data: {\"type\":\"response.function_call_arguments.done\",\"output_index\":0,\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"output\":[{\"type\":\"function_call\"}]}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("run it")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + let calls: Vec<_> = response.tool_calls().collect(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].arguments, "{\"command\":\"ls\"}"); + } + + #[tokio::test] + async fn reasoning_items_are_kept_for_replay() { + let body = concat!( + "data: {\"type\":\"response.output_item.done\",\"output_index\":0,\"item\":{\"type\":\"reasoning\",\"id\":\"rs_1\",\"summary\":[],\"encrypted_content\":\"ENC\"}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + assert_eq!(response.reasoning_details.len(), 1); + assert_eq!(response.reasoning_details[0]["type"], "reasoning"); + assert_eq!(response.reasoning_details[0]["encrypted_content"], "ENC"); + assert_eq!(response.reasoning_details[0]["id"], "rs_1"); + } + + #[tokio::test] + async fn incomplete_maps_to_length_stop_reason() { + let body = concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n", + "data: {\"type\":\"response.incomplete\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + assert_eq!(response.text(), "partial"); + assert_eq!(response.stop_reason, StopReason::Length); + } + + #[tokio::test] + async fn failed_event_becomes_an_error() { + let body = "data: {\"type\":\"response.failed\",\"response\":{\"error\":{\"code\":\"server_error\",\"message\":\"boom\"}}}\n\n"; + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let error = collect_err(&api, user_request(&messages, &options, &credential)).await; + + assert!(error.to_string().contains("server_error")); + assert!(error.to_string().contains("boom")); + } + + #[tokio::test] + async fn top_level_error_event_becomes_an_error() { + let body = "data: {\"type\":\"error\",\"message\":\"rate limited\"}\n\n"; + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let error = collect_err(&api, user_request(&messages, &options, &credential)).await; + + assert!(error.to_string().contains("rate limited")); + } + + #[tokio::test] + async fn stream_without_terminal_event_is_an_error() { + let body = "data: {\"type\":\"response.output_text.delta\",\"delta\":\"half\"}\n\n"; + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let error = collect_err(&api, user_request(&messages, &options, &credential)).await; + + assert!(error.to_string().contains("terminal event")); + } + + #[tokio::test] + async fn unknown_event_types_are_ignored() { + let body = concat!( + "data: {\"type\":\"response.some_future_event\",\"payload\":{\"x\":1}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{}}\n\n", + ); + let (url, _server) = serve(body).await; + let api = ResponsesApi::new(url, Arc::new(HttpClient::new())); + let messages = [Message::user("ping")]; + let options = RequestOptions::default(); + let credential = crate::Credential::ApiKey("token".into()); + let response = collect(&api, user_request(&messages, &options, &credential)).await; + + assert_eq!(response.stop_reason, StopReason::Stop); + } +} diff --git a/crates/llm/src/apis/stream.rs b/crates/llm/src/apis/stream.rs new file mode 100644 index 0000000..1523049 --- /dev/null +++ b/crates/llm/src/apis/stream.rs @@ -0,0 +1,319 @@ +//! Shared SSE streaming machinery for the API clients. + +use super::sse::SseDecoder; +use crate::{LlmError, LlmEvent, LlmStream}; +use futures_util::{Stream, StreamExt, stream}; +use serde::Deserialize; +use std::collections::{BTreeMap, VecDeque}; + +/// Interprets the `data:` frames of one provider SSE stream into +/// [`LlmEvent`]s. +/// +/// Implemented once per API; [`sse_events`] owns the shared plumbing (SSE +/// decoding, event ordering, end-of-stream handling) so implementations only +/// translate frames. +pub(crate) trait StreamHandler: Send { + /// Whether the provider's terminal frame has been seen; once true, the + /// stream stops without reading further frames. + fn done(&self) -> bool; + + /// Translate one `data:` frame into the events to emit, in order. + fn frame(&mut self, data: &str) -> Result, LlmError>; + + /// Settle the stream at end of input, after the final frames were passed + /// to [`StreamHandler::frame`]: return the closing events, or an error + /// when the protocol requires a terminal frame that never arrived. + fn finish(&mut self) -> Result, LlmError>; +} + +/// Decode `input` as an SSE body and emit the events [`StreamHandler`] +/// translates its frames into. +pub(crate) fn sse_events(input: S, handler: H) -> LlmStream +where + S: Stream> + Unpin + Send + 'static, + H: StreamHandler + 'static, +{ + let state = StreamState { + input, + decoder: SseDecoder::default(), + handler, + pending: VecDeque::new(), + finished: false, + }; + let output = stream::try_unfold(state, |mut state| async move { + let event = state.next_event().await?; + Ok::<_, LlmError>(event.map(|event| (event, state))) + }); + Box::pin(output) +} + +/// Decodes SSE frames, hands them to the handler, and buffers the resulting +/// events until the consumer drains them. +struct StreamState { + input: S, + decoder: SseDecoder, + handler: H, + pending: VecDeque, + /// Whether end of input was reached and [`StreamHandler::finish`] ran. + finished: bool, +} + +impl StreamState +where + S: Stream> + Unpin, + H: StreamHandler, +{ + async fn next_event(&mut self) -> Result, LlmError> { + loop { + if let Some(event) = self.pending.pop_front() { + return Ok(Some(event)); + } + if self.finished || self.handler.done() { + return Ok(None); + } + match self.input.next().await { + Some(Ok(bytes)) => { + for frame in self.decoder.push(&bytes)? { + // Frames arriving after the terminal one (e.g. in the + // same TCP batch) are dropped: no events may follow + // `Done`. + if self.handler.done() { + break; + } + self.pending.extend(self.handler.frame(&frame)?); + } + } + Some(Err(error)) => return Err(LlmError::Transport(error)), + None => { + for frame in self.decoder.finish()? { + self.pending.extend(self.handler.frame(&frame)?); + } + self.finished = true; + self.pending.extend(self.handler.finish()?); + } + } + } + } +} + +/// Assigns the dense tool-call indices the response builder expects, keyed by +/// the provider's stream position (`output_index` / step `index`). +#[derive(Default)] +pub(crate) struct ToolIndices { + positions: BTreeMap, + next: usize, +} + +impl ToolIndices { + /// Index for `position`, allocating the next dense index on first sight; + /// unpositioned calls (`None`) always get a fresh dense index. + pub(crate) fn index(&mut self, position: Option) -> usize { + let Some(position) = position else { + return self.allocate(); + }; + if let Some(&index) = self.positions.get(&position) { + return index; + } + let index = self.next; + self.next += 1; + self.positions.insert(position, index); + index + } + + /// Allocate the next dense index for a call the provider did not position. + pub(crate) fn allocate(&mut self) -> usize { + let index = self.next; + self.next += 1; + index + } +} + +/// Error object carried by provider stream events: +/// `{"code": ..., "message": ...}`. +#[derive(Debug, Deserialize)] +pub(crate) struct WireError { + #[serde(default)] + pub(crate) code: Option, + #[serde(default)] + pub(crate) message: Option, +} + +/// Build the stream error for a provider error event, surfacing the error +/// code and the error/event message when present. +pub(crate) fn stream_error( + api: &str, + error: Option, + message: Option, +) -> LlmError { + let mut parts = Vec::new(); + if let Some(error) = &error + && let Some(code) = error.code.as_ref().and_then(|code| { + code.as_str() + .map(str::to_owned) + .or_else(|| code.as_i64().map(|code| code.to_string())) + }) + { + parts.push(format!("code {code}")); + } + if let Some(message) = error.and_then(|error| error.message) { + parts.push(message); + } + if let Some(message) = message + && !message.is_empty() + { + parts.push(message); + } + LlmError::InvalidResponse(if parts.is_empty() { + format!("{api} stream failed without an error message") + } else { + format!("{api} stream error ({})", parts.join(": ")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::StopReason; + use bytes::Bytes; + use futures_util::StreamExt; + + /// Emits one text event per frame; ends the stream on the terminal frame. + struct Counting { + terminal: String, + done: bool, + } + + impl StreamHandler for Counting { + fn done(&self) -> bool { + self.done + } + + fn frame(&mut self, data: &str) -> Result, LlmError> { + if data == self.terminal { + self.done = true; + return Ok(vec![LlmEvent::Done { + stop_reason: StopReason::Stop, + usage: None, + model: None, + }]); + } + Ok(vec![LlmEvent::TextDelta { + text: data.to_owned(), + }]) + } + + fn finish(&mut self) -> Result, LlmError> { + if self.done { + return Ok(Vec::new()); + } + Err(LlmError::InvalidResponse("missing terminal frame".into())) + } + } + + fn input(chunks: Vec<&str>) -> impl Stream> + Unpin { + futures_util::stream::iter( + chunks + .into_iter() + .map(|chunk| Ok(Bytes::from(chunk.to_owned()))) + .collect::>(), + ) + } + + async fn events( + chunks: Vec<&'static str>, + terminal: &'static str, + ) -> Result, LlmError> { + let mut events = sse_events( + input(chunks), + Counting { + terminal: terminal.to_owned(), + done: false, + }, + ); + let mut collected = Vec::new(); + while let Some(event) = events.next().await { + collected.push(event?); + } + Ok(collected) + } + + #[tokio::test] + async fn frames_become_events_in_order() { + let events = events(vec!["data: a\n\n", "data: b\n\ndata: end\n\n"], "end") + .await + .unwrap(); + assert_eq!( + events, + vec![ + LlmEvent::TextDelta { text: "a".into() }, + LlmEvent::TextDelta { text: "b".into() }, + LlmEvent::Done { + stop_reason: StopReason::Stop, + usage: None, + model: None, + }, + ] + ); + } + + #[tokio::test] + async fn frames_after_the_terminal_frame_are_dropped() { + let events = events(vec!["data: end\n\ndata: late\n\n"], "end") + .await + .unwrap(); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], LlmEvent::Done { .. })); + } + + #[tokio::test] + async fn missing_terminal_frame_is_an_error() { + let error = events(vec!["data: a\n\n"], "end").await.unwrap_err(); + assert!(error.to_string().contains("missing terminal frame")); + } + + #[test] + fn tool_indices_are_dense_and_stable() { + let mut indices = ToolIndices::default(); + assert_eq!(indices.index(Some(7)), 0); + assert_eq!(indices.index(Some(7)), 0); + assert_eq!(indices.index(Some(2)), 1); + assert_eq!(indices.index(None), 2); + assert_eq!(indices.index(None), 3); + assert_eq!(indices.index(Some(7)), 0); + } + + #[test] + fn stream_error_includes_code_and_message() { + let error = stream_error( + "test", + Some(WireError { + code: Some(serde_json::json!("rate_limit")), + message: Some("slow down".into()), + }), + Some("event message".into()), + ); + assert_eq!( + error.to_string(), + "invalid response: test stream error (code rate_limit: slow down: event message)" + ); + + let numeric = stream_error( + "test", + Some(WireError { + code: Some(serde_json::json!(429)), + message: None, + }), + None, + ); + assert_eq!( + numeric.to_string(), + "invalid response: test stream error (code 429)" + ); + + let empty = stream_error("test", None, None); + assert_eq!( + empty.to_string(), + "invalid response: test stream failed without an error message" + ); + } +} diff --git a/crates/llm/src/extensions.rs b/crates/llm/src/extensions.rs index 1642609..f1eb5d8 100644 --- a/crates/llm/src/extensions.rs +++ b/crates/llm/src/extensions.rs @@ -37,6 +37,10 @@ impl Extensions { /// `prompt_cache_key`, vanilla OpenAI-compatible APIs as `prompt_cache_key`. pub struct SessionId(pub String); +/// Extra HTTP headers to send with a request, attached per request by the +/// caller (for example a ChatGPT account id obtained from auth). +pub struct Headers(pub Vec<(String, String)>); + #[cfg(test)] mod tests { use super::*; @@ -73,6 +77,17 @@ mod tests { assert_eq!(detached.get::().unwrap().0, "other"); } + #[test] + fn headers_round_trip() { + let mut extensions = Extensions::default(); + extensions.insert(Headers(vec![("a".into(), "1".into())])); + + assert_eq!( + extensions.get::().unwrap().0, + vec![("a".into(), "1".into())] + ); + } + #[test] fn merge_layers_other_over_self() { struct Routing(Vec); diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 6e80962..b2d44ff 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -14,11 +14,11 @@ mod transport; pub mod apis; pub use api::LlmApi; -pub use apis::{ChatCompletionsApi, InteractionsApi}; +pub use apis::{ChatCompletionsApi, InteractionsApi, ResponsesApi}; pub use codec::{CodecChunk, LlmApiCodec}; pub use error::LlmError; pub use event::{LlmEvent, LlmStream}; -pub use extensions::{Extensions, SessionId}; +pub use extensions::{Extensions, Headers, SessionId}; pub use message::{ContentPart, ImageUrl, Message, Role}; pub use request::{ CompletionInput, Credential, LlmRequest, PromptCacheControl, PromptCacheControlType, diff --git a/crates/llm/src/transport.rs b/crates/llm/src/transport.rs index e4c833f..2b2c8bb 100644 --- a/crates/llm/src/transport.rs +++ b/crates/llm/src/transport.rs @@ -1,6 +1,21 @@ use crate::{Credential, LlmError}; use std::time::Duration; +/// Upper bound for error bodies surfaced in [`LlmError::Http`], so HTML +/// challenge pages do not flood logs or the UI. +const MAX_ERROR_BODY: usize = 512; + +fn truncate_bytes(value: &str, limit: usize) -> String { + if value.len() <= limit { + return value.to_string(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + format!("{}… [truncated]", &value[..end]) +} + /// Reusable HTTP client shared by API implementations. pub struct HttpClient { client: reqwest::Client, @@ -23,7 +38,18 @@ impl HttpClient { body: &str, credential: Option<&Credential>, ) -> Result { - self.send(url, body, |request| match credential { + self.post_with_headers(url, body, credential, &[]).await + } + + /// Like [`HttpClient::post`], attaching `headers` to the request as well. + pub async fn post_with_headers( + &self, + url: &str, + body: &str, + credential: Option<&Credential>, + headers: &[(String, String)], + ) -> Result { + self.send(url, body, headers, |request| match credential { Some(Credential::ApiKey(value)) => request.bearer_auth(value), Some(Credential::Header(name, value)) => request.header(name, value), Some(Credential::None) | None => request, @@ -31,10 +57,35 @@ impl HttpClient { .await } + /// POST a streaming request. A non-success status becomes + /// [`LlmError::Http`], with the body capped at `MAX_ERROR_BODY` bytes so an + /// HTML challenge page stays readable. + pub async fn post_stream( + &self, + url: &str, + body: &str, + credential: Option<&Credential>, + headers: &[(String, String)], + ) -> Result { + let response = self + .post_with_headers(url, body, credential, headers) + .await?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.map_err(LlmError::Transport)?; + return Err(LlmError::Http { + status: status.as_u16(), + body: truncate_bytes(&body, MAX_ERROR_BODY), + }); + } + Ok(response) + } + async fn send( &self, url: &str, body: &str, + headers: &[(String, String)], configure: F, ) -> Result where @@ -42,11 +93,14 @@ impl HttpClient { { let mut attempt = 0; loop { - let request = configure( + let mut request = configure( self.client .post(url) .header("Content-Type", "application/json"), ); + for (name, value) in headers { + request = request.header(name, value); + } let response = request.body(body.to_owned()).send().await; diff --git a/crates/providers/Cargo.toml b/crates/providers/Cargo.toml index e31c6b8..5f0b52d 100644 --- a/crates/providers/Cargo.toml +++ b/crates/providers/Cargo.toml @@ -5,12 +5,16 @@ edition = "2024" [dependencies] async-trait = { workspace = true } +base64 = { workspace = true } llm = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } reqwest = { workspace = true } -tokio = { workspace = true, features = ["sync", "rt"] } +tokio = { workspace = true, features = ["sync", "rt", "net", "time"] } +tracing = { workspace = true } +uuid = { workspace = true } [dev-dependencies] futures-util = { workspace = true } diff --git a/crates/providers/src/auth.rs b/crates/providers/src/auth.rs index 6ace10a..0efc2df 100644 --- a/crates/providers/src/auth.rs +++ b/crates/providers/src/auth.rs @@ -9,11 +9,13 @@ use thiserror::Error; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthMethod { ApiKey, + OAuth, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthResult { ApiKey(String), + OAuth, } #[derive(Debug, Error)] @@ -26,9 +28,19 @@ pub enum AuthError { Storage(#[from] CredentialError), } +/// Credentials resolved for one request: the request credential plus any +/// provider-specific headers to send (for example a ChatGPT account id learned +/// at sign-in). Resolving is a pure read — callers attach the headers to the +/// request; nothing is mutated in shared state. +#[derive(Debug, Clone)] +pub struct Resolved { + pub credential: RequestCredential, + pub headers: Vec<(String, String)>, +} + #[async_trait] pub trait AuthResolver: Send + Sync { - async fn resolve(&self) -> Result; + async fn resolve(&self) -> Result; } pub struct ApiKeyAuth { @@ -43,11 +55,14 @@ impl ApiKeyAuth { #[async_trait] impl AuthResolver for ApiKeyAuth { - async fn resolve(&self) -> Result { + async fn resolve(&self) -> Result { if self.key.is_empty() { Err(AuthError::Missing) } else { - Ok(RequestCredential::ApiKey(self.key.clone())) + Ok(Resolved { + credential: RequestCredential::ApiKey(self.key.clone()), + headers: Vec::new(), + }) } } } @@ -56,8 +71,11 @@ pub struct NoAuth; #[async_trait] impl AuthResolver for NoAuth { - async fn resolve(&self) -> Result { - Ok(RequestCredential::None) + async fn resolve(&self) -> Result { + Ok(Resolved { + credential: RequestCredential::None, + headers: Vec::new(), + }) } } @@ -83,11 +101,18 @@ impl CredentialAuth { #[async_trait] impl AuthResolver for CredentialAuth { - async fn resolve(&self) -> Result { + async fn resolve(&self) -> Result { if let Some(credential) = self.store.read(&self.provider).await? { return match credential { - Credential::ApiKey { key } if !key.is_empty() => Ok(RequestCredential::ApiKey(key)), + Credential::ApiKey { key } if !key.is_empty() => Ok(Resolved { + credential: RequestCredential::ApiKey(key), + headers: Vec::new(), + }), Credential::ApiKey { .. } => Err(AuthError::Missing), + // OAuth credentials are resolved by their provider's own + // resolver (`ChatGptAuth` for ChatGPT), never by this + // API-key resolver. + Credential::OAuth { .. } => Err(AuthError::Missing), }; } @@ -95,7 +120,10 @@ impl AuthResolver for CredentialAuth { && let Ok(key) = env::var(variable) && !key.is_empty() { - return Ok(RequestCredential::ApiKey(key)); + return Ok(Resolved { + credential: RequestCredential::ApiKey(key), + headers: Vec::new(), + }); } Err(AuthError::Missing) diff --git a/crates/providers/src/chatgpt.rs b/crates/providers/src/chatgpt.rs new file mode 100644 index 0000000..3a77716 --- /dev/null +++ b/crates/providers/src/chatgpt.rs @@ -0,0 +1,643 @@ +//! ChatGPT subscription provider: browser sign-in, token refresh, and a +//! stateless Responses client. +//! +//! This is an **unofficial** client for the backend the Codex CLI uses. +//! OpenAI has not published that backend, so the endpoint, client ID, and +//! header values can change or stop working without notice, and access is +//! tied to the user's own ChatGPT account. +//! +//! Tokens stay on this machine, owner-only, and are never logged or sent +//! anywhere except OpenAI. +//! +//! Sign-in mechanics live in [`crate::oauth`]; this module supplies the +//! ChatGPT configuration and turns issued tokens into stored credentials. + +use crate::oauth::{self, OAuthConfig, OAuthError, Tokens}; +use crate::{ + ApiId, AuthError, AuthMethod, AuthResolver, Credential, CredentialStore, ModelInfo, Provider, + ProviderError, ProviderId, Resolved, ServerToolInfo, SignInStart, +}; +use async_trait::async_trait; +use constants::{ + ACCOUNT_ID_CLAIM, AUTH_CLAIM_KEY, OPENAI_BETA, ORIGINATOR, PROTOCOL_VERSION, RESPONSES_BASE_URL, +}; +use llm::{HttpClient, LlmApi, ResponsesApi}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use thiserror::Error; + +/// The sign-in half of [`constants`]. +pub fn oauth_config() -> OAuthConfig { + OAuthConfig { + authorize_url: constants::AUTHORIZE_URL, + token_url: constants::TOKEN_URL, + client_id: constants::CLIENT_ID, + redirect_uri: constants::REDIRECT_URI, + redirect_path: constants::REDIRECT_PATH, + redirect_port: constants::REDIRECT_PORT, + scopes: constants::SCOPES, + extra_authorize_params: constants::EXTRA_AUTHORIZE_PARAMS, + } +} + +/// Credential metadata key holding the account id sent as the +/// `ChatGPT-Account-Id` request header. +const ACCOUNT_ID_METADATA_KEY: &str = "account_id"; + +/// Request header carrying the ChatGPT account id. +const ACCOUNT_ID_HEADER: &str = "ChatGPT-Account-Id"; + +pub mod constants { + /// Base URL of the Codex-backed Responses API; `ResponsesApi` appends the + /// `/responses` endpoint itself. + pub const RESPONSES_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; + pub const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize"; + pub const TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; + /// Unpublished client ID shared by Codex-compatible clients. + pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; + /// Fixed by the registered client; do not pick another port. + pub const REDIRECT_PORT: u16 = 1455; + pub const REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; + pub const REDIRECT_PATH: &str = "/auth/callback"; + pub const SCOPES: &str = "openid profile email offline_access"; + /// Extra parameters the authorization URL must carry. + pub const EXTRA_AUTHORIZE_PARAMS: &[(&str, &str)] = &[("idv_captcha", "1")]; + /// JWT claim path holding the ChatGPT account id. + pub const AUTH_CLAIM_KEY: &str = "https://api.openai.com/auth"; + pub const ACCOUNT_ID_CLAIM: &str = "chatgpt_account_id"; + /// Our own client name; never impersonate the Codex CLI. + pub const ORIGINATOR: &str = "alan"; + /// Protocol revision sent in the `version` header. The backend gates newer + /// models on a minimum client version: on 2026-10-08 `gpt-5.6-luna` + /// rejected the crate version `0.1.0`. `originator` and `User-Agent` still + /// identify us as `alan`. Bump when the backend demands a newer contract. + pub const PROTOCOL_VERSION: &str = "0.161.0"; + /// Beta opt-in header sent with every request. + pub const OPENAI_BETA: &str = "responses=experimental"; + /// Shown before the browser sign-in starts. + pub const SIGN_IN_NOTICE: &str = "Unofficial client. Personal use only; access depends on OpenAI's terms and may stop working at any time."; +} + +#[derive(Debug, Error)] +pub enum ChatGptError { + #[error(transparent)] + SignIn(#[from] OAuthError), + #[error("no ChatGPT account id found in the issued tokens")] + MissingAccount, +} + +/// Extract the ChatGPT account id from a JWT payload. The signature is not +/// verified: the token was just issued to us over TLS, and the value only ever +/// becomes a request header, never an authorization decision. +pub fn account_id_from_token(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload.as_bytes(), + ) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + claims + .get(AUTH_CLAIM_KEY) + .and_then(|auth| auth.get(ACCOUNT_ID_CLAIM)) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + +/// Stored ChatGPT credentials with the account id resolved from the token. +#[derive(Debug, Clone)] +pub struct OAuthSecrets { + pub access_token: String, + pub refresh_token: String, + pub id_token: String, + pub expires_at: u64, + pub account_id: String, +} + +impl OAuthSecrets { + pub fn from_tokens(tokens: Tokens) -> Result { + let account_id = account_id_from_token(&tokens.id_token) + .or_else(|| account_id_from_token(&tokens.access_token)) + .ok_or(ChatGptError::MissingAccount)?; + Ok(Self { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + expires_at: unix_now() + tokens.expires_in.saturating_sub(EXPIRY_MARGIN_SECS), + account_id, + }) + } + + pub fn expired(&self) -> bool { + unix_now() >= self.expires_at + } + + /// Serialize the account id into generic credential metadata. + pub fn to_metadata(&self) -> BTreeMap { + BTreeMap::from([(ACCOUNT_ID_METADATA_KEY.to_string(), self.account_id.clone())]) + } +} + +const EXPIRY_MARGIN_SECS: u64 = 120; + +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + +/// Auth resolver for the ChatGPT provider: reads stored OAuth credentials and +/// refreshes them when expired, once even when callers race. Resolving returns +/// the access token plus the `ChatGPT-Account-Id` header as [`Resolved::headers`]. +pub struct ChatGptAuth { + provider: ProviderId, + store: Arc, + /// Single-flight for refreshes; also serializes store writes. + refresh_lock: tokio::sync::Mutex<()>, + /// Performs the refresh-token grant; swappable in tests. + refresh: Arc, +} + +/// Performs the refresh-token grant. +#[async_trait] +pub trait TokenRefresher: Send + Sync { + async fn refresh(&self, refresh_token: String) -> Result; +} + +struct LiveTokenRefresher; + +#[async_trait] +impl TokenRefresher for LiveTokenRefresher { + async fn refresh(&self, refresh_token: String) -> Result { + oauth::refresh_tokens(&oauth_config(), &refresh_token) + .await + .map_err(|error| AuthError::Validation(error.to_string())) + } +} + +#[async_trait] +impl AuthResolver for ChatGptAuth { + async fn resolve(&self) -> Result { + let credential = self + .store + .read(&self.provider) + .await? + .ok_or(AuthError::Missing)?; + + let secrets = match credential { + Credential::OAuth { + access_token, + refresh_token, + id_token, + expires_at, + metadata, + } => OAuthSecrets { + access_token, + refresh_token, + id_token, + expires_at, + account_id: metadata + .get(ACCOUNT_ID_METADATA_KEY) + .cloned() + .ok_or(AuthError::Missing)?, + }, + Credential::ApiKey { .. } => { + return Err(AuthError::Missing); + } + }; + + if secrets.expired() { + self.refresh(secrets).await + } else { + Ok(secrets) + } + .map(|secrets| Resolved { + credential: llm::Credential::ApiKey(secrets.access_token), + headers: vec![(ACCOUNT_ID_HEADER.to_string(), secrets.account_id)], + }) + } +} + +impl ChatGptAuth { + pub fn new(provider: ProviderId, store: Arc) -> Self { + Self { + provider, + store, + refresh_lock: tokio::sync::Mutex::new(()), + refresh: Arc::new(LiveTokenRefresher), + } + } + + /// Replace the refresh grant, e.g. for tests. + pub fn with_refresh(mut self, refresh: Arc) -> Self { + self.refresh = refresh; + self + } + + async fn refresh(&self, stale: OAuthSecrets) -> Result { + let _guard = self.refresh_lock.lock().await; + + // Another caller may have refreshed while we waited; re-read instead + // of burning the (single-use) refresh token we hold. + if let Some(Credential::OAuth { + access_token, + refresh_token, + id_token, + expires_at, + metadata, + }) = self.store.read(&self.provider).await? + && expires_at > stale.expires_at + { + return Ok(OAuthSecrets { + access_token, + refresh_token, + id_token, + expires_at, + account_id: metadata + .get(ACCOUNT_ID_METADATA_KEY) + .cloned() + .ok_or(AuthError::Missing)?, + }); + } + + let tokens = self + .refresh + .refresh(stale.refresh_token) + .await + .map_err(|error| AuthError::Validation(error.to_string()))?; + let refreshed = OAuthSecrets::from_tokens(tokens) + .map_err(|error| AuthError::Validation(error.to_string()))?; + + self.store + .put( + &self.provider, + Credential::OAuth { + access_token: refreshed.access_token.clone(), + refresh_token: refreshed.refresh_token.clone(), + id_token: refreshed.id_token.clone(), + expires_at: refreshed.expires_at, + metadata: refreshed.to_metadata(), + }, + ) + .await + .map_err(AuthError::Storage)?; + + Ok(refreshed) + } +} + +/// The ChatGPT subscription provider. +pub struct ChatGptProvider { + id: ProviderId, + models: Vec, + apis: HashMap>, + auth: Arc, +} + +impl ChatGptProvider { + pub fn from_store(store: Arc) -> ChatGptProviderBuilder { + let auth = Arc::new(ChatGptAuth::new(ProviderId::new("chatgpt"), store)); + ChatGptProviderBuilder { + auth, + models: default_models(), + } + } + + pub fn builder(auth: Arc) -> ChatGptProviderBuilder { + ChatGptProviderBuilder { + auth, + models: default_models(), + } + } +} + +fn default_models() -> Vec { + let models = [ + ("gpt-6-astra", "GPT-6 Astra"), + ("gpt-6.1-sol", "GPT-6.1 Sol"), + ("gpt-6-luna", "GPT-6 Luna"), + ]; + models + .map(|(id, name)| ModelInfo { + provider: ProviderId::new("chatgpt"), + id: id.to_string(), + name: name.to_string(), + pricing: None, + context_length: Some(1_000_000), + }) + .to_vec() +} + +pub struct ChatGptProviderBuilder { + auth: Arc, + models: Vec, +} + +impl ChatGptProviderBuilder { + pub fn with_models(mut self, models: impl IntoIterator) -> Self { + self.models = models.into_iter().collect(); + self + } + + pub fn build(self) -> ChatGptProvider { + let api: Arc = Arc::new( + ResponsesApi::new(RESPONSES_BASE_URL, Arc::new(HttpClient::new())) + .with_header("Accept", "text/event-stream") + .with_header("OpenAI-Beta", OPENAI_BETA) + .with_header("originator", ORIGINATOR) + .with_header("version", PROTOCOL_VERSION) + .with_header("User-Agent", concat!("alan/", env!("CARGO_PKG_VERSION"))), + ); + ChatGptProvider { + id: ProviderId::new("chatgpt"), + models: self.models, + apis: HashMap::from([(ApiId::OpenAiResponses, api)]), + auth: self.auth, + } + } +} + +#[async_trait] +impl Provider for ChatGptProvider { + fn id(&self) -> ProviderId { + self.id.clone() + } + + fn models(&self) -> Vec { + self.models.clone() + } + + fn server_tools(&self) -> &[ServerToolInfo] { + &[] + } + + fn auth_methods(&self) -> Vec { + vec![AuthMethod::OAuth] + } + + fn apis(&self) -> &HashMap> { + &self.apis + } + + fn auth(&self) -> Arc { + self.auth.clone() + } + + async fn validate_auth( + &self, + _auth_result: &crate::AuthResult, + ) -> Result<(), crate::auth::AuthError> { + // The sign-in flow already proved the credentials, and every request + // exercises the stored token; a live probe would need another + // unpublished endpoint. + Ok(()) + } + + fn sign_in_notice(&self) -> Option<&'static str> { + Some(constants::SIGN_IN_NOTICE) + } + + async fn start_sign_in(&self) -> Result { + oauth::start_sign_in(&oauth_config()) + .await + .map_err(|error| ProviderError::SignIn(Box::new(error))) + } + + async fn finish_sign_in(&self, start: SignInStart) -> Result { + let tokens = oauth::finish_sign_in(&oauth_config(), start) + .await + .map_err(|error| ProviderError::SignIn(Box::new(error)))?; + let secrets = OAuthSecrets::from_tokens(tokens) + .map_err(|error| ProviderError::SignIn(error.into()))?; + let metadata = secrets.to_metadata(); + Ok(Credential::OAuth { + access_token: secrets.access_token, + refresh_token: secrets.refresh_token, + id_token: secrets.id_token, + expires_at: secrets.expires_at, + metadata, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::InMemoryCredentialStore; + use std::sync::atomic::{AtomicUsize, Ordering}; + + async fn store_with(credential: Credential) -> Arc { + let store = InMemoryCredentialStore::new(); + store + .put(&ProviderId::new("chatgpt"), credential) + .await + .expect("in-memory store write cannot fail"); + Arc::new(store) + } + + fn oauth_credential(account_id: &str, expires_at: u64) -> Credential { + Credential::OAuth { + access_token: "live-access".into(), + refresh_token: "live-refresh".into(), + id_token: id_token_for(account_id), + expires_at, + metadata: BTreeMap::from([( + ACCOUNT_ID_METADATA_KEY.to_string(), + account_id.to_string(), + )]), + } + } + + fn id_token_for(account_id: &str) -> String { + let claims = serde_json::json!({ + AUTH_CLAIM_KEY: { ACCOUNT_ID_CLAIM: account_id }, + }); + let payload = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + serde_json::to_vec(&claims).unwrap().as_slice(), + ); + format!("eyJhbGciOiJSUzI1NiJ9.{payload}.not-checked") + } + + #[test] + fn oauth_config_matches_the_codex_endpoints() { + let config = oauth_config(); + assert_eq!(config.authorize_url, constants::AUTHORIZE_URL); + assert_eq!(config.token_url, constants::TOKEN_URL); + assert_eq!(config.client_id, constants::CLIENT_ID); + assert_eq!(config.redirect_uri, constants::REDIRECT_URI); + assert_eq!(config.redirect_port, constants::REDIRECT_PORT); + assert_eq!(config.extra_authorize_params, &[("idv_captcha", "1")]); + } + + #[test] + fn account_id_is_read_from_the_auth_claim() { + let token = id_token_for("acc_123"); + assert_eq!(account_id_from_token(&token).as_deref(), Some("acc_123")); + // Malformed tokens yield None instead of panicking. + assert_eq!(account_id_from_token("not-a-jwt"), None); + assert_eq!( + account_id_from_token("a.eyJzdWIiOiJ4In0.c"), + None, + "missing claim must be None" + ); + } + + #[tokio::test] + async fn single_flight_refreshes_once_under_concurrency() { + let calls = Arc::new(AtomicUsize::new(0)); + let refresh_calls = calls.clone(); + + struct CountingRefresher { + calls: Arc, + } + + #[async_trait] + impl TokenRefresher for CountingRefresher { + async fn refresh(&self, _refresh_token: String) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + // Widen the race window so concurrent callers pile up behind + // the single-flight lock. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + Ok(Tokens { + access_token: "fresh-access".into(), + refresh_token: "fresh-refresh".into(), + id_token: id_token_for("acct-1"), + expires_in: 3600, + }) + } + } + + let auth = ChatGptAuth::new( + ProviderId::new("chatgpt"), + store_with(oauth_credential("acct-1", unix_now().saturating_sub(10))).await, + ) + .with_refresh(Arc::new(CountingRefresher { + calls: refresh_calls.clone(), + })); + + let auth = Arc::new(auth); + let mut tasks = Vec::new(); + for _ in 0..5 { + let auth = auth.clone(); + tasks.push(tokio::spawn(async move { auth.resolve().await })); + } + let mut resolved = Vec::new(); + for task in tasks { + resolved.push(task.await.unwrap().unwrap()); + } + + // Every caller sees the same fresh token, and the grant ran once. + assert!(resolved.iter().all( + |r| matches!(&r.credential, llm::Credential::ApiKey(value) if value == "fresh-access") + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + // Each resolution carries the account id as a request header. + assert!( + resolved + .iter() + .all(|r| r.headers == vec![(ACCOUNT_ID_HEADER.to_string(), "acct-1".to_string())]) + ); + } + + #[tokio::test] + async fn valid_credentials_resolve_without_refreshing() { + let calls = Arc::new(AtomicUsize::new(0)); + + struct CountingRefresher { + calls: Arc, + } + + #[async_trait] + impl TokenRefresher for CountingRefresher { + async fn refresh(&self, _refresh_token: String) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(Tokens { + access_token: "should-not-be-used".into(), + refresh_token: "x".into(), + id_token: id_token_for("acct-1"), + expires_in: 3600, + }) + } + } + + let credential = oauth_credential("acct-1", unix_now() + 3600); + let auth = ChatGptAuth::new(ProviderId::new("chatgpt"), store_with(credential).await) + .with_refresh(Arc::new(CountingRefresher { + calls: calls.clone(), + })); + + let resolved = auth.resolve().await.unwrap(); + assert!(matches!( + resolved.credential, + llm::Credential::ApiKey(value) if value == "live-access" + )); + assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!( + resolved.headers, + vec![(ACCOUNT_ID_HEADER.to_string(), "acct-1".to_string())] + ); + } + + #[tokio::test] + async fn credentials_without_account_metadata_are_an_error() { + let credential = Credential::OAuth { + access_token: "live-access".into(), + refresh_token: "live-refresh".into(), + id_token: id_token_for("acct-1"), + expires_at: unix_now() + 3600, + metadata: BTreeMap::new(), + }; + let auth = ChatGptAuth::new(ProviderId::new("chatgpt"), store_with(credential).await); + let error = auth.resolve().await.unwrap_err(); + assert!(error.to_string().contains("not configured")); + } + + #[tokio::test] + async fn missing_credentials_are_an_error() { + let auth = ChatGptAuth::new( + ProviderId::new("chatgpt"), + Arc::new(InMemoryCredentialStore::new()), + ); + let error = auth.resolve().await.unwrap_err(); + assert!(error.to_string().contains("not configured")); + } + + #[test] + fn oauth_secrets_resolve_account_and_margin() { + let tokens = Tokens { + access_token: "a".into(), + refresh_token: "r".into(), + id_token: id_token_for("acct-9"), + expires_in: 7200, + }; + let secrets = OAuthSecrets::from_tokens(tokens).unwrap(); + assert_eq!(secrets.account_id, "acct-9"); + assert!(!secrets.expired()); + // The refresh margin is applied: expires_at < now + expires_in. + assert!(secrets.expires_at < unix_now() + 7200); + // The account id survives the metadata round trip. + let metadata = secrets.to_metadata(); + assert_eq!( + metadata.get(ACCOUNT_ID_METADATA_KEY).map(String::as_str), + Some("acct-9") + ); + } + + #[test] + fn missing_account_id_fails_clearly() { + let tokens = Tokens { + access_token: "a".into(), + refresh_token: "r".into(), + // JWT without the account claim. + id_token: "e30.x.e30".into(), + expires_in: 7200, + }; + let error = OAuthSecrets::from_tokens(tokens).unwrap_err(); + assert!(error.to_string().contains("account id")); + } +} diff --git a/crates/providers/src/credentials.rs b/crates/providers/src/credentials.rs index dd0eff8..209d2c4 100644 --- a/crates/providers/src/credentials.rs +++ b/crates/providers/src/credentials.rs @@ -13,7 +13,21 @@ use thiserror::Error; #[derive(Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Credential { - ApiKey { key: String }, + ApiKey { + key: String, + }, + /// Generic OAuth credentials. Provider-specific claims (for example a + /// ChatGPT account id) live in `metadata`; secret material there is + /// shielded by the manual `Debug` impl below. + OAuth { + access_token: String, + refresh_token: String, + id_token: String, + /// Unix timestamp (seconds) after which the access token must be + /// refreshed. + expires_at: u64, + metadata: BTreeMap, + }, } impl fmt::Debug for Credential { @@ -24,6 +38,7 @@ impl fmt::Debug for Credential { "type", &match self { Self::ApiKey { .. } => "api_key", + Self::OAuth { .. } => "oauth", }, ) .finish() @@ -39,6 +54,7 @@ pub struct CredentialInfo { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CredentialKind { ApiKey, + OAuth, } #[derive(Debug, Error)] @@ -100,6 +116,7 @@ impl CredentialStore for InMemoryCredentialStore { provider: ProviderId::new(provider), kind: match credential { Credential::ApiKey { .. } => CredentialKind::ApiKey, + Credential::OAuth { .. } => CredentialKind::OAuth, }, }) .collect()) @@ -152,7 +169,24 @@ impl FileCredentialStore { fn read_all(&self) -> Result, CredentialError> { match fs::read_to_string(&self.path) { - Ok(contents) => serde_json::from_str(&contents).map_err(CredentialError::Serialization), + Ok(contents) => { + // Migrate before typing: pre-rename files carry the old + // `chat_gpt_o_auth` tag and a flat `account_id` field, which + // would otherwise fail to parse or be silently dropped. + let mut entries: BTreeMap = + serde_json::from_str(&contents).map_err(CredentialError::Serialization)?; + for entry in entries.values_mut() { + migrate_legacy_oauth(entry); + } + entries + .into_iter() + .map(|(provider, entry)| { + let credential: Credential = serde_json::from_value(entry) + .map_err(CredentialError::Serialization)?; + Ok((provider, credential)) + }) + .collect() + } Err(error) if error.kind() == ErrorKind::NotFound => Ok(BTreeMap::new()), Err(error) => Err(CredentialError::Io(error)), } @@ -205,6 +239,7 @@ impl CredentialStore for FileCredentialStore { provider: ProviderId::new(provider), kind: match credential { Credential::ApiKey { .. } => CredentialKind::ApiKey, + Credential::OAuth { .. } => CredentialKind::OAuth, }, }) .collect()) @@ -266,9 +301,179 @@ fn set_unix_mode(path: &Path, mode: u32) -> std::io::Result<()> { fs::set_permissions(path, fs::Permissions::from_mode(mode)) } +/// Migrate one legacy credential entry in place: files written before the +/// OAuth rename serialize `Credential::ChatGptOAuth` as +/// `{"type": "chat_gpt_o_auth", ..., "account_id": "..."}`; the current +/// variant is `"oauth"` with provider claims in a nested `metadata` map. +fn migrate_legacy_oauth(entry: &mut serde_json::Value) { + let Some(object) = entry.as_object_mut() else { + return; + }; + let is_legacy = object + .get("type") + .and_then(serde_json::Value::as_str) + .is_some_and(|tag| tag == "chat_gpt_o_auth" || tag == "chatgpt_oauth"); + if !is_legacy { + return; + } + object.insert( + "type".to_string(), + // serde's snake_case of the `OAuth` variant is `o_auth`. + serde_json::Value::String("o_auth".to_string()), + ); + let account_id = object.remove("account_id"); + if let Some(account_id) = account_id { + let mut metadata = serde_json::Map::new(); + metadata.insert("account_id".to_string(), account_id); + object.insert("metadata".to_string(), serde_json::Value::Object(metadata)); + } +} + #[cfg(not(unix))] fn set_unix_mode(_path: &Path, _mode: u32) -> std::io::Result<()> { Ok(()) } pub type SharedCredentialStore = Arc; + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("alan-credentials-test-{name}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn legacy_oauth_files_still_parse() { + let dir = temp_dir("legacy-oauth"); + let path = dir.join("credentials.json"); + // Written by the pre-rename binary: serde's snake_case of the old + // `ChatGptOAuth` variant. + fs::write( + &path, + r#"{"chatgpt": {"type": "chat_gpt_o_auth", "access_token": "at", "refresh_token": "rt", "id_token": "it", "expires_at": 12345, "account_id": "acc"}}"#, + ) + .unwrap(); + + let store = FileCredentialStore::new(&path); + let read = tokio::runtime::Runtime::new() + .unwrap() + .block_on(store.read(&ProviderId::new("chatgpt"))) + .unwrap(); + assert!( + matches!(read, Some(Credential::OAuth { ref metadata, .. }) + if metadata.get("account_id").map(String::as_str) == Some("acc")), + "legacy OAuth entry must still parse, got {read:?}" + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn legacy_api_key_files_still_parse() { + let dir = temp_dir("legacy"); + let path = dir.join("credentials.json"); + fs::write( + &path, + r#"{"deepseek": {"type": "api_key", "key": "sk-old"}}"#, + ) + .unwrap(); + + let store = FileCredentialStore::new(&path); + let read = tokio::runtime::Runtime::new() + .unwrap() + .block_on(store.read(&ProviderId::new("deepseek"))) + .unwrap(); + assert!( + matches!(read, Some(Credential::ApiKey { ref key }) if key == "sk-old"), + "legacy ApiKey entry must still parse, got {read:?}" + ); + let _ = fs::remove_dir_all(&dir); + } + + fn oauth_credential() -> Credential { + Credential::OAuth { + access_token: "at".into(), + refresh_token: "rt".into(), + id_token: "it".into(), + expires_at: 12345, + metadata: BTreeMap::from([("account_id".to_string(), "acc".to_string())]), + } + } + + #[test] + fn oauth_credentials_round_trip_through_the_file() { + let dir = temp_dir("oauth"); + let path = dir.join("credentials.json"); + let store = FileCredentialStore::new(&path); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime + .block_on(store.put(&ProviderId::new("chatgpt"), oauth_credential())) + .unwrap(); + let read = runtime + .block_on(store.read(&ProviderId::new("chatgpt"))) + .unwrap(); + let Some(Credential::OAuth { metadata, .. }) = &read else { + panic!("expected an OAuth credential, got {read:?}"); + }; + // Provider-specific metadata survives the round trip. + assert_eq!(metadata.get("account_id").map(String::as_str), Some("acc")); + + // Both variants coexist in one file. + runtime + .block_on(store.put( + &ProviderId::new("deepseek"), + Credential::ApiKey { key: "sk".into() }, + )) + .unwrap(); + let kinds = runtime.block_on(store.list()).unwrap(); + assert_eq!(kinds.len(), 2); + assert!(kinds.iter().any(|info| info.kind == CredentialKind::OAuth)); + assert!(kinds.iter().any(|info| info.kind == CredentialKind::ApiKey)); + let _ = fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn file_is_written_owner_only() { + let dir = temp_dir("mode"); + let path = dir.join("credentials.json"); + let store = FileCredentialStore::new(&path); + let runtime = tokio::runtime::Runtime::new().unwrap(); + + runtime + .block_on(store.put( + &ProviderId::new("chatgpt"), + Credential::ApiKey { key: "k".into() }, + )) + .unwrap(); + + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn debug_never_shows_token_values() { + let api_key = Credential::ApiKey { + key: "super-secret-key".into(), + }; + let oauth = Credential::OAuth { + access_token: "secret-access".into(), + refresh_token: "secret-refresh".into(), + id_token: "secret-id".into(), + expires_at: 1, + metadata: BTreeMap::from([("account_id".to_string(), "secret-account".to_string())]), + }; + let rendered = format!("{api_key:?} {oauth:?}"); + assert!(rendered.contains("api_key")); + assert!(rendered.contains("oauth")); + assert!(!rendered.contains("secret")); + assert!(!rendered.contains("super-secret-key")); + } +} diff --git a/crates/providers/src/deepseek.rs b/crates/providers/src/deepseek.rs index 5acaed1..5617b40 100644 --- a/crates/providers/src/deepseek.rs +++ b/crates/providers/src/deepseek.rs @@ -194,6 +194,8 @@ impl Provider for DeepSeekProvider { ) -> Result<(), crate::auth::AuthError> { match auth_result { crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + // OAuth is handled by the ChatGPT provider's own resolver. + crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } @@ -203,7 +205,8 @@ impl Provider for DeepSeekProvider { .auth .resolve() .await - .map_err(|error| ProviderError::Fetch(format!("authentication failed: {error}")))?; + .map_err(|error| ProviderError::Fetch(format!("authentication failed: {error}")))? + .credential; let mut request = reqwest::Client::new().get(format!("{BASE_URL}/models")); if let llm::Credential::ApiKey(key) = credential { request = request.bearer_auth(key); diff --git a/crates/providers/src/google.rs b/crates/providers/src/google.rs index 026e324..a84d15a 100644 --- a/crates/providers/src/google.rs +++ b/crates/providers/src/google.rs @@ -116,6 +116,8 @@ impl Provider for GoogleProvider { ) -> Result<(), crate::auth::AuthError> { match auth_result { crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + // OAuth is handled by the ChatGPT provider's own resolver. + crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 4e4cfb5..981af61 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -1,10 +1,12 @@ mod auth; mod catalog; +mod chatgpt; mod credentials; mod deepseek; mod google; mod local; mod model; +mod oauth; mod openrouter; mod provider; mod store; @@ -13,9 +15,13 @@ mod zai; pub use deepseek::{DeepSeekBuilder, DeepSeekProvider}; pub use auth::{ - ApiKeyAuth, AuthError, AuthMethod, AuthResolver, AuthResult, CredentialAuth, NoAuth, + ApiKeyAuth, AuthError, AuthMethod, AuthResolver, AuthResult, CredentialAuth, NoAuth, Resolved, }; pub use catalog::{ApiId, ModelCapabilities, ModelInfo, ModelPricing, ProviderId, ServerToolInfo}; +pub use chatgpt::{ + ChatGptAuth, ChatGptError, ChatGptProvider, ChatGptProviderBuilder, OAuthSecrets, + account_id_from_token, constants, +}; pub use credentials::{ Credential, CredentialError, CredentialInfo, CredentialKind, CredentialStore, FileCredentialStore, InMemoryCredentialStore, SharedCredentialStore, @@ -23,6 +29,10 @@ pub use credentials::{ pub use google::{GoogleBuilder, GoogleProvider}; pub use local::{LocalApi, LocalModelEntry, LocalProvider, bind_local_model, list_local_models}; pub use model::{Model, ModelError, ModelOptions}; +pub use oauth::{ + OAuthConfig, OAuthError, SignInStart, Tokens as OAuthTokens, exchange_code, parse_callback, + refresh_tokens, start_sign_in as start_oauth_sign_in, +}; pub use openrouter::{OpenRouterBuilder, OpenRouterProvider, Options as OpenRouterOptions}; pub use provider::{Provider, ProviderError, ProviderRegistry, bind_model}; pub use store::LocalModelStore; diff --git a/crates/providers/src/model.rs b/crates/providers/src/model.rs index 3960e45..2a5af07 100644 --- a/crates/providers/src/model.rs +++ b/crates/providers/src/model.rs @@ -1,7 +1,7 @@ use crate::auth::{AuthError, AuthResolver}; use crate::catalog::ModelInfo; use llm::{ - CompletionInput, Extensions, LlmApi, LlmError, LlmRequest, LlmResponse, LlmStream, + CompletionInput, Extensions, Headers, LlmApi, LlmError, LlmRequest, LlmResponse, LlmStream, ReasoningEffort, ServerTool, ToolSpec, }; use std::sync::Arc; @@ -89,36 +89,38 @@ impl Model { } pub async fn complete(&self, input: CompletionInput<'_>) -> Result { - let credential = self.auth.resolve().await?; + let resolved = self.auth.resolve().await?; let tools = self.tools(input.tools); - let mut extensions = self.extensions.clone(); - extensions.merge(input.extensions); - let request = LlmRequest { - model_id: &self.info.id, - messages: input.messages, - tools: &tools, - options: input.options, - credential: Some(&credential), - reasoning_effort: self.reasoning_effort, - extensions, - }; + let request = self.build_request(&resolved, input, &tools); Ok(self.api.complete(request).await?) } pub async fn stream(&self, input: CompletionInput<'_>) -> Result { - let credential = self.auth.resolve().await?; + let resolved = self.auth.resolve().await?; let tools = self.tools(input.tools); + let request = self.build_request(&resolved, input, &tools); + Ok(self.api.stream(request).await?) + } + + fn build_request<'a>( + &'a self, + resolved: &'a crate::auth::Resolved, + input: CompletionInput<'a>, + tools: &'a [ToolSpec], + ) -> LlmRequest<'a> { let mut extensions = self.extensions.clone(); extensions.merge(input.extensions); - let request = LlmRequest { + if !resolved.headers.is_empty() { + extensions.insert(Headers(resolved.headers.clone())); + } + LlmRequest { model_id: &self.info.id, messages: input.messages, - tools: &tools, + tools, options: input.options, - credential: Some(&credential), + credential: Some(&resolved.credential), reasoning_effort: self.reasoning_effort, extensions, - }; - Ok(self.api.stream(request).await?) + } } } diff --git a/crates/providers/src/oauth.rs b/crates/providers/src/oauth.rs new file mode 100644 index 0000000..c165e64 --- /dev/null +++ b/crates/providers/src/oauth.rs @@ -0,0 +1,528 @@ +//! OAuth 2.0 browser sign-in. +//! +//! This module owns the flow mechanics (PKCE, loopback callback capture, +//! authorization URLs, token exchange and refresh), configured through +//! [`OAuthConfig`]. Providers supply their own endpoints, client id, and fixed +//! redirect port; `chatgpt.rs` is the reference consumer. + +use base64::Engine; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::Duration; +use thiserror::Error; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Values tied to a provider's registered client (client id, redirect port, +/// scopes) or to a published endpoint, so each is `'static`. +#[derive(Debug, Clone, Copy)] +pub struct OAuthConfig { + pub authorize_url: &'static str, + pub token_url: &'static str, + pub client_id: &'static str, + /// Absolute redirect URI registered with the provider; its port is the + /// loopback port bound by [`start_sign_in`]. + pub redirect_uri: &'static str, + /// Path of [`OAuthConfig::redirect_uri`], used to recognize callbacks. + pub redirect_path: &'static str, + /// Fixed redirect port; the provider's client registration pins it. + pub redirect_port: u16, + pub scopes: &'static str, + /// Provider quirks appended to the authorization URL, e.g. captcha + /// flags demanded by unpublished backends. + pub extra_authorize_params: &'static [(&'static str, &'static str)], +} + +#[derive(Debug, Error)] +pub enum OAuthError { + #[error("OAuth flow failed: {0}")] + Flow(String), + #[error( + "port {0} is already in use; the sign-in redirect target is fixed, \ + so free the port and try again" + )] + PortBusy(u16), + #[error("browser sign-in timed out; no callback arrived on port {0}")] + CallbackTimeout(u16), +} + +/// PKCE pair: S256 challenge over a URL-safe random verifier. +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +fn create_pkce() -> Pkce { + // 64 random bytes -> 86 base64url characters, within the 43–128 range. + let mut bytes = [0u8; 64]; + fill_random(&mut bytes); + let verifier = base64_url(&bytes); + Pkce { + challenge: pkce_challenge(&verifier), + verifier, + } +} + +/// S256 challenge for a PKCE verifier (RFC 7636, appendix B). +fn pkce_challenge(verifier: &str) -> String { + base64_url(&Sha256::digest(verifier.as_bytes())) +} + +fn base64_url(bytes: &[u8]) -> String { + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +fn fill_random(buffer: &mut [u8]) { + for chunk in buffer.chunks_mut(16) { + chunk.copy_from_slice(&uuid::Uuid::new_v4().into_bytes()[..chunk.len()]); + } +} + +fn create_state() -> String { + uuid::Uuid::new_v4().simple().to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Tokens { + pub access_token: String, + pub refresh_token: String, + pub id_token: String, + pub expires_in: u64, +} + +pub async fn exchange_code( + config: &OAuthConfig, + code: &str, + verifier: &str, +) -> Result { + token_request( + config, + &[ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", config.redirect_uri), + ("client_id", config.client_id), + ("code_verifier", verifier), + ], + "code exchange", + ) + .await +} + +pub async fn refresh_tokens( + config: &OAuthConfig, + refresh_token: &str, +) -> Result { + token_request( + config, + &[ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", config.client_id), + ], + "token refresh", + ) + .await +} + +/// A hung token endpoint must not block sign-in forever. +const TOKEN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Client reused by every token grant, so the TLS session and connection +/// pool are shared instead of rebuilt for each exchange or refresh. +fn token_client() -> &'static reqwest::Client { + static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(TOKEN_REQUEST_TIMEOUT) + .build() + .expect("build the token client (fails only on invalid proxy configuration)") + }) +} + +async fn token_request( + config: &OAuthConfig, + form: &[(&str, &str)], + what: &str, +) -> Result { + let body = form + .iter() + .map(|(key, value)| format!("{}={}", urlencode(key), urlencode(value))) + .collect::>() + .join("&"); + let response = token_client() + .post(config.token_url) + .header("content-type", "application/x-www-form-urlencoded") + .body(body) + .send() + .await + .map_err(|error| OAuthError::Flow(format!("{what} request failed: {error}")))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| OAuthError::Flow(format!("{what} response failed: {error}")))?; + + if !status.is_success() { + // Surface status and the OAuth `error` field; never echo token + // values (the body here is an error payload, not a token). + let oauth_error = serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .get("error") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }); + return Err(OAuthError::Flow(match oauth_error { + Some(error) => format!("{what} returned HTTP {status} ({error})"), + None => format!("{what} returned HTTP {status}"), + })); + } + + serde_json::from_str(&body) + .map_err(|error| OAuthError::Flow(format!("{what} returned invalid JSON: {error}"))) +} + +/// The program used to open a URL in the system browser, by platform. +fn browser_opener() -> Option<&'static str> { + #[cfg(target_os = "macos")] + { + Some("open") + } + #[cfg(target_os = "windows")] + { + Some("cmd") + } + #[cfg(all(unix, not(target_os = "macos")))] + { + Some("xdg-open") + } +} + +/// Open the system browser at `url`. Failure is not fatal: the URL is shown +/// in the UI so the user can open it by hand. +async fn open_browser(url: &str) { + let Some(program) = browser_opener() else { + tracing::debug!(%url, "no known browser opener for this platform"); + return; + }; + let mut command = std::process::Command::new(program); + // Windows' `cmd` needs `/C start ""` to launch a URL; other shells do not. + #[cfg(target_os = "windows")] + command.arg("/C").arg("start").arg("").arg(url); + #[cfg(not(target_os = "windows"))] + command.arg(url); + command + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + let spawned = tokio::task::spawn_blocking(move || command.spawn()).await; + if let Ok(Err(error)) = spawned { + tracing::debug!(%url, %error, "could not open the browser automatically"); + } +} + +const CALLBACK_PAGE: &str = "

Sign-in complete. You can close this tab and return to the terminal.

"; + +/// Parse a redirect target such as `/auth/callback?code=..&state=..`. +/// Returns the code only when the path matches and the state matches. +pub fn parse_callback(config: &OAuthConfig, target: &str, state: &str) -> Option { + if !target.starts_with(config.redirect_path) { + return None; + } + let query = target.split('?').nth(1)?; + let mut code = None; + let mut state_ok = false; + for pair in query.split('&') { + let Some((key, value)) = pair.split_once('=') else { + continue; + }; + match key { + "code" => code = Some(value.to_string()), + "state" if value == state => state_ok = true, + _ => {} + } + } + match (code, state_ok) { + (Some(code), true) => Some(code), + _ => None, + } +} + +/// Accept callbacks on an already-bound listener until one carries a valid +/// code and state, or the timeout elapses. +pub async fn await_callback_on( + listener: TcpListener, + config: &OAuthConfig, + state: &str, + timeout: Duration, +) -> Result { + let result = tokio::time::timeout(timeout, async { + loop { + let (mut stream, _) = listener.accept().await.map_err(|error| { + OAuthError::Flow(format!("callback listener failed: {error}")) + })?; + let mut buffer = Vec::new(); + // Read until the header block ends; browsers may keep the + // connection open, so do not wait for EOF. The check runs after the + // bytes are appended, or a request arriving in a single packet + // would never be answered. + let mut chunk = [0u8; 1024]; + loop { + let read = stream.read(&mut chunk).await.map_err(|error| { + OAuthError::Flow(format!("callback read failed: {error}")) + })?; + buffer.extend_from_slice(&chunk[..read]); + if read == 0 + || buffer.windows(4).any(|window| window == b"\r\n\r\n") + || buffer.len() > 16 * 1024 + { + break; + } + } + let request = String::from_utf8_lossy(&buffer).into_owned(); + let target = request + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_string(); + + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: text/html\r\nconnection: close\r\ncontent-length: {}\r\n\r\n{}", + CALLBACK_PAGE.len(), + CALLBACK_PAGE + ); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + + if let Some(code) = parse_callback(config, &target, state) { + return Ok::(code); + } + // Wrong state or an error response: keep listening for a valid + // callback (e.g. a retried sign-in) until the timeout. + continue; + } + }) + .await; + + drop(listener); + result.unwrap_or(Err(OAuthError::CallbackTimeout(config.redirect_port))) +} + +const CALLBACK_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// Build the authorization URL for a PKCE pair and state. +pub fn authorize_url(config: &OAuthConfig, pkce: &Pkce, state: &str) -> String { + use std::fmt::Write; + let mut url = String::from(config.authorize_url); + let _ = write!( + url, + "?response_type=code&client_id={client_id}&redirect_uri={redirect_uri}&scope={scope}\ + &code_challenge={challenge}&code_challenge_method=S256&state={state}", + client_id = urlencode(config.client_id), + redirect_uri = urlencode(config.redirect_uri), + scope = urlencode(config.scopes), + challenge = urlencode(&pkce.challenge), + state = urlencode(state), + ); + for (key, value) in config.extra_authorize_params { + let _ = write!(url, "&{}={}", urlencode(key), urlencode(value)); + } + url +} + +fn urlencode(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(*byte as char) + } + other => { + let _ = std::fmt::Write::write_fmt(&mut encoded, format_args!("%{other:02X}")); + } + } + } + encoded +} + +/// A started sign-in flow: the authorization URL to show the user plus the +/// state needed to complete the exchange. The loopback listener is already +/// bound, so the first callback cannot be lost between phases. +pub struct SignInStart { + pub url: String, + pkce: Pkce, + state: String, + listener: TcpListener, +} + +/// Bind the redirect port and build the authorization URL. Does not open a +/// browser yet; call `finish_sign_in` with the returned value. +pub async fn start_sign_in(config: &OAuthConfig) -> Result { + let listener = TcpListener::bind(("127.0.0.1", config.redirect_port)) + .await + .map_err(|error| { + if error.kind() == std::io::ErrorKind::AddrInUse { + OAuthError::PortBusy(config.redirect_port) + } else { + OAuthError::Flow(format!( + "could not listen on port {}: {error}", + config.redirect_port + )) + } + })?; + let pkce = create_pkce(); + let state = create_state(); + let url = authorize_url(config, &pkce, &state); + Ok(SignInStart { + url, + pkce, + state, + listener, + }) +} + +/// Open the browser, wait for the callback, and exchange the code. The +/// caller is responsible for showing the URL; nothing is printed here. +pub async fn finish_sign_in( + config: &OAuthConfig, + start: SignInStart, +) -> Result { + open_browser(&start.url).await; + let code = await_callback_on(start.listener, config, &start.state, CALLBACK_TIMEOUT).await?; + exchange_code(config, &code, &start.pkce.verifier).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> OAuthConfig { + OAuthConfig { + authorize_url: "https://auth.example.com/oauth/authorize", + token_url: "https://auth.example.com/oauth/token", + client_id: "test-client-id", + redirect_uri: "http://localhost:1455/auth/callback", + redirect_path: "/auth/callback", + redirect_port: 1455, + scopes: "openid profile", + extra_authorize_params: &[("idv_captcha", "1")], + } + } + + #[test] + fn pkce_challenge_matches_rfc7636_vector() { + // Verifier/challenge pair from RFC 7636 appendix B. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + pkce_challenge(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn generated_pkce_meets_length_rules() { + let pkce = create_pkce(); + assert!( + (43..=128).contains(&pkce.verifier.chars().count()), + "verifier length out of RFC range: {}", + pkce.verifier.len() + ); + assert_eq!(pkce.challenge, pkce_challenge(&pkce.verifier)); + assert!(!pkce.challenge.contains('=')); + assert!(!pkce.challenge.contains('+')); + assert!(!pkce.challenge.contains('/')); + } + + #[test] + fn state_mismatch_is_rejected() { + let config = test_config(); + assert_eq!( + parse_callback( + &config, + "/auth/callback?code=abc&state=expected", + "expected" + ), + Some("abc".into()) + ); + assert_eq!( + parse_callback(&config, "/auth/callback?code=abc&state=wrong", "expected"), + None + ); + assert_eq!( + parse_callback(&config, "/auth/callback?code=abc", "expected"), + None + ); + assert_eq!( + parse_callback(&config, "/auth/callback?state=expected", "expected"), + None + ); + assert_eq!( + parse_callback(&config, "/other/path?code=abc&state=expected", "expected"), + None + ); + } + + #[test] + fn authorize_url_carries_pkce_and_state() { + let config = test_config(); + let pkce = create_pkce(); + let state = create_state(); + let url = authorize_url(&config, &pkce, &state); + assert!(url.starts_with(config.authorize_url)); + assert!(url.contains("response_type=code")); + assert!(url.contains(&format!("client_id={}", config.client_id))); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains(&format!("state={state}"))); + assert!(url.contains(&format!("code_challenge={}", pkce.challenge))); + assert!(url.contains("scope=openid%20profile")); + // The fixed redirect URI must survive URL encoding intact. + assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback")); + // Provider quirks ride along as extra parameters. + assert!(url.contains("&idv_captcha=1")); + } + + #[tokio::test] + async fn callback_with_keep_alive_connection_is_answered() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let config = test_config(); + let state = create_state(); + let path = format!("/auth/callback?code=the_code&state={state}"); + + let server_config = config; + let server_state = state.clone(); + let server = tokio::spawn(async move { + await_callback_on( + listener, + &server_config, + &server_state, + Duration::from_secs(5), + ) + .await + }); + + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + // One write, then hold the connection open without EOF, like a + // keep-alive browser. + let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n"); + stream.write_all(request.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.unwrap(); + let response = String::from_utf8(response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK")); + assert!(response.contains("close this tab")); + + // The flow completes instead of waiting for a second packet. + let code = tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("server must finish, not hang") + .unwrap() + .expect("valid code"); + assert_eq!(code, "the_code"); + } +} diff --git a/crates/providers/src/openrouter.rs b/crates/providers/src/openrouter.rs index a11372f..4e2da2e 100644 --- a/crates/providers/src/openrouter.rs +++ b/crates/providers/src/openrouter.rs @@ -275,6 +275,8 @@ impl Provider for OpenRouterProvider { ) -> Result<(), crate::auth::AuthError> { match auth_result { crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + // OAuth is handled by the ChatGPT provider's own resolver. + crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index d055102..419bc23 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -19,6 +19,10 @@ pub enum ProviderError { MissingAuth, #[error("failed to fetch models: {0}")] Fetch(String), + #[error("this provider does not support browser sign-in")] + NoBrowserSignIn, + #[error("browser sign-in failed: {0}")] + SignIn(#[source] Box), } #[async_trait] @@ -42,6 +46,28 @@ pub trait Provider: Send + Sync { auth_result: &crate::auth::AuthResult, ) -> Result<(), crate::auth::AuthError>; + /// One-line notice shown before a browser sign-in starts, e.g. an + /// unofficial-client disclaimer. `None` renders nothing. + fn sign_in_notice(&self) -> Option<&'static str> { + None + } + + /// Bind the provider's redirect port and build the authorization URL. + /// The default implementation rejects: only a provider that declares + /// [`crate::AuthMethod::OAuth`] supports the browser flow. + async fn start_sign_in(&self) -> Result { + Err(ProviderError::NoBrowserSignIn) + } + + /// Open the browser, await the callback, exchange the code, and return the + /// credential to store. + async fn finish_sign_in( + &self, + _start: crate::oauth::SignInStart, + ) -> Result { + Err(ProviderError::NoBrowserSignIn) + } + /// Refresh the provider's model catalog from its source, updating the /// catalog returned by [`Provider::models`]. Providers without a live /// catalog keep their static list and the default body is a no-op. diff --git a/crates/providers/src/zai.rs b/crates/providers/src/zai.rs index c843dda..fa3d933 100644 --- a/crates/providers/src/zai.rs +++ b/crates/providers/src/zai.rs @@ -143,6 +143,8 @@ impl Provider for ZaiProvider { ) -> Result<(), crate::auth::AuthError> { match auth_result { crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + // OAuth is handled by the ChatGPT provider's own resolver. + crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } From 542578e58b4d4a0b6e9b7a13dfde0358befa7a0f Mon Sep 17 00:00:00 2001 From: Revantark Date: Sun, 11 Oct 2026 16:26:32 +0530 Subject: [PATCH 2/5] add image support to responses api --- crates/llm/src/apis/responses/codec.rs | 48 ++++++++++++++++++++------ 1 file changed, 37 insertions(+), 11 deletions(-) diff --git a/crates/llm/src/apis/responses/codec.rs b/crates/llm/src/apis/responses/codec.rs index 0f3c867..c91332a 100644 --- a/crates/llm/src/apis/responses/codec.rs +++ b/crates/llm/src/apis/responses/codec.rs @@ -184,10 +184,8 @@ fn user_item(message: &Message) -> Result { for part in parts { match part { ContentPart::Text { text } => content.push(input_text(text)), - ContentPart::Image { .. } => { - return Err(LlmError::InvalidResponse( - "images are not supported by the ChatGPT provider".into(), - )); + ContentPart::Image { image_url } => { + content.push(input_image(&image_url.url)); } } } @@ -207,6 +205,14 @@ fn input_text(text: &str) -> serde_json::Value { serde_json::json!({ "type": "input_text", "text": text }) } +/// Base64 data URIs and direct URLs both serialize as `input_image` items. +fn input_image(url: &str) -> serde_json::Value { + serde_json::json!({ + "type": "input_image", + "image_url": url, + }) +} + /// Assistant turns replay as: reasoning items (verbatim, original position), /// then function calls, then the text message. Reasoning entries that are not /// Responses reasoning items (e.g. summaries captured from other providers) @@ -527,14 +533,34 @@ mod tests { } #[test] - fn images_are_rejected() { - let messages = [Message::user_with_parts(vec![ContentPart::Image { - image_url: crate::ImageUrl { - url: "https://example.invalid/a.png".into(), + fn user_images_serialize_as_input_image_items() { + let data_uri = "data:image/png;base64,iVBORw0KGgo="; + let messages = [Message::user_with_parts(vec![ + ContentPart::Text { + text: "What's in this image?".into(), }, - }])]; - let error = request_with(&messages, &[], ReasoningEffort::None).unwrap_err(); + ContentPart::Image { + image_url: crate::ImageUrl { + url: "https://example.invalid/a.png".into(), + }, + }, + ContentPart::Image { + image_url: crate::ImageUrl { + url: data_uri.into(), + }, + }, + ])]; + let body = body_for(&messages); + let input = body["input"].as_array().unwrap(); - assert!(error.to_string().contains("images are not supported")); + assert_eq!(input.len(), 1); + let content = input[0]["content"].as_array().unwrap(); + assert_eq!(content.len(), 3); + assert_eq!(content[0]["type"], "input_text"); + assert_eq!(content[0]["text"], "What's in this image?"); + assert_eq!(content[1]["type"], "input_image"); + assert_eq!(content[1]["image_url"], "https://example.invalid/a.png"); + assert_eq!(content[2]["type"], "input_image"); + assert_eq!(content[2]["image_url"], data_uri); } } From 3a3ea82ceb8ec50a8c5d279f0a68f8430fafd3e7 Mon Sep 17 00:00:00 2001 From: Revantark Date: Sun, 11 Oct 2026 16:38:14 +0530 Subject: [PATCH 3/5] add tracing dep to llm --- Cargo.lock | 1 + crates/llm/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 799aced..1ebe7b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1394,6 +1394,7 @@ dependencies = [ "serde_json", "thiserror", "tokio", + "tracing", ] [[package]] diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index dc20085..f67f753 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -12,3 +12,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tracing = { workspace= true } From b043f92fffe0dccfc6848a21a16be01970b645cf Mon Sep 17 00:00:00 2001 From: Revantark Date: Sun, 11 Oct 2026 19:22:01 +0530 Subject: [PATCH 4/5] split auth and provider --- crates/alan/src/login_overlay.rs | 81 ++++++++++-------------------- crates/providers/src/auth.rs | 7 ++- crates/providers/src/chatgpt.rs | 75 ++++++++++++--------------- crates/providers/src/deepseek.rs | 44 ++++++++-------- crates/providers/src/google.rs | 24 +++++---- crates/providers/src/lib.rs | 11 ++-- crates/providers/src/local.rs | 23 +++++---- crates/providers/src/oauth.rs | 20 +++++++- crates/providers/src/openrouter.rs | 44 ++++++++-------- crates/providers/src/provider.rs | 75 ++++++++++++++------------- crates/providers/src/zai.rs | 25 +++++---- 11 files changed, 212 insertions(+), 217 deletions(-) diff --git a/crates/alan/src/login_overlay.rs b/crates/alan/src/login_overlay.rs index 8abcc7b..f04f073 100644 --- a/crates/alan/src/login_overlay.rs +++ b/crates/alan/src/login_overlay.rs @@ -4,7 +4,10 @@ use crate::views::theme; use alan_tui::context::Context; use alan_tui::{ActionStatus, Component, RenderContext}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use providers::{AuthMethod, AuthResult, CredentialStore, ProviderId, ProviderRegistry}; +use providers::{ + AuthMethod, AuthResult, Credential, CredentialStore, OAuthConfig, ProviderId, ProviderRegistry, + finish_oauth_sign_in, start_oauth_sign_in, +}; use ratatui::Frame; use ratatui::layout::Alignment; use ratatui::layout::Rect; @@ -27,7 +30,6 @@ enum LoginState { }, Prompting { provider_id: ProviderId, - auth_method: providers::AuthMethod, input: String, }, /// A browser sign-in is running; the URL is shown in case the @@ -35,8 +37,6 @@ enum LoginState { AwaitingBrowser { provider_id: ProviderId, url: String, - /// Provider's own sign-in notice, repeated under the URL when set. - notice: Option<&'static str>, }, Validating { message: String, @@ -90,27 +90,18 @@ impl LoginOverlay { } fn submit_input(&mut self, cx: &mut Context<'_, Self, AlanAction>) { - let LoginState::Prompting { - provider_id, - auth_method, - input, - } = &mut self.state - else { + let LoginState::Prompting { provider_id, input } = &mut self.state else { return; }; - let auth_result = match auth_method { - AuthMethod::ApiKey => providers::AuthResult::ApiKey(std::mem::take(input)), - // Browser sign-in never reaches the key-input path. - AuthMethod::OAuth => return, - }; - let Some(provider) = self.providers.get(provider_id) else { self.state = LoginState::Error("Provider not found".into()); cx.notify(); return; }; + let api_key = std::mem::take(input); + let auth_result = AuthResult::ApiKey(api_key.clone()); let provider = Arc::clone(&provider); let credentials = Arc::clone(&self.credentials); @@ -121,15 +112,9 @@ impl LoginOverlay { .await .map_err(|e| alan_tui::TaskError(e.into()))?; - match auth_result { - AuthResult::ApiKey(key) => { - let credential = providers::Credential::ApiKey { key }; - if let Err(e) = credentials.put(&provider.id(), credential).await { - return Err(alan_tui::TaskError(e.into())); - } - } - // Browser sign-in stores its credentials in its own task. - AuthResult::OAuth => {} + let credential = Credential::ApiKey { key: api_key }; + if let Err(e) = credentials.put(&provider.id(), credential).await { + return Err(alan_tui::TaskError(e.into())); } Ok(()) @@ -179,23 +164,22 @@ impl LoginOverlay { return; }; - let auth_method = provider.auth_methods().into_iter().next(); - - if auth_method.is_none() { + let Some(auth_method) = provider.auth_methods().into_iter().next() else { self.state = LoginState::Error("No authentication methods available".into()); cx.notify(); return; - } + }; - match auth_method.unwrap() { + match auth_method { AuthMethod::ApiKey => { self.state = LoginState::Prompting { provider_id, - auth_method: AuthMethod::ApiKey, input: String::new(), }; } - AuthMethod::OAuth => self.start_browser_sign_in(provider_id, cx), + AuthMethod::OAuth { config } => { + self.start_browser_sign_in(provider_id, config, cx) + } } cx.notify(); } @@ -207,12 +191,10 @@ impl LoginOverlay { } } - /// Run the OAuth browser sign-in through the provider's own flow as a - /// two-stage background task: bind the redirect port and show the URL - /// first, then open the browser and wait for the callback. fn start_browser_sign_in( &mut self, provider_id: ProviderId, + config: OAuthConfig, cx: &mut Context<'_, Self, AlanAction>, ) { let Some(provider) = self.providers.get(&provider_id) else { @@ -220,19 +202,13 @@ impl LoginOverlay { cx.notify(); return; }; - let notice = provider.sign_in_notice(); self.state = LoginState::Validating { - message: match notice { - Some(notice) => format!("Starting sign-in…\n{notice}"), - None => "Starting sign-in…".to_string(), - }, + message: "Starting sign-in…".to_string(), }; - let phase_one_provider = Arc::clone(&provider); let task = cx.spawn( async move { - phase_one_provider - .start_sign_in() + start_oauth_sign_in(&config) .await .map_err(|error| alan_tui::TaskError(error.into())) }, @@ -242,14 +218,16 @@ impl LoginOverlay { overlay.state = LoginState::AwaitingBrowser { provider_id: provider_id.clone(), url: start.url.clone(), - notice, }; let credentials = Arc::clone(&overlay.credentials); + let provider = Arc::clone(&provider); let future = async move { - let credential = provider - .finish_sign_in(start) + let tokens = finish_oauth_sign_in(&config, start) .await .map_err(|error| alan_tui::TaskError(error.into()))?; + let credential = provider + .credential_from_tokens(tokens) + .map_err(|error| alan_tui::TaskError(error.into()))?; credentials .put(&provider_id, credential) .await @@ -481,13 +459,8 @@ fn render_login(frame: &mut Frame, area: Rect, state: &LoginState) { ); frame.set_cursor_position((content.x + 3 + visible as u16, content.y)); } - LoginState::AwaitingBrowser { url, notice, .. } => { - let message = match notice { - Some(notice) => { - format!("Complete the sign-in in your browser, or open:\n{url}\n\n{notice}") - } - None => format!("Complete the sign-in in your browser, or open:\n{url}"), - }; + LoginState::AwaitingBrowser { url, .. } => { + let message = format!("Complete the sign-in in your browser, or open:\n{url}"); render_message(frame, content, &message, muted); } LoginState::Validating { message } => render_message(frame, content, message, muted), @@ -561,7 +534,6 @@ mod tests { let states = [ LoginState::Prompting { provider_id: ProviderId("example".into()), - auth_method: AuthMethod::ApiKey, input: "secret-key".repeat(80), }, LoginState::Selecting { @@ -574,7 +546,6 @@ mod tests { LoginState::AwaitingBrowser { provider_id: ProviderId("chatgpt".into()), url: "https://auth.openai.com/oauth/authorize?x=1".into(), - notice: Some("Unofficial client. Personal use only."), }, LoginState::Success, LoginState::Error("Could not authenticate. Check your API key.".into()), diff --git a/crates/providers/src/auth.rs b/crates/providers/src/auth.rs index 0efc2df..41c089f 100644 --- a/crates/providers/src/auth.rs +++ b/crates/providers/src/auth.rs @@ -6,15 +6,18 @@ use std::env; use std::sync::Arc; use thiserror::Error; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AuthMethod { ApiKey, - OAuth, + OAuth { config: crate::oauth::OAuthConfig }, } +/// A credential the user just supplied through the login flow. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthResult { + /// An API key pasted into the prompt. ApiKey(String), + /// A browser sign-in completed; the tokens were exchanged successfully. OAuth, } diff --git a/crates/providers/src/chatgpt.rs b/crates/providers/src/chatgpt.rs index 3a77716..88223d8 100644 --- a/crates/providers/src/chatgpt.rs +++ b/crates/providers/src/chatgpt.rs @@ -12,10 +12,10 @@ //! Sign-in mechanics live in [`crate::oauth`]; this module supplies the //! ChatGPT configuration and turns issued tokens into stored credentials. -use crate::oauth::{self, OAuthConfig, OAuthError, Tokens}; +use crate::oauth::{self, OAuthConfig, Tokens, unix_now}; use crate::{ - ApiId, AuthError, AuthMethod, AuthResolver, Credential, CredentialStore, ModelInfo, Provider, - ProviderError, ProviderId, Resolved, ServerToolInfo, SignInStart, + ApiId, Auth, AuthError, AuthMethod, AuthResolver, Credential, CredentialStore, ModelInfo, + Provider, ProviderError, ProviderId, Resolved, ServerToolInfo, }; use async_trait::async_trait; use constants::{ @@ -24,10 +24,8 @@ use constants::{ use llm::{HttpClient, LlmApi, ResponsesApi}; use std::collections::{BTreeMap, HashMap}; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; -/// The sign-in half of [`constants`]. pub fn oauth_config() -> OAuthConfig { OAuthConfig { authorize_url: constants::AUTHORIZE_URL, @@ -75,14 +73,12 @@ pub mod constants { pub const PROTOCOL_VERSION: &str = "0.161.0"; /// Beta opt-in header sent with every request. pub const OPENAI_BETA: &str = "responses=experimental"; - /// Shown before the browser sign-in starts. - pub const SIGN_IN_NOTICE: &str = "Unofficial client. Personal use only; access depends on OpenAI's terms and may stop working at any time."; } #[derive(Debug, Error)] pub enum ChatGptError { - #[error(transparent)] - SignIn(#[from] OAuthError), + #[error("sign-in failed: {0}")] + SignIn(String), #[error("no ChatGPT account id found in the issued tokens")] MissingAccount, } @@ -108,7 +104,7 @@ pub fn account_id_from_token(token: &str) -> Option { /// Stored ChatGPT credentials with the account id resolved from the token. #[derive(Debug, Clone)] -pub struct OAuthSecrets { +struct OAuthSecrets { pub access_token: String, pub refresh_token: String, pub id_token: String, @@ -121,11 +117,12 @@ impl OAuthSecrets { let account_id = account_id_from_token(&tokens.id_token) .or_else(|| account_id_from_token(&tokens.access_token)) .ok_or(ChatGptError::MissingAccount)?; + let expires_at = tokens.expires_at(); Ok(Self { access_token: tokens.access_token, refresh_token: tokens.refresh_token, id_token: tokens.id_token, - expires_at: unix_now() + tokens.expires_in.saturating_sub(EXPIRY_MARGIN_SECS), + expires_at, account_id, }) } @@ -140,15 +137,6 @@ impl OAuthSecrets { } } -const EXPIRY_MARGIN_SECS: u64 = 120; - -fn unix_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(0) -} - /// Auth resolver for the ChatGPT provider: reads stored OAuth credentials and /// refreshes them when expired, once even when callers race. Resolving returns /// the access token plus the `ChatGPT-Account-Id` header as [`Resolved::headers`]. @@ -374,13 +362,18 @@ impl Provider for ChatGptProvider { &[] } - fn auth_methods(&self) -> Vec { - vec![AuthMethod::OAuth] - } - fn apis(&self) -> &HashMap> { &self.apis } +} + +#[async_trait] +impl Auth for ChatGptProvider { + fn auth_methods(&self) -> Vec { + vec![AuthMethod::OAuth { + config: oauth_config(), + }] + } fn auth(&self) -> Arc { self.auth.clone() @@ -388,30 +381,24 @@ impl Provider for ChatGptProvider { async fn validate_auth( &self, - _auth_result: &crate::AuthResult, + auth_result: &crate::auth::AuthResult, ) -> Result<(), crate::auth::AuthError> { - // The sign-in flow already proved the credentials, and every request - // exercises the stored token; a live probe would need another - // unpublished endpoint. - Ok(()) - } - - fn sign_in_notice(&self) -> Option<&'static str> { - Some(constants::SIGN_IN_NOTICE) - } - - async fn start_sign_in(&self) -> Result { - oauth::start_sign_in(&oauth_config()) - .await - .map_err(|error| ProviderError::SignIn(Box::new(error))) + match auth_result { + // The sign-in flow already proved the credentials, and every + // request exercises the stored token; a live probe would need + // another unpublished endpoint. + crate::auth::AuthResult::OAuth => Ok(()), + // ChatGPT authenticates with a browser sign-in only. + crate::auth::AuthResult::ApiKey(_) => Err(crate::auth::AuthError::Missing), + } } - async fn finish_sign_in(&self, start: SignInStart) -> Result { - let tokens = oauth::finish_sign_in(&oauth_config(), start) - .await - .map_err(|error| ProviderError::SignIn(Box::new(error)))?; + fn credential_from_tokens( + &self, + tokens: crate::oauth::Tokens, + ) -> Result { let secrets = OAuthSecrets::from_tokens(tokens) - .map_err(|error| ProviderError::SignIn(error.into()))?; + .map_err(|error| ProviderError::SignIn(error.to_string()))?; let metadata = secrets.to_metadata(); Ok(Credential::OAuth { access_token: secrets.access_token, diff --git a/crates/providers/src/deepseek.rs b/crates/providers/src/deepseek.rs index 5617b40..c384373 100644 --- a/crates/providers/src/deepseek.rs +++ b/crates/providers/src/deepseek.rs @@ -1,6 +1,6 @@ use crate::{ - ApiId, ApiKeyAuth, AuthError, AuthResolver, CredentialAuth, ModelInfo, Provider, ProviderError, - ProviderId, ServerToolInfo, + ApiId, ApiKeyAuth, Auth, AuthError, AuthResolver, CredentialAuth, ModelInfo, Provider, + ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; use llm::apis::chat_completions::{BaseRequest, BaseResponse, decode_stream_response}; @@ -176,29 +176,10 @@ impl Provider for DeepSeekProvider { &[] } - fn auth_methods(&self) -> Vec { - vec![crate::auth::AuthMethod::ApiKey] - } - fn apis(&self) -> &HashMap> { &self.apis } - fn auth(&self) -> Arc { - self.auth.clone() - } - - async fn validate_auth( - &self, - auth_result: &crate::auth::AuthResult, - ) -> Result<(), crate::auth::AuthError> { - match auth_result { - crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, - // OAuth is handled by the ChatGPT provider's own resolver. - crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), - } - } - async fn fetch_models(&self) -> Result<(), ProviderError> { let _guard = self.fetch_lock.lock().await; let credential = self @@ -231,6 +212,27 @@ impl Provider for DeepSeekProvider { } } +#[async_trait] +impl Auth for DeepSeekProvider { + fn auth_methods(&self) -> Vec { + vec![crate::auth::AuthMethod::ApiKey] + } + + fn auth(&self) -> Arc { + self.auth.clone() + } + + async fn validate_auth( + &self, + auth_result: &crate::auth::AuthResult, + ) -> Result<(), crate::auth::AuthError> { + match auth_result { + crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), + } + } +} + pub struct DeepSeekBuilder { api_key: String, models: Vec, diff --git a/crates/providers/src/google.rs b/crates/providers/src/google.rs index a84d15a..32a9c0e 100644 --- a/crates/providers/src/google.rs +++ b/crates/providers/src/google.rs @@ -1,6 +1,6 @@ use crate::{ - ApiId, ApiKeyAuth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, Provider, - ProviderError, ProviderId, ServerToolInfo, + ApiId, ApiKeyAuth, Auth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, + Provider, ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; use llm::{HttpClient, InteractionsApi, LlmApi}; @@ -98,14 +98,21 @@ impl Provider for GoogleProvider { &self.server_tools } - fn auth_methods(&self) -> Vec { - vec![crate::auth::AuthMethod::ApiKey] - } - fn apis(&self) -> &HashMap> { &self.apis } + async fn fetch_models(&self) -> Result<(), ProviderError> { + Ok(()) + } +} + +#[async_trait] +impl Auth for GoogleProvider { + fn auth_methods(&self) -> Vec { + vec![crate::auth::AuthMethod::ApiKey] + } + fn auth(&self) -> Arc { self.auth.clone() } @@ -120,11 +127,6 @@ impl Provider for GoogleProvider { crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } - - async fn fetch_models(&self) -> Result<(), ProviderError> { - // Models are pre-configured in from_store, no need to fetch - Ok(()) - } } pub struct GoogleBuilder { diff --git a/crates/providers/src/lib.rs b/crates/providers/src/lib.rs index 981af61..7aeaea5 100644 --- a/crates/providers/src/lib.rs +++ b/crates/providers/src/lib.rs @@ -19,8 +19,8 @@ pub use auth::{ }; pub use catalog::{ApiId, ModelCapabilities, ModelInfo, ModelPricing, ProviderId, ServerToolInfo}; pub use chatgpt::{ - ChatGptAuth, ChatGptError, ChatGptProvider, ChatGptProviderBuilder, OAuthSecrets, - account_id_from_token, constants, + ChatGptAuth, ChatGptError, ChatGptProvider, ChatGptProviderBuilder, account_id_from_token, + constants, }; pub use credentials::{ Credential, CredentialError, CredentialInfo, CredentialKind, CredentialStore, @@ -30,10 +30,11 @@ pub use google::{GoogleBuilder, GoogleProvider}; pub use local::{LocalApi, LocalModelEntry, LocalProvider, bind_local_model, list_local_models}; pub use model::{Model, ModelError, ModelOptions}; pub use oauth::{ - OAuthConfig, OAuthError, SignInStart, Tokens as OAuthTokens, exchange_code, parse_callback, - refresh_tokens, start_sign_in as start_oauth_sign_in, + OAuthConfig, OAuthError, SignInStart, Tokens as OAuthTokens, exchange_code, + finish_sign_in as finish_oauth_sign_in, parse_callback, refresh_tokens, + start_sign_in as start_oauth_sign_in, }; pub use openrouter::{OpenRouterBuilder, OpenRouterProvider, Options as OpenRouterOptions}; -pub use provider::{Provider, ProviderError, ProviderRegistry, bind_model}; +pub use provider::{Auth, Provider, ProviderError, ProviderRegistry, bind_model}; pub use store::LocalModelStore; pub use zai::{ZaiBuilder, ZaiProvider}; diff --git a/crates/providers/src/local.rs b/crates/providers/src/local.rs index 989992a..2653db7 100644 --- a/crates/providers/src/local.rs +++ b/crates/providers/src/local.rs @@ -1,7 +1,7 @@ use crate::store::LocalModelStore; use crate::{ - ApiId, ApiKeyAuth, AuthResolver, ModelInfo, ModelOptions, NoAuth, Provider, ProviderError, - ProviderId, ServerToolInfo, + ApiId, ApiKeyAuth, Auth, AuthResolver, ModelInfo, ModelOptions, NoAuth, Provider, + ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; use llm::{ChatCompletionsApi, HttpClient, LlmApi}; @@ -142,10 +142,6 @@ impl Provider for LocalProvider { &[] } - fn auth_methods(&self) -> Vec { - vec![] - } - fn apis(&self) -> &HashMap> { // Local models bind per-entry via `bind_local_model`, which reads the // URL from the chosen `LocalModelEntry`. This provider-level entry is a @@ -160,6 +156,17 @@ impl Provider for LocalProvider { &APIS } + async fn fetch_models(&self) -> Result<(), ProviderError> { + self.load().await + } +} + +#[async_trait] +impl Auth for LocalProvider { + fn auth_methods(&self) -> Vec { + vec![] + } + fn auth(&self) -> Arc { Arc::new(NoAuth) } @@ -170,10 +177,6 @@ impl Provider for LocalProvider { ) -> Result<(), crate::auth::AuthError> { Ok(()) } - - async fn fetch_models(&self) -> Result<(), ProviderError> { - self.load().await - } } /// Bind a [`crate::Model`] to a local model entry, using the entry's URL and diff --git a/crates/providers/src/oauth.rs b/crates/providers/src/oauth.rs index c165e64..7a0c80d 100644 --- a/crates/providers/src/oauth.rs +++ b/crates/providers/src/oauth.rs @@ -8,14 +8,14 @@ use base64::Engine; use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use thiserror::Error; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; /// Values tied to a provider's registered client (client id, redirect port, /// scopes) or to a published endpoint, so each is `'static`. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct OAuthConfig { pub authorize_url: &'static str, pub token_url: &'static str, @@ -90,6 +90,22 @@ pub struct Tokens { pub expires_in: u64, } +impl Tokens { + pub fn expires_at(&self) -> u64 { + unix_now() + self.expires_in.saturating_sub(EXPIRY_MARGIN_SECS) + } +} + +const EXPIRY_MARGIN_SECS: u64 = 120; + +/// Unix now in seconds. +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} + pub async fn exchange_code( config: &OAuthConfig, code: &str, diff --git a/crates/providers/src/openrouter.rs b/crates/providers/src/openrouter.rs index 4e2da2e..fbcd78f 100644 --- a/crates/providers/src/openrouter.rs +++ b/crates/providers/src/openrouter.rs @@ -1,6 +1,6 @@ use crate::{ - ApiId, ApiKeyAuth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, Provider, - ProviderError, ProviderId, ServerToolInfo, + ApiId, ApiKeyAuth, Auth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, + Provider, ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; use llm::apis::chat_completions::{BaseRequest, BaseResponse, WireUsage, decode_stream_response}; @@ -257,29 +257,10 @@ impl Provider for OpenRouterProvider { &self.server_tools } - fn auth_methods(&self) -> Vec { - vec![crate::auth::AuthMethod::ApiKey] - } - fn apis(&self) -> &HashMap> { &self.apis } - fn auth(&self) -> Arc { - self.auth.clone() - } - - async fn validate_auth( - &self, - auth_result: &crate::auth::AuthResult, - ) -> Result<(), crate::auth::AuthError> { - match auth_result { - crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, - // OAuth is handled by the ChatGPT provider's own resolver. - crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), - } - } - async fn fetch_models(&self) -> Result<(), ProviderError> { let _guard = self.fetch_lock.lock().await; let response = reqwest::Client::new() @@ -303,6 +284,27 @@ impl Provider for OpenRouterProvider { } } +#[async_trait] +impl Auth for OpenRouterProvider { + fn auth_methods(&self) -> Vec { + vec![crate::auth::AuthMethod::ApiKey] + } + + fn auth(&self) -> Arc { + self.auth.clone() + } + + async fn validate_auth( + &self, + auth_result: &crate::auth::AuthResult, + ) -> Result<(), crate::auth::AuthError> { + match auth_result { + crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, + _ => Err(crate::auth::AuthError::Missing), + } + } +} + pub struct OpenRouterBuilder { api_key: String, models: Vec, diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 419bc23..0ff505b 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -19,54 +19,59 @@ pub enum ProviderError { MissingAuth, #[error("failed to fetch models: {0}")] Fetch(String), - #[error("this provider does not support browser sign-in")] - NoBrowserSignIn, - #[error("browser sign-in failed: {0}")] - SignIn(#[source] Box), + #[error("issued OAuth tokens did not contain a usable credential: {0}")] + SignIn(String), } #[async_trait] -pub trait Provider: Send + Sync { - fn id(&self) -> ProviderId; - - fn models(&self) -> Vec; - - fn server_tools(&self) -> &[ServerToolInfo]; - - fn auth_methods(&self) -> Vec; - - /// The LLM API implementations this provider can route to, keyed by API id. - fn apis(&self) -> &HashMap>; - +pub trait Auth: Send + Sync { /// The auth resolver used to obtain credentials for requests. fn auth(&self) -> Arc; + /// The authentication methods this provider supports. + fn auth_methods(&self) -> Vec; + + /// Validate a credential the user just supplied: an API key, or the + /// result of a browser sign-in. async fn validate_auth( &self, - auth_result: &crate::auth::AuthResult, + result: &crate::auth::AuthResult, ) -> Result<(), crate::auth::AuthError>; - /// One-line notice shown before a browser sign-in starts, e.g. an - /// unofficial-client disclaimer. `None` renders nothing. - fn sign_in_notice(&self) -> Option<&'static str> { - None - } - - /// Bind the provider's redirect port and build the authorization URL. - /// The default implementation rejects: only a provider that declares - /// [`crate::AuthMethod::OAuth`] supports the browser flow. - async fn start_sign_in(&self) -> Result { - Err(ProviderError::NoBrowserSignIn) - } - - /// Open the browser, await the callback, exchange the code, and return the - /// credential to store. - async fn finish_sign_in( + /// Turn issued OAuth tokens into the credential to store. The default + /// stores the raw tokens; a provider with provider-specific claims + /// (e.g. an account id claim) overrides it to resolve them. + fn credential_from_tokens( &self, - _start: crate::oauth::SignInStart, + tokens: crate::oauth::Tokens, ) -> Result { - Err(ProviderError::NoBrowserSignIn) + let expires_at = tokens.expires_at(); + Ok(crate::Credential::OAuth { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + expires_at, + metadata: Default::default(), + }) } +} + +/// A model provider: identity, model catalog, and the API routing used to +/// bind a catalog entry to a live [`crate::Model`]. +/// +/// Every provider is also an [`Auth`] — binding a model requires its +/// credential resolver — but the two responsibilities are kept in separate +/// traits. +#[async_trait] +pub trait Provider: Auth + Send + Sync { + fn id(&self) -> ProviderId; + + fn models(&self) -> Vec; + + fn server_tools(&self) -> &[ServerToolInfo]; + + /// The LLM API implementations this provider can route to, keyed by API id. + fn apis(&self) -> &HashMap>; /// Refresh the provider's model catalog from its source, updating the /// catalog returned by [`Provider::models`]. Providers without a live diff --git a/crates/providers/src/zai.rs b/crates/providers/src/zai.rs index fa3d933..528a28b 100644 --- a/crates/providers/src/zai.rs +++ b/crates/providers/src/zai.rs @@ -1,6 +1,6 @@ use crate::{ - ApiId, ApiKeyAuth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, Provider, - ProviderError, ProviderId, ServerToolInfo, + ApiId, ApiKeyAuth, Auth, AuthError, AuthResolver, CredentialAuth, ModelInfo, ModelPricing, + Provider, ProviderError, ProviderId, ServerToolInfo, }; use async_trait::async_trait; use llm::{ChatCompletionsApi, HttpClient, LlmApi}; @@ -125,14 +125,22 @@ impl Provider for ZaiProvider { &self.server_tools } - fn auth_methods(&self) -> Vec { - vec![crate::auth::AuthMethod::ApiKey] - } - fn apis(&self) -> &HashMap> { &self.apis } + async fn fetch_models(&self) -> Result<(), ProviderError> { + // Models are pre-configured in from_store, no need to fetch + Ok(()) + } +} + +#[async_trait] +impl Auth for ZaiProvider { + fn auth_methods(&self) -> Vec { + vec![crate::auth::AuthMethod::ApiKey] + } + fn auth(&self) -> Arc { self.auth.clone() } @@ -147,11 +155,6 @@ impl Provider for ZaiProvider { crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } } - - async fn fetch_models(&self) -> Result<(), ProviderError> { - // Models are pre-configured in from_store, no need to fetch - Ok(()) - } } pub struct ZaiBuilder { From 581f57127d40b68d473a70b5eafd7c5328e4b1ca Mon Sep 17 00:00:00 2001 From: Revantark Date: Sun, 11 Oct 2026 19:35:34 +0530 Subject: [PATCH 5/5] remove unnecessary comments --- crates/alan/src/login_overlay.rs | 4 +--- crates/llm/src/apis/chat_completions/mod.rs | 3 +-- crates/providers/src/auth.rs | 7 ------- crates/providers/src/credentials.rs | 10 ---------- crates/providers/src/provider.rs | 2 -- crates/providers/src/zai.rs | 1 - 6 files changed, 2 insertions(+), 25 deletions(-) diff --git a/crates/alan/src/login_overlay.rs b/crates/alan/src/login_overlay.rs index f04f073..fd3c7b7 100644 --- a/crates/alan/src/login_overlay.rs +++ b/crates/alan/src/login_overlay.rs @@ -32,8 +32,6 @@ enum LoginState { provider_id: ProviderId, input: String, }, - /// A browser sign-in is running; the URL is shown in case the - /// browser did not open. AwaitingBrowser { provider_id: ProviderId, url: String, @@ -49,7 +47,6 @@ pub struct LoginOverlay { providers: Arc, credentials: Arc, state: LoginState, - /// The running browser sign-in, cancellable with Esc. browser_task: Option, } @@ -219,6 +216,7 @@ impl LoginOverlay { provider_id: provider_id.clone(), url: start.url.clone(), }; + let credentials = Arc::clone(&overlay.credentials); let provider = Arc::clone(&provider); let future = async move { diff --git a/crates/llm/src/apis/chat_completions/mod.rs b/crates/llm/src/apis/chat_completions/mod.rs index 9989bda..85ed91e 100644 --- a/crates/llm/src/apis/chat_completions/mod.rs +++ b/crates/llm/src/apis/chat_completions/mod.rs @@ -103,8 +103,7 @@ impl StreamHandler for ChatHandler { { self.stop_reason = Some(stop_reason); } - // Emit usage *after* the chunk's text/reasoning/tool-call deltas - // so consumers see content before the summary. + let mut events = chunk.events; if let Some(usage) = chunk.usage { events.push(LlmEvent::Usage { usage }); diff --git a/crates/providers/src/auth.rs b/crates/providers/src/auth.rs index 41c089f..78eb925 100644 --- a/crates/providers/src/auth.rs +++ b/crates/providers/src/auth.rs @@ -31,10 +31,6 @@ pub enum AuthError { Storage(#[from] CredentialError), } -/// Credentials resolved for one request: the request credential plus any -/// provider-specific headers to send (for example a ChatGPT account id learned -/// at sign-in). Resolving is a pure read — callers attach the headers to the -/// request; nothing is mutated in shared state. #[derive(Debug, Clone)] pub struct Resolved { pub credential: RequestCredential, @@ -112,9 +108,6 @@ impl AuthResolver for CredentialAuth { headers: Vec::new(), }), Credential::ApiKey { .. } => Err(AuthError::Missing), - // OAuth credentials are resolved by their provider's own - // resolver (`ChatGptAuth` for ChatGPT), never by this - // API-key resolver. Credential::OAuth { .. } => Err(AuthError::Missing), }; } diff --git a/crates/providers/src/credentials.rs b/crates/providers/src/credentials.rs index 209d2c4..0e6e2b7 100644 --- a/crates/providers/src/credentials.rs +++ b/crates/providers/src/credentials.rs @@ -16,9 +16,6 @@ pub enum Credential { ApiKey { key: String, }, - /// Generic OAuth credentials. Provider-specific claims (for example a - /// ChatGPT account id) live in `metadata`; secret material there is - /// shielded by the manual `Debug` impl below. OAuth { access_token: String, refresh_token: String, @@ -170,9 +167,6 @@ impl FileCredentialStore { fn read_all(&self) -> Result, CredentialError> { match fs::read_to_string(&self.path) { Ok(contents) => { - // Migrate before typing: pre-rename files carry the old - // `chat_gpt_o_auth` tag and a flat `account_id` field, which - // would otherwise fail to parse or be silently dropped. let mut entries: BTreeMap = serde_json::from_str(&contents).map_err(CredentialError::Serialization)?; for entry in entries.values_mut() { @@ -301,10 +295,6 @@ fn set_unix_mode(path: &Path, mode: u32) -> std::io::Result<()> { fs::set_permissions(path, fs::Permissions::from_mode(mode)) } -/// Migrate one legacy credential entry in place: files written before the -/// OAuth rename serialize `Credential::ChatGptOAuth` as -/// `{"type": "chat_gpt_o_auth", ..., "account_id": "..."}`; the current -/// variant is `"oauth"` with provider claims in a nested `metadata` map. fn migrate_legacy_oauth(entry: &mut serde_json::Value) { let Some(object) = entry.as_object_mut() else { return; diff --git a/crates/providers/src/provider.rs b/crates/providers/src/provider.rs index 0ff505b..60d6e04 100644 --- a/crates/providers/src/provider.rs +++ b/crates/providers/src/provider.rs @@ -31,8 +31,6 @@ pub trait Auth: Send + Sync { /// The authentication methods this provider supports. fn auth_methods(&self) -> Vec; - /// Validate a credential the user just supplied: an API key, or the - /// result of a browser sign-in. async fn validate_auth( &self, result: &crate::auth::AuthResult, diff --git a/crates/providers/src/zai.rs b/crates/providers/src/zai.rs index 528a28b..592f67c 100644 --- a/crates/providers/src/zai.rs +++ b/crates/providers/src/zai.rs @@ -151,7 +151,6 @@ impl Auth for ZaiProvider { ) -> Result<(), crate::auth::AuthError> { match auth_result { crate::auth::AuthResult::ApiKey(key) => validate_api_key(key).await, - // OAuth is handled by the ChatGPT provider's own resolver. crate::auth::AuthResult::OAuth => Err(crate::auth::AuthError::Missing), } }