From 04dd877fe1e97ffe0c744065e3ceff2cf7c45d34 Mon Sep 17 00:00:00 2001 From: staklab <177474538+staklab@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:39:40 -0700 Subject: [PATCH 1/5] chore: resolve existing collapsible-match lints --- src/providers/kimi/translate/request.rs | 12 ++++-------- src/tui.rs | 9 +++++---- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/providers/kimi/translate/request.rs b/src/providers/kimi/translate/request.rs index 3beefcff..cd4d2d19 100644 --- a/src/providers/kimi/translate/request.rs +++ b/src/providers/kimi/translate/request.rs @@ -565,15 +565,11 @@ fn push_assistant_message(out: &mut Vec, blocks: &[ContentBlock]) { for block in blocks { match block { - ContentBlock::Text { text } => { - if !text.is_empty() { - text_parts.push(text.clone()); - } + ContentBlock::Text { text } if !text.is_empty() => { + text_parts.push(text.clone()); } - ContentBlock::Thinking { thinking, .. } => { - if !thinking.is_empty() { - thinking_parts.push(thinking.clone()); - } + ContentBlock::Thinking { thinking, .. } if !thinking.is_empty() => { + thinking_parts.push(thinking.clone()); } ContentBlock::ToolUse { id, name, input } => { let args = serde_json::to_string(input).unwrap_or_else(|_| "{}".to_string()); diff --git a/src/tui.rs b/src/tui.rs index 994b8144..bb61c1f1 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -205,11 +205,12 @@ fn run_monitor_events( { return Ok(MonitorExit::Detached); } - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - if app.handle_ctrl_c() { - return Ok(MonitorExit::ForceQuit); - } + KeyCode::Char('c') + if key.modifiers.contains(KeyModifiers::CONTROL) && app.handle_ctrl_c() => + { + return Ok(MonitorExit::ForceQuit); } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {} _ if app.phase == MonitorPhase::ShuttingDown => {} KeyCode::Char('y') if app.phase == MonitorPhase::ConfirmingShutdown => { app.begin_shutdown() From 7b9a4eb5072d49a0591c129b15eb5c6a21157965 Mon Sep 17 00:00:00 2001 From: staklab <177474538+staklab@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:40:01 -0700 Subject: [PATCH 2/5] feat: add isolated official Sign in with ChatGPT provider Implement browser PKCE authorization, separate rotating credentials, account model discovery, and public Responses HTTP/SSE translation. Keep existing provider defaults and credentials independent. --- Cargo.lock | 113 +- Cargo.toml | 1 + src/config.rs | 16 + src/main.rs | 8 +- src/provider.rs | 5 + src/providers/chatgpt_siwc/auth.rs | 754 +++++++++++ src/providers/chatgpt_siwc/cli.rs | 98 ++ src/providers/chatgpt_siwc/client.rs | 241 ++++ src/providers/chatgpt_siwc/mod.rs | 135 ++ src/providers/chatgpt_siwc/store.rs | 237 ++++ src/providers/chatgpt_siwc/stream.rs | 493 +++++++ src/providers/chatgpt_siwc/tests.rs | 1232 ++++++++++++++++++ src/providers/chatgpt_siwc/translate.rs | 279 ++++ src/providers/codex/translate/live_stream.rs | 13 +- src/providers/mod.rs | 1 + src/registry.rs | 19 + src/server.rs | 96 +- src/session.rs | 3 +- src/traffic.rs | 3 + tests/cli.rs | 47 + tests/fixtures/siwc/README.md | 4 + tests/fixtures/siwc/jwks.json | 1 + tests/fixtures/siwc/test-rsa.der | Bin 0 -> 1193 bytes 23 files changed, 3784 insertions(+), 15 deletions(-) create mode 100644 src/providers/chatgpt_siwc/auth.rs create mode 100644 src/providers/chatgpt_siwc/cli.rs create mode 100644 src/providers/chatgpt_siwc/client.rs create mode 100644 src/providers/chatgpt_siwc/mod.rs create mode 100644 src/providers/chatgpt_siwc/store.rs create mode 100644 src/providers/chatgpt_siwc/stream.rs create mode 100644 src/providers/chatgpt_siwc/tests.rs create mode 100644 src/providers/chatgpt_siwc/translate.rs create mode 100644 tests/fixtures/siwc/README.md create mode 100644 tests/fixtures/siwc/jwks.json create mode 100644 tests/fixtures/siwc/test-rsa.der diff --git a/Cargo.lock b/Cargo.lock index 21c20018..d910f879 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,6 +117,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -275,6 +299,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -351,6 +377,7 @@ dependencies = [ "http-body-util", "hyper-util", "jiff", + "jsonwebtoken", "once_cell", "predicates", "prost", @@ -378,6 +405,15 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -571,6 +607,12 @@ dependencies = [ "syn", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.16.0" @@ -665,6 +707,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -1143,6 +1191,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -1154,6 +1212,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1379,6 +1453,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1735,7 +1815,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -1815,7 +1895,7 @@ checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -2002,6 +2082,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2511,6 +2600,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" @@ -2966,6 +3061,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 27ee8e75..3bd38bc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ ratatui = "0.29" crossterm = "0.28" jiff = { version = "0.2", default-features = false, features = ["std", "tz-system", "tzdb-zoneinfo"] } tiktoken-rs = { version = "0.12", default-features = false } +jsonwebtoken = { version = "11.1", default-features = false, features = ["aws_lc_rs"] } [dev-dependencies] tokio = { version = "1", features = ["test-util"] } diff --git a/src/config.rs b/src/config.rs index f93cd237..68bd3984 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use crate::paths; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AliasProvider { + Siwc, Codex, Kimi, } @@ -14,6 +15,7 @@ pub enum AliasProvider { impl AliasProvider { pub fn as_str(&self) -> &str { match self { + AliasProvider::Siwc => "siwc", AliasProvider::Codex => "codex", AliasProvider::Kimi => "kimi", } @@ -45,6 +47,19 @@ struct FileConfig { pub cursor: Option, pub grok: Option, pub opencode: Option, + pub siwc: Option, +} + +#[derive(Deserialize)] +struct SiwcConfig { + model: Option, +} + +pub fn siwc_model() -> Option { + std::env::var("CCP_SIWC_MODEL") + .ok() + .filter(|m| !m.trim().is_empty()) + .or_else(|| read_file_config(&paths::config_dir())?.siwc?.model) } #[derive(Deserialize, Clone)] @@ -124,6 +139,7 @@ struct FileLog { fn parse_alias(raw: &str) -> Option { match raw { + "siwc" => Some(AliasProvider::Siwc), "codex" => Some(AliasProvider::Codex), "kimi" => Some(AliasProvider::Kimi), _ => None, diff --git a/src/main.rs b/src/main.rs index 1899dbba..bbab01b7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,11 @@ struct Cli { #[derive(Debug, Subcommand)] enum Commands { + /// Official Sign in with ChatGPT authentication, models, and isolated proxy + Siwc { + #[command(subcommand)] + command: claude_code_proxy::providers::chatgpt_siwc::cli::Command, + }, /// Print version information Version, /// Start the proxy server and monitor @@ -95,6 +100,7 @@ fn main() -> Result<()> { }); match commands { + Commands::Siwc { command } => claude_code_proxy::providers::chatgpt_siwc::cli::run(command), Commands::Version => { println!("claude-code-proxy {}", VERSION); Ok(()) @@ -328,7 +334,7 @@ fn run_provider_cli(name: &str, command: ProviderGroup) -> Result<()> { fn print_models(registry: &Registry, full: bool) { let grouped = registry.grouped_models(); - for provider in ["codex", "kimi", "grok", "opencode", "cursor"] { + for provider in ["siwc", "codex", "kimi", "grok", "opencode", "cursor"] { let Some(models) = grouped.get(provider) else { continue; }; diff --git a/src/provider.rs b/src/provider.rs index 2894f449..bf776b36 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -42,6 +42,11 @@ pub trait Provider: Send + Sync { async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response; + /// Account-specific catalog, when the provider supports discovery. + async fn model_catalog(&self, _limit: Option) -> Option { + None + } + async fn generate_anthropic_stream( &self, _body: MessagesRequest, diff --git a/src/providers/chatgpt_siwc/auth.rs b/src/providers/chatgpt_siwc/auth.rs new file mode 100644 index 00000000..d214fa5b --- /dev/null +++ b/src/providers/chatgpt_siwc/auth.rs @@ -0,0 +1,754 @@ +use anyhow::{Context, Result, bail, ensure}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet}; +use rand::RngCore; +use serde::Deserialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::{Mutex, oneshot}; + +use super::store::{Profile, Store, Tokens, validate_label}; + +pub(super) const ISSUER: &str = "https://auth.openai.com"; +pub(super) const RESOURCE: &str = "https://api.openai.com/v1"; +const DYNAMIC_CLIENT: &str = "dynamic_agent_client"; +const SCOPES: &str = + "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"; + +#[derive(Clone)] +pub(super) struct Endpoints { + pub authorize: String, + pub token: String, + pub discovery: String, + pub jwks: String, + pub revoke: String, + pub api: String, +} + +impl Default for Endpoints { + fn default() -> Self { + Self { + authorize: format!("{ISSUER}/api/accounts/authorize"), + token: format!("{ISSUER}/api/accounts/oauth/token"), + discovery: format!("{ISSUER}/.well-known/openid-configuration"), + jwks: format!("{ISSUER}/.well-known/jwks.json"), + revoke: format!("{ISSUER}/api/accounts/oauth/revoke"), + api: RESOURCE.into(), + } + } +} + +#[derive(Clone)] +pub(super) struct Auth { + pub store: Store, + pub profile: String, + pub endpoints: Endpoints, + http: reqwest::Client, +} + +pub(super) fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub(super) fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(15)) + .user_agent(concat!( + "claude-code-proxy/", + env!("CARGO_PKG_VERSION"), + " SIWC" + )) + .build() + .expect("SIWC HTTP client") +} + +pub(super) async fn read_json(mut response: reqwest::Response) -> Result { + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| anyhow::anyhow!("HTTP response interrupted"))? + { + ensure!( + bytes.len() + chunk.len() <= 2 * 1024 * 1024, + "HTTP JSON response exceeds limit" + ); + bytes.extend(chunk); + } + serde_json::from_slice(&bytes).map_err(|_| anyhow::anyhow!("Invalid HTTP JSON response")) +} + +pub(super) struct Attempt { + pub state: String, + pub nonce: String, + pub verifier: String, + pub redirect_uri: String, + pub host_id: String, + pub selected: Option, + pub issued_client: Option, +} + +fn random_secret() -> String { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +impl Attempt { + pub fn new(redirect_uri: String, host_id: String, selected: Option) -> Self { + Self { + state: random_secret(), + nonce: random_secret(), + verifier: random_secret(), + redirect_uri, + host_id, + selected, + issued_client: None, + } + } + + pub fn authorize_url( + &self, + endpoints: &Endpoints, + consent: bool, + hints: bool, + ) -> Result { + let mut url = url::Url::parse(&endpoints.authorize)?; + let client_id = self + .selected + .as_ref() + .map(|p| p.client_id.as_str()) + .or(self.issued_client.as_deref()) + .unwrap_or(DYNAMIC_CLIENT); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(self.verifier.as_bytes())); + let mut q = url.query_pairs_mut(); + q.append_pair("client_id", client_id) + .append_pair("ext_agent_host_id", &self.host_id) + .append_pair("response_type", "code") + .append_pair("redirect_uri", &self.redirect_uri) + .append_pair("scope", SCOPES) + .append_pair("resource", RESOURCE) + .append_pair("state", &self.state) + .append_pair("nonce", &self.nonce) + .append_pair("code_challenge_method", "S256") + .append_pair("code_challenge", &challenge); + if client_id == DYNAMIC_CLIENT { + q.append_pair("agent_name_hint", "claude-code-proxy"); + } + if consent { + q.append_pair("prompt", "consent"); + } + if hints && let Some(profile) = &self.selected { + if let Some(tokens) = &profile.tokens { + q.append_pair("id_token_hint", &tokens.id_token); + } + if let Some(email) = &profile.email { + q.append_pair("login_hint", email); + } + } + drop(q); + Ok(url) + } + + pub fn callback(&mut self, query: &HashMap) -> Result { + ensure!( + query.get("state") == Some(&self.state), + "OAuth state mismatch" + ); + if query.contains_key("error") { + bail!("OAuth authorization declined or failed; no tokens exchanged"); + } + let expected = self + .selected + .as_ref() + .map(|p| p.client_id.as_str()) + .or(self.issued_client.as_deref()); + let client = query + .get("client_id") + .map(String::as_str) + .or(expected) + .context("Registration incomplete: no issued client_id")?; + ensure!( + !client.is_empty() + && client != DYNAMIC_CLIENT + && client.len() <= 512 + && client + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'), + "Invalid issued client_id" + ); + ensure!( + expected.is_none_or(|id| id == client), + "OAuth client_id changed for selected registration" + ); + let code = query + .get("code") + .filter(|s| !s.is_empty()) + .context("OAuth callback has no authorization code")? + .clone(); + self.issued_client = Some(client.to_string()); + Ok(code) + } +} + +#[derive(Clone, Deserialize)] +struct IdClaims { + sub: String, + iss: String, + aud: Value, + exp: u64, + iat: u64, + nonce: Option, + azp: Option, + email: Option, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, + id_token: Option, + token_type: String, + expires_in: u64, + scope: Option, + earliest_refresh_at: Option, +} + +impl Auth { + pub fn new(profile: String) -> Result { + validate_label(&profile)?; + Ok(Self { + store: Store::default_path(), + profile, + endpoints: Endpoints::default(), + http: http_client(), + }) + } + + #[cfg(test)] + pub fn for_test(store: Store, endpoints: Endpoints) -> Self { + Self { + store, + profile: "default".into(), + endpoints, + http: http_client(), + } + } + + async fn discovery(&self) -> Result { + let response = self + .http + .get(&self.endpoints.discovery) + .timeout(Duration::from_secs(20)) + .send() + .await + .map_err(|_| anyhow::anyhow!("OpenAI discovery network error"))?; + ensure!(response.status().is_success(), "OpenAI discovery failed"); + let doc = read_json(response).await?; + ensure!( + doc["issuer"] == ISSUER + && doc["jwks_uri"] == self.endpoints.jwks + && doc["token_endpoint"] == self.endpoints.token + && doc["revocation_endpoint"] == self.endpoints.revoke, + "Unexpected OpenAI discovery endpoints" + ); + Ok(doc) + } + + async fn verify_id( + &self, + token: &str, + client_id: &str, + nonce: &str, + refresh: bool, + ) -> Result { + ensure!(token.len() <= 64 * 1024, "ID token exceeds limit"); + let header = + decode_header(token).map_err(|_| anyhow::anyhow!("Invalid ID token header"))?; + ensure!(header.alg == Algorithm::RS256, "ID token must use RS256"); + let kid = header.kid.context("ID token missing key ID")?; + let doc = self.discovery().await?; + let response = self + .http + .get(doc["jwks_uri"].as_str().context("Missing JWKS URI")?) + .timeout(Duration::from_secs(20)) + .send() + .await + .map_err(|_| anyhow::anyhow!("JWKS network error"))?; + ensure!(response.status().is_success(), "JWKS request failed"); + let raw = read_json(response).await?; + let keys: JwkSet = serde_json::from_value(raw.clone()).context("Invalid JWKS")?; + let key = keys + .find(&kid) + .context("ID token key is absent from official JWKS")?; + let raw_key = raw["keys"] + .as_array() + .and_then(|keys| keys.iter().find(|k| k["kid"] == kid)) + .context("Invalid signing key")?; + ensure!( + raw_key["kty"] == "RSA" + && raw_key.get("alg").is_none_or(|v| v == "RS256") + && raw_key.get("use").is_none_or(|v| v == "sig"), + "Invalid JWKS signing key type" + ); + let mut validation = Validation::new(Algorithm::RS256); + validation.set_required_spec_claims(&["iss", "sub", "aud", "exp"]); + validation.set_issuer(&[ISSUER]); + validation.set_audience(&[client_id]); + validation.validate_nbf = true; + validation.leeway = 0; + let key = DecodingKey::from_jwk(key).context("Invalid RSA signing key")?; + let claims = decode::(token, &key, &validation) + .map_err(|_| anyhow::anyhow!("ID token signature or claims validation failed"))? + .claims; + ensure!( + !claims.sub.is_empty() && claims.iat <= now() + 60 && claims.exp > claims.iat, + "Invalid ID token identity or lifetime" + ); + ensure!( + (refresh && claims.nonce.is_none()) || claims.nonce.as_deref() == Some(nonce), + "ID token nonce mismatch" + ); + let multiple_audiences = claims.aud.as_array().is_some_and(|a| a.len() > 1); + ensure!( + !multiple_audiences || claims.azp.as_deref() == Some(client_id), + "ID token authorized party missing or invalid" + ); + ensure!( + claims.azp.as_deref().is_none_or(|azp| azp == client_id), + "ID token authorized party mismatch" + ); + Ok(claims) + } + + fn token_set(response: TokenResponse, prior: Option<&Tokens>, nonce: &str) -> Result { + ensure!( + !response.access_token.is_empty() + && !response.refresh_token.is_empty() + && response.token_type.eq_ignore_ascii_case("Bearer") + && response.expires_in > 0, + "Invalid OAuth token response" + ); + let scopes = match response.scope { + Some(scope) => scope.split_whitespace().map(String::from).collect(), + None => prior + .map(|t| t.scopes.clone()) + .context("OAuth response omitted granted scopes")?, + }; + let id_token = response + .id_token + .or_else(|| prior.map(|t| t.id_token.clone())) + .context("Missing ID token")?; + Ok(Tokens { + access_token: response.access_token, + refresh_token: response.refresh_token, + id_token, + expires_at: now().saturating_add(response.expires_in), + earliest_refresh_at: response.earliest_refresh_at, + scopes, + nonce: nonce.to_string(), + }) + } + + pub async fn exchange(&self, attempt: &Attempt, code: &str) -> Result { + let client_id = attempt + .issued_client + .as_deref() + .context("No issued client_id")?; + let response = self + .http + .post(&self.endpoints.token) + .timeout(Duration::from_secs(30)) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id), + ("code", code), + ("code_verifier", &attempt.verifier), + ("redirect_uri", &attempt.redirect_uri), + ("resource", RESOURCE), + ]) + .send() + .await + .map_err(|_| { + anyhow::anyhow!("OAuth code exchange interrupted; start a fresh sign-in") + })?; + let status = response.status(); + let raw = read_json(response).await?; + if !status.is_success() { + if raw["error"] == "invalid_grant" { + bail!("invalid_grant: start fresh authorization with the issued client ID"); + } + bail!("OAuth code exchange failed (HTTP {})", status.as_u16()); + } + let response: TokenResponse = serde_json::from_value(raw) + .map_err(|_| anyhow::anyhow!("Invalid OAuth token response"))?; + let claims = self + .verify_id( + response.id_token.as_deref().context("Missing ID token")?, + client_id, + &attempt.nonce, + false, + ) + .await?; + if let Some(selected) = &attempt.selected { + ensure!( + claims.sub == selected.subject && claims.iss == selected.issuer, + "Returning account identity mismatch; original credentials retained" + ); + } + let tokens = Self::token_set(response, None, &attempt.nonce)?; + let permitted = tokens.permits_inference(); + let mut locked = self.store.lock().await?; + ensure!( + locked.state.ext_agent_host_id == attempt.host_id, + "SIWC host changed during authorization" + ); + let current = locked.state.profiles.get(&self.profile); + ensure!( + current.map(|p| p.revision) == attempt.selected.as_ref().map(|p| p.revision), + "Account changed during authorization; repeat sign-in" + ); + ensure!( + !locked + .state + .profiles + .iter() + .any(|(label, p)| label != &self.profile && p.client_id == client_id), + "This registration already belongs to another profile; select that profile" + ); + locked.state.profiles.insert( + self.profile.clone(), + Profile { + client_id: client_id.into(), + issuer: claims.iss, + subject: claims.sub, + email: claims.email, + revision: attempt.selected.as_ref().map_or(1, |p| p.revision + 1), + tokens: Some(tokens), + refresh_pending: false, + }, + ); + locked.save()?; + Ok(permitted) + } + + pub async fn access_token(&self) -> Result { + let mut locked = self.store.lock().await?; + let profile = locked + .state + .profiles + .get_mut(&self.profile) + .context("SIWC profile is not signed in; run siwc auth login")?; + ensure!( + !profile.refresh_pending, + "Previous refresh outcome is uncertain; run siwc auth login to reauthorize safely" + ); + let old = profile + .tokens + .clone() + .context("SIWC profile is signed out; run siwc auth login")?; + ensure!( + old.permits_inference(), + "ChatGPT plan permission is disabled; run siwc auth login --consent" + ); + if old.expires_at > now() + 60 + || (old.expires_at > now() && old.earliest_refresh_at.is_some_and(|t| t > now())) + { + return Ok(old.access_token); + } + ensure!( + old.earliest_refresh_at.is_none_or(|t| t <= now()), + "Access token expired before earliest_refresh_at; retry later or reauthorize" + ); + let client_id = profile.client_id.clone(); + let subject = profile.subject.clone(); + profile.refresh_pending = true; + profile.revision += 1; + locked.save()?; + // Durable before network I/O: cancellation, crashes, or lost replies must not reuse a rotating token. + let response = self + .http + .post(&self.endpoints.token) + .timeout(Duration::from_secs(30)) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", &client_id), + ("refresh_token", &old.refresh_token), + ("resource", RESOURCE), + ]) + .send() + .await + .map_err(|_| { + anyhow::anyhow!( + "Refresh outcome uncertain; credentials retained, reauthorization required" + ) + })?; + let status = response.status(); + let raw = read_json(response).await?; + if !status.is_success() { + let code = raw["error"] + .as_str() + .or_else(|| raw.pointer("/error/code").and_then(Value::as_str)) + .unwrap_or(""); + let terminal = matches!( + code, + "invalid_grant" + | "invalid_refresh_token" + | "token_expired" + | "refresh_token_expired" + | "refresh_token_invalidated" + | "refresh_token_reused" + ); + let profile = locked + .state + .profiles + .get_mut(&self.profile) + .expect("locked profile"); + profile.refresh_pending = false; + if terminal { + profile.tokens = None; + } + locked.save()?; + if terminal { + bail!("Refresh token is unusable; run siwc auth login (saved client ID retained)"); + } + if code == "invalid_client" { + bail!("OAuth invalid_client: verify saved client registration"); + } + bail!( + "Token refresh failed (HTTP {}); credentials retained", + status.as_u16() + ); + } + let response: TokenResponse = serde_json::from_value(raw) + .map_err(|_| anyhow::anyhow!("Invalid refresh response; reauthorization required"))?; + if let Some(id) = &response.id_token { + let claims = self.verify_id(id, &client_id, &old.nonce, true).await?; + ensure!( + claims.sub == subject, + "Refresh account identity mismatch; reauthorization required" + ); + } + let tokens = Self::token_set(response, Some(&old), &old.nonce)?; + let access = tokens.access_token.clone(); + let permitted = tokens.permits_inference(); + let profile = locked + .state + .profiles + .get_mut(&self.profile) + .expect("locked profile"); + profile.tokens = Some(tokens); + profile.refresh_pending = false; + locked.save()?; + ensure!( + permitted, + "ChatGPT plan permission was removed; inference stopped" + ); + Ok(access) + } + + pub async fn logout(&self) -> Result { + let mut locked = self.store.lock().await?; + let Some(profile) = locked.state.profiles.get(&self.profile) else { + return Ok(true); + }; + let mut confirmed = profile.tokens.is_none(); + if let Some(tokens) = &profile.tokens + && let Ok(doc) = self.discovery().await + { + for attempt in 0..3 { + let response = self + .http + .post( + doc["revocation_endpoint"] + .as_str() + .context("Missing revocation endpoint")?, + ) + .timeout(Duration::from_secs(15)) + .form(&[ + ("token", tokens.refresh_token.as_str()), + ("token_type_hint", "refresh_token"), + ("client_id", profile.client_id.as_str()), + ]) + .send() + .await; + match response { + Ok(response) if response.status() == reqwest::StatusCode::OK => { + confirmed = true; + break; + } + Ok(response) if !response.status().is_server_error() => break, + _ if attempt < 2 => { + tokio::time::sleep(Duration::from_millis(250 * (1 << attempt))).await + } + _ => break, + } + } + } + let profile = locked + .state + .profiles + .get_mut(&self.profile) + .expect("locked profile"); + profile.tokens = None; + profile.refresh_pending = false; + profile.revision += 1; + locked.save()?; + Ok(confirmed) + } + + pub async fn session_active(&self) -> Result { + let locked = self.store.lock().await?; + Ok(locked + .state + .profiles + .get(&self.profile) + .and_then(|p| p.tokens.as_ref()) + .is_some_and(Tokens::permits_inference)) + } + + pub async fn status(&self) -> Result { + let locked = self.store.lock().await?; + let profiles: Vec<_> = locked.state.profiles.iter().map(|(label, p)| serde_json::json!({ + "profile": label, "selected": label == &self.profile, "client_id": p.client_id, "subject": p.subject, "email": p.email, + "signed_in": p.tokens.is_some(), "plan_permission": p.tokens.as_ref().is_some_and(Tokens::permits_inference), + "expires_at": p.tokens.as_ref().map(|t| t.expires_at), "reauthorization_required": p.refresh_pending, + })).collect(); + Ok( + serde_json::json!({"selected_profile":self.profile,"ext_agent_host_id":locked.state.ext_agent_host_id,"profiles":profiles}), + ) + } + + pub async fn login(&self, consent: bool, no_browser: bool) -> Result<()> { + let locked = self.store.lock().await?; + let host_id = locked.state.ext_agent_host_id.clone(); + let selected = locked.state.profiles.get(&self.profile).cloned(); + drop(locked); + let mut issued_client = None; + for retry in 0..2 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let redirect = format!( + "http://127.0.0.1:{}/auth/callback", + listener.local_addr()?.port() + ); + let mut attempt = Attempt::new(redirect, host_id.clone(), selected.clone()); + attempt.issued_client = issued_client; + let url = attempt.authorize_url(&self.endpoints, consent, !no_browser)?; + let callback = wait_callback(listener, attempt.state.clone()); + tokio::pin!(callback); + if no_browser { + // Never print a retained ID token: hint-free authorization is equally valid. + println!("Continue with ChatGPT:\n{url}"); + } else { + open_browser(url.as_str())?; + println!("Continue with ChatGPT in your browser. Waiting for authorization…"); + } + let query = callback.await?; + let code = attempt.callback(&query)?; + match self.exchange(&attempt, &code).await { + Ok(true) => { + println!( + "Signed in to SIWC profile '{}'; ChatGPT plan permission granted.", + self.profile + ); + return Ok(()); + } + Ok(false) => { + println!( + "Signed in to SIWC profile '{}', but ChatGPT plan permission is disabled. Run siwc auth login --consent before inference.", + self.profile + ); + return Ok(()); + } + Err(e) if retry == 0 && e.to_string().starts_with("invalid_grant:") => { + issued_client = attempt.issued_client.clone(); + println!( + "Authorization code expired; starting fresh authorization with the issued client ID." + ); + } + Err(e) => return Err(e), + } + } + bail!("OAuth authorization did not complete") + } +} + +pub(super) async fn wait_callback( + listener: tokio::net::TcpListener, + expected_state: String, +) -> Result> { + use axum::{Router, extract::Query, http::StatusCode, response::IntoResponse, routing::get}; + let (tx, rx) = oneshot::channel(); + let tx = Arc::new(Mutex::new(Some(tx))); + let app = Router::new().route( + "/auth/callback", + get(move |Query(query): Query>| { + let tx = tx.clone(); + let expected_state = expected_state.clone(); + async move { + if query.get("state") != Some(&expected_state) { + return (StatusCode::BAD_REQUEST, "OAuth state mismatch").into_response(); + } + let Some(tx) = tx.lock().await.take() else { + return (StatusCode::CONFLICT, "Authorization callback already used") + .into_response(); + }; + let _ = tx.send(query); + ( + [ + ("cache-control", "no-store"), + ("referrer-policy", "no-referrer"), + ("content-security-policy", "default-src 'none'"), + ], + "Authorization received. Return to the terminal for validation results.", + ) + .into_response() + } + }), + ); + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let server = axum::serve(listener, app) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .into_future(); + tokio::pin!(server); + tokio::select! { + result = &mut server => { result?; bail!("OAuth callback listener stopped") }, + result = tokio::time::timeout(Duration::from_secs(300), rx) => { + let _ = shutdown_tx.send(()); + let _ = tokio::time::timeout(Duration::from_secs(1), &mut server).await; + result.context("OAuth authorization timed out")?.context("OAuth callback closed") + }, + _ = tokio::signal::ctrl_c() => bail!("OAuth authorization cancelled"), + } +} + +fn open_browser(url: &str) -> Result<()> { + #[cfg(target_os = "macos")] + let result = std::process::Command::new("/usr/bin/open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + #[cfg(not(target_os = "macos"))] + let result = std::process::Command::new("xdg-open") + .arg(url) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + ensure!( + result.is_ok_and(|status| status.success()), + "Cannot open browser; retry siwc auth login --no-browser" + ); + Ok(()) +} diff --git a/src/providers/chatgpt_siwc/cli.rs b/src/providers/chatgpt_siwc/cli.rs new file mode 100644 index 00000000..a054ec2c --- /dev/null +++ b/src/providers/chatgpt_siwc/cli.rs @@ -0,0 +1,98 @@ +use super::{SiwcProvider, auth::Auth, client::Client}; +use anyhow::Result; +use clap::Subcommand; + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Manage official Sign in with ChatGPT accounts + Auth { + #[command(subcommand)] + command: AuthCommand, + }, + /// Fetch the selected account's current model catalog from the official API + Models { + #[arg(long, default_value = "default")] + profile: String, + }, + /// Start an isolated SIWC proxy on 127.0.0.1 + Serve { + #[arg(long, default_value = "default")] + profile: String, + #[arg(long)] + model: Option, + #[arg(long, default_value_t = 18765)] + port: u16, + }, +} + +#[derive(Debug, Subcommand)] +pub enum AuthCommand { + /// Continue with ChatGPT; repeat for reauthorization of the same registration + Login { + #[arg(long, default_value = "default")] + profile: String, + /// Explicitly request consent for ChatGPT plan usage + #[arg(long)] + consent: bool, + /// Print a hint-free authorization URL instead of launching a browser + #[arg(long)] + no_browser: bool, + }, + /// Show account registrations and permission/expiry status, without tokens + Status { + #[arg(long, default_value = "default")] + profile: String, + }, + /// Revoke the renewable session and clear tokens, retaining registration/host + Logout { + #[arg(long, default_value = "default")] + profile: String, + }, +} + +pub fn run(command: Command) -> Result<()> { + tokio::runtime::Builder::new_multi_thread().enable_all().build()?.block_on(async { + match command { + Command::Auth { command } => match command { + AuthCommand::Login { profile, consent, no_browser } => Auth::new(profile)?.login(consent, no_browser).await, + AuthCommand::Status { profile } => { println!("{}", serde_json::to_string_pretty(&Auth::new(profile)?.status().await?)?); Ok(()) }, + AuthCommand::Logout { profile } => { + let confirmed = Auth::new(profile)?.logout().await?; + println!("Signed out locally; registration and host ID retained."); + if !confirmed { println!("Remote revocation was not confirmed. Disconnect the app in ChatGPT Settings: https://chatgpt.com/settings/usage"); } + Ok(()) + }, + }, + Command::Models { profile } => { + let models = Client::new(Auth::new(profile)?).models().await?; + println!("{}", serde_json::to_string_pretty(&models)?); + Ok(()) + }, + Command::Serve { profile, model, port } => { + let provider = SiwcProvider::new(profile.clone(), model.or_else(crate::config::siwc_model))?; + // Authenticate once at startup; every request rereads credentials. + provider.client.auth.access_token().await?; + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).await?; + println!("SIWC proxy: http://{} | ChatGPT profile: {profile}", listener.local_addr()?); + println!("Models: claude-code-proxy siwc models --profile {profile}"); + let registry = crate::registry::Registry::from_providers(crate::config::AliasProvider::Siwc, [std::sync::Arc::new(provider) as std::sync::Arc]); + axum::serve(listener, crate::server::app(std::sync::Arc::new(registry)).into_make_service_with_connect_info::()) + .with_graceful_shutdown(async { shutdown_signal().await }).await?; + Ok(()) + }, + } + }) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + if let Ok(mut term) = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + { + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term.recv() => {} } + return; + } + } + let _ = tokio::signal::ctrl_c().await; +} diff --git a/src/providers/chatgpt_siwc/client.rs b/src/providers/chatgpt_siwc/client.rs new file mode 100644 index 00000000..754c0c11 --- /dev/null +++ b/src/providers/chatgpt_siwc/client.rs @@ -0,0 +1,241 @@ +use super::auth::{Auth, http_client, read_json}; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::{Value, json}; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub(super) struct ApiError { + pub status: StatusCode, + pub body: Value, + pub request_id: Option, + pub retry_after: Option, +} + +impl ApiError { + pub fn local(status: StatusCode, code: &str, message: &str) -> Self { + Self { + status, + body: json!({"error":{"code":code,"message":message}}), + request_id: None, + retry_after: None, + } + } + pub fn error_type(&self) -> &'static str { + match self.status.as_u16() { + 400 | 404 | 422 => "invalid_request_error", + 401 => "authentication_error", + 403 => "permission_error", + 429 => "rate_limit_error", + 503 => "overloaded_error", + _ => "api_error", + } + } + pub fn envelope(&self) -> Value { + let message = self + .body + .pointer("/error/message") + .or_else(|| self.body.get("detail")) + .and_then(Value::as_str) + .unwrap_or("SIWC upstream request failed"); + json!({"type":"error","error":{"type":self.error_type(),"message":message,"code":self.body.pointer("/error/code"),"param":self.body.pointer("/error/param")},"upstream_status":self.status.as_u16(),"upstream_body":self.body,"upstream_request_id":self.request_id}) + } + pub fn response(&self) -> Response { + let mut response = (self.status, Json(self.envelope())).into_response(); + for (key, value) in [ + ("x-openai-request-id", &self.request_id), + ("retry-after", &self.retry_after), + ] { + if let Some(value) = value + && let Ok(value) = value.parse() + { + response.headers_mut().insert(key, value); + } + } + response + } +} +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Deliberately exclude upstream text from CLI diagnostics and logs. + write!( + f, + "SIWC HTTP {} ({})", + self.status.as_u16(), + self.body + .pointer("/error/code") + .and_then(Value::as_str) + .unwrap_or("upstream_error") + ) + } +} +impl std::error::Error for ApiError {} + +#[derive(Clone)] +pub(super) struct Client { + pub auth: Auth, + http: reqwest::Client, + pub usage_pause: std::sync::Arc>>, +} + +impl Client { + pub fn new(auth: Auth) -> Self { + Self { + auth, + http: http_client(), + usage_pause: Default::default(), + } + } + async fn request(&self, body: Option<&Value>) -> Result { + if body.is_some() + && let Some(error) = self.usage_pause.lock().expect("usage pause").clone() + { + return Err(error); + } + let token = self.auth.access_token().await.map_err(|e| { + ApiError::local( + StatusCode::UNAUTHORIZED, + "siwc_authorization_required", + &e.to_string(), + ) + })?; + for attempt in 0..3 { + if body.is_some() + && let Some(error) = self.usage_pause.lock().expect("usage pause").clone() + { + return Err(error); + } + if !self.auth.session_active().await.unwrap_or(false) { + return Err(ApiError::local( + StatusCode::UNAUTHORIZED, + "siwc_signed_out", + "SIWC account signed out or permission removed", + )); + } + let request = if let Some(body) = body { + self.http + .post(format!("{}/responses", self.auth.endpoints.api)) + .header("accept", "text/event-stream") + .json(body) + } else { + self.http.get(format!("{}/models", self.auth.endpoints.api)) + }; + let result = + tokio::time::timeout(Duration::from_secs(60), request.bearer_auth(&token).send()) + .await; + let response = result.ok().and_then(Result::ok).ok_or_else(|| { + ApiError::local( + StatusCode::BAD_GATEWAY, + "siwc_network_error", + "SIWC request failed or timed out before headers; no automatic replay", + ) + })?; + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let request_id = response + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let retry_after = response + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(String::from); + let raw = tokio::time::timeout(Duration::from_secs(15), read_json(response)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_else( + || json!({"detail":"Upstream returned an unreadable error response"}), + ); + // Error payloads are untrusted; never return a reflected bearer token. + let raw = serde_json::to_string(&raw) + .unwrap_or_default() + .replace(&token, "[REDACTED]"); + let error = ApiError { + status, + body: serde_json::from_str(&raw).unwrap_or(Value::Null), + request_id, + retry_after, + }; + if error.body.pointer("/error/code").and_then(Value::as_str) + == Some("subscription_sharing_usage_limit_exceeded") + { + *self.usage_pause.lock().expect("usage pause") = Some(error.clone()); + } + let code = error + .body + .pointer("/error/code") + .and_then(Value::as_str) + .unwrap_or(""); + let temporary = status == StatusCode::SERVICE_UNAVAILABLE + && matches!( + code, + "" | "subscription_sharing_usage_unavailable" + | "subscription_sharing_user_unavailable" + ); + if !temporary || attempt == 2 { + return Err(error); + } + let delay = error + .retry_after + .as_deref() + .and_then(|v| v.parse::().ok()) + .map(Duration::from_secs) + .unwrap_or(Duration::from_millis(250 * (1 << attempt))); + if delay > Duration::from_secs(5) { + return Err(error); + } + tokio::time::sleep(delay).await; + } + unreachable!("bounded request loop") + } + pub async fn responses(&self, body: &Value) -> Result { + self.request(Some(body)).await + } + pub async fn models(&self) -> Result, ApiError> { + let response = self.request(None).await?; + let raw = tokio::time::timeout(Duration::from_secs(20), read_json(response)) + .await + .ok() + .and_then(Result::ok) + .ok_or_else(|| { + ApiError::local( + StatusCode::BAD_GATEWAY, + "invalid_model_catalog", + "Could not read SIWC model catalog", + ) + })?; + let models = raw["models"].as_array().ok_or_else(|| { + ApiError::local( + StatusCode::BAD_GATEWAY, + "invalid_model_catalog", + "SIWC model catalog has no models array", + ) + })?; + models + .iter() + .filter(|m| m["visibility"] == "list") + .map(|m| { + let slug = m["slug"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + ApiError::local( + StatusCode::BAD_GATEWAY, + "invalid_model_catalog", + "SIWC model entry has no slug", + ) + })?; + let display = m["display_name"].as_str().unwrap_or(slug); + Ok(json!({"type":"model","object":"model","id":slug,"display_name":display})) + }) + .collect() + } +} diff --git a/src/providers/chatgpt_siwc/mod.rs b/src/providers/chatgpt_siwc/mod.rs new file mode 100644 index 00000000..9e3b1d4e --- /dev/null +++ b/src/providers/chatgpt_siwc/mod.rs @@ -0,0 +1,135 @@ +//! Official Sign in with ChatGPT: independent auth and public HTTP transport. +mod auth; +pub mod cli; +mod client; +mod store; +mod stream; +pub(crate) use stream::StreamOutcome; +#[cfg(test)] +mod tests; +mod translate; + +use self::{ + auth::Auth, + client::{ApiError, Client}, +}; +use crate::{CliHandlers, MessagesRequest, Provider, RequestContext}; +use anyhow::Result; +use async_trait::async_trait; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::json; + +pub struct SiwcProvider { + client: Client, + default_model: Option, +} +impl SiwcProvider { + pub fn new(profile: String, default_model: Option) -> Result { + Ok(Self { + client: Client::new(Auth::new(profile)?), + default_model, + }) + } + fn model(&self, requested: Option<&str>) -> Result { + let model = requested.unwrap_or("siwc"); + if model == "siwc" || crate::registry::is_anthropic_alias(model) { + self.default_model.clone().ok_or_else(|| { + anyhow::anyhow!("Set CCP_SIWC_MODEL or siwc.model to a slug from `siwc models`") + }) + } else { + Ok(model.strip_prefix("siwc/").unwrap_or(model).to_string()) + } + } +} + +#[async_trait] +impl Provider for SiwcProvider { + fn name(&self) -> &'static str { + "siwc" + } + fn supported_models(&self) -> Vec { + vec!["siwc".into()] + } + fn cli(&self) -> &'static dyn CliHandlers { + &SIWC_CLI + } + async fn model_catalog(&self, limit: Option) -> Option { + Some(match self.client.models().await { + Ok(mut data) => { + let has_more = limit.is_some_and(|limit| data.len() > limit); + if let Some(limit) = limit { + data.truncate(limit); + } + Json(json!({"object":"list", "first_id":data.first().map(|m| &m["id"]),"last_id":data.last().map(|m| &m["id"]),"data":data,"has_more":has_more})).into_response() + } + Err(e) => e.response(), + }) + } + async fn handle_messages(&self, body: MessagesRequest, _ctx: RequestContext) -> Response { + let model = match self.model(body.model.as_deref()) { + Ok(model) => model, + Err(e) => return bad_request(&e.to_string()), + }; + let prepared = match translate::prepare(&body, model.clone()) { + Ok(prepared) => prepared, + Err(e) => return bad_request(&e.to_string()), + }; + match self.client.responses(&prepared.body).await { + Ok(response) => { + stream::response( + response, + prepared.tool_names, + model, + body.stream, + prepared.estimated_tokens, + self.client.usage_pause.clone(), + self.client.auth.clone(), + ) + .await + } + Err(e) => e.response(), + } + } + async fn handle_count_tokens(&self, body: MessagesRequest, _ctx: RequestContext) -> Response { + match self + .model(body.model.as_deref()) + .and_then(|model| translate::prepare(&body, model)) + { + Ok(prepared) => ( + [("x-siwc-token-count", "estimate")], + Json(json!({"input_tokens":prepared.estimated_tokens})), + ) + .into_response(), + Err(e) => bad_request(&e.to_string()), + } + } +} +fn bad_request(message: &str) -> Response { + ApiError::local( + StatusCode::BAD_REQUEST, + "siwc_unsupported_or_invalid_request", + message, + ) + .response() +} + +struct SiwcCli; +static SIWC_CLI: SiwcCli = SiwcCli; +impl CliHandlers for SiwcCli { + fn login(&self) -> Result<()> { + anyhow::bail!("Use claude-code-proxy siwc auth login") + } + fn device(&self) -> Result<()> { + anyhow::bail!("SIWC uses browser Authorization Code + PKCE; use siwc auth login") + } + fn status(&self) -> Result<()> { + anyhow::bail!("Use claude-code-proxy siwc auth status") + } + fn logout(&self) -> Result<()> { + anyhow::bail!("Use claude-code-proxy siwc auth logout") + } +} diff --git a/src/providers/chatgpt_siwc/store.rs b/src/providers/chatgpt_siwc/store.rs new file mode 100644 index 00000000..500360b9 --- /dev/null +++ b/src/providers/chatgpt_siwc/store.rs @@ -0,0 +1,237 @@ +//! Private, atomic storage. No provider credentials are imported. +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + time::Duration, +}; + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct Tokens { + pub access_token: String, + pub refresh_token: String, + pub id_token: String, + pub expires_at: u64, + pub earliest_refresh_at: Option, + pub scopes: Vec, + pub nonce: String, +} + +impl Tokens { + pub fn permits_inference(&self) -> bool { + ["resource.invoke", "chatgpt.tokens.use.direct"] + .iter() + .all(|scope| self.scopes.iter().any(|s| s == scope)) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub(super) struct Profile { + pub client_id: String, + pub issuer: String, + pub subject: String, + pub email: Option, + pub revision: u64, + pub tokens: Option, + #[serde(default)] + pub refresh_pending: bool, +} + +#[derive(Serialize, Deserialize)] +pub(super) struct State { + pub version: u32, + pub ext_agent_host_id: String, + pub profiles: BTreeMap, +} + +#[derive(Clone)] +pub(super) struct Store { + root: PathBuf, +} + +pub(super) struct LockedStore { + root: PathBuf, + _lock: File, + pub state: State, +} + +pub(super) fn validate_label(label: &str) -> Result<()> { + ensure!( + !label.is_empty() + && label.len() <= 64 + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'), + "Profile label must contain 1–64 ASCII letters, digits, '-' or '_'" + ); + Ok(()) +} + +impl Store { + pub fn new(root: PathBuf) -> Self { + Self { root } + } + pub fn default_path() -> Self { + Self::new(crate::paths::config_dir().join("siwc")) + } + + pub async fn lock(&self) -> Result { + prepare_directory(&self.root)?; + let path = self.root.join("state.lock"); + check_private(&path, false)?; + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock = options + .open(&path) + .context("Cannot open SIWC credential lock")?; + check_private(&path, false)?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(40); + loop { + match lock.try_lock() { + Ok(()) => break, + Err(std::fs::TryLockError::WouldBlock) + if tokio::time::Instant::now() < deadline => + { + tokio::time::sleep(Duration::from_millis(25)).await + } + Err(_) => bail!("SIWC credential store is busy or cannot be locked; retry later"), + } + } + // Remove only our own abandoned atomic-write files while holding the lock. + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name + .strip_prefix(".state-") + .and_then(|n| n.strip_suffix(".tmp")) + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_some() + { + check_private(&entry.path(), false)?; + fs::remove_file(entry.path())?; + } + } + let path = self.root.join("state.json"); + check_private(&path, false)?; + let (state, fresh) = match fs::read(&path) { + Ok(raw) => ( + serde_json::from_slice::(&raw) + .context("Invalid SIWC credential store; refusing to overwrite it")?, + false, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ( + State { + version: 1, + ext_agent_host_id: format!("urn:uuid:{}", uuid::Uuid::new_v4()), + profiles: BTreeMap::new(), + }, + true, + ), + Err(e) => return Err(e).context("Cannot read SIWC credential store"), + }; + ensure!( + state.version == 1, + "Unsupported SIWC credential store version" + ); + let host = state + .ext_agent_host_id + .strip_prefix("urn:uuid:") + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + ensure!( + host.is_some_and(|id| id.get_version_num() == 4), + "Invalid stored SIWC host ID; refusing to replace it" + ); + let locked = LockedStore { + root: self.root.clone(), + _lock: lock, + state, + }; + if fresh { + locked.save()?; + } + Ok(locked) + } +} + +impl LockedStore { + pub fn save(&self) -> Result<()> { + let target = self.root.join("state.json"); + check_private(&target, false)?; + let temp = self + .root + .join(format!(".state-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(&temp)?; + file.write_all(&serde_json::to_vec(&self.state)?)?; + file.sync_all()?; + fs::rename(&temp, &target)?; + File::open(&self.root)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result.context("Cannot atomically save SIWC credentials") + } +} + +fn prepare_directory(root: &Path) -> Result<()> { + #[cfg(not(unix))] + bail!("SIWC file credentials currently require Unix owner-only permissions"); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + let mut builder = fs::DirBuilder::new(); + builder.recursive(true).mode(0o700); + builder.create(root)?; + check_private(root, true) + } +} + +fn check_private(path: &Path, directory: bool) -> Result<()> { + let meta = match fs::symlink_metadata(path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => return Err(e).context("Cannot inspect SIWC storage permissions"), + }; + ensure!( + !meta.file_type().is_symlink() + && (if directory { + meta.is_dir() + } else { + meta.is_file() + }), + "SIWC storage must be a regular private file/directory, never a symlink" + ); + #[cfg(unix)] + { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + ensure!( + meta.permissions().mode() & 0o077 == 0, + "SIWC storage permissions are too broad; use chmod 700 for its directory and chmod 600 for its files" + ); + if !directory { + ensure!( + meta.nlink() == 1, + "SIWC credential files must not have hard links" + ); + } + } + Ok(()) +} diff --git a/src/providers/chatgpt_siwc/stream.rs b/src/providers/chatgpt_siwc/stream.rs new file mode 100644 index 00000000..46ebcc3f --- /dev/null +++ b/src/providers/chatgpt_siwc/stream.rs @@ -0,0 +1,493 @@ +//! Incremental, bounded SSE parsing plus SIWC completion/tool integrity checks. +use super::{auth::Auth, client::ApiError}; + +pub(super) type UsagePause = std::sync::Arc>>; + +#[derive(Clone, Default)] +pub(crate) struct StreamOutcome(std::sync::Arc>>); +impl StreamOutcome { + pub(crate) fn failure(&self) -> Option { + self.0.lock().expect("SIWC stream outcome").clone() + } + fn fail(&self) { + *self.0.lock().expect("SIWC stream outcome") = + Some("SIWC stream failed; inspect the client error event".into()); + } +} +fn pause_on_limit(pause: &UsagePause, error: &ApiError) { + if error.body.pointer("/error/code").and_then(Value::as_str) + == Some("subscription_sharing_usage_limit_exceeded") + { + *pause.lock().expect("usage pause") = Some(error.clone()); + } +} +use crate::providers::codex::translate::{ + accumulate::accumulate_response, live_stream::LiveStreamTranslator, +}; +use axum::{ + body::Body, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use bytes::Bytes; +use futures_util::stream; +use serde_json::{Value, json}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + time::Duration, +}; + +const MAX_FRAME: usize = 8 * 1024 * 1024; +const MAX_BUFFERED_RESPONSE: usize = 32 * 1024 * 1024; + +#[derive(Default)] +pub(super) struct Decoder { + line: Vec, + data: Vec, + size: usize, + skip_lf: bool, + event: Option, + pub events: VecDeque, +} +impl Decoder { + pub fn push(&mut self, bytes: &[u8]) -> Result<(), ApiError> { + for &byte in bytes { + if self.skip_lf { + self.skip_lf = false; + if byte == b'\n' { + continue; + } + } + if byte == b'\r' || byte == b'\n' { + self.skip_lf = byte == b'\r'; + let line = String::from_utf8(std::mem::take(&mut self.line)) + .map_err(|_| protocol("Invalid UTF-8 in SSE"))?; + if line.is_empty() { + if !self.data.is_empty() { + let data = self.data.join("\n"); + if data == "[DONE]" { + return Err(protocol( + "SSE ended with [DONE] instead of response.completed", + )); + } + let mut value: Value = serde_json::from_str(&data) + .map_err(|_| protocol("Invalid JSON in SSE event"))?; + if value.get("type").is_none() + && let Some(event) = &self.event + { + value["type"] = json!(event); + } + self.events.push_back(value); + } + self.data.clear(); + self.event = None; + self.size = 0; + } else if !line.starts_with(':') { + let (field, value) = line.split_once(':').unwrap_or((&line, "")); + let value = value.strip_prefix(' ').unwrap_or(value); + match field { + "data" => self.data.push(value.into()), + "event" => self.event = Some(value.into()), + _ => {} + } + } + } else { + self.line.push(byte); + } + self.size += 1; + if self.size > MAX_FRAME { + return Err(protocol("SSE event exceeds size limit")); + } + } + Ok(()) + } +} + +fn protocol(message: &str) -> ApiError { + ApiError::local(StatusCode::BAD_GATEWAY, "siwc_stream_error", message) +} + +struct Call { + id: String, + name: String, + item_id: String, + args: String, + done: bool, +} + +pub(super) struct Reader { + response: reqwest::Response, + decoder: Decoder, + names: HashSet, + calls: HashMap, + call_ids: HashSet, + pub complete: bool, + request_id: Option, + auth: Auth, + last_check: tokio::time::Instant, + last_chunk: tokio::time::Instant, + last_ping: tokio::time::Instant, +} +impl Reader { + pub fn new( + response: reqwest::Response, + names: HashSet, + auth: Auth, + ) -> Result { + if !response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .is_some_and(|s| { + s.split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/event-stream")) + }) + { + return Err(protocol("Responses API did not return text/event-stream")); + } + let request_id = response + .headers() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(String::from); + Ok(Self { + response, + decoder: Decoder::default(), + names, + calls: HashMap::new(), + call_ids: HashSet::new(), + complete: false, + request_id, + auth, + last_check: tokio::time::Instant::now() - Duration::from_secs(1), + last_chunk: tokio::time::Instant::now(), + last_ping: tokio::time::Instant::now(), + }) + } + pub async fn next(&mut self) -> Result, ApiError> { + self.next_inner().await.map_err(|mut error| { + error.request_id = self.request_id.clone(); + error + }) + } + async fn next_inner(&mut self) -> Result, ApiError> { + if self.complete { + return Ok(None); + } + loop { + if self.last_check.elapsed() >= Duration::from_secs(1) { + if !self.auth.session_active().await.unwrap_or(false) { + return Err(protocol( + "SIWC account signed out or permission removed; stream stopped", + )); + } + self.last_check = tokio::time::Instant::now(); + } + if let Some(mut value) = self.decoder.events.pop_front() { + if value["type"] == "response.refusal.delta" { + value["type"] = json!("response.output_text.delta"); + } + if let Err(mut e) = self.validate(&value) { + e.request_id = self.request_id.clone(); + return Err(e); + } + return Ok(Some(value)); + } + if self.last_chunk.elapsed() >= Duration::from_secs(90) { + return Err(protocol( + "SIWC stream idle timeout before response.completed", + )); + } + let chunk = match tokio::time::timeout(Duration::from_secs(1), self.response.chunk()) + .await + { + Ok(result) => result + .map_err(|_| protocol("SIWC stream disconnected before response.completed"))?, + Err(_) => { + if self.last_ping.elapsed() >= Duration::from_secs(15) { + self.last_ping = tokio::time::Instant::now(); + return Ok(Some(json!({"type":"keepalive"}))); + } + continue; + } + }; + self.last_chunk = tokio::time::Instant::now(); + match chunk { + Some(bytes) => self.decoder.push(&bytes)?, + None => return Err(protocol("SIWC stream ended without response.completed")), + } + } + } + fn validate(&mut self, value: &Value) -> Result<(), ApiError> { + let kind = value["type"] + .as_str() + .ok_or_else(|| protocol("SSE event missing type"))?; + match kind { + "error" | "response.failed" | "response.incomplete" => { + let error = value.get("error").or_else(|| value.pointer("/response/error")).filter(|v| !v.is_null()).or_else(|| (kind == "error" && (value.get("code").is_some() || value.get("message").is_some())).then_some(value)).cloned().unwrap_or_else(|| json!({"code":"response_incomplete","message":"SIWC response did not complete"})); + let status = match error["code"].as_str().unwrap_or("") { + "subscription_sharing_usage_limit_exceeded" | "rate_limit_exceeded" => { + StatusCode::TOO_MANY_REQUESTS + } + "subscription_sharing_usage_unavailable" + | "subscription_sharing_user_unavailable" => StatusCode::SERVICE_UNAVAILABLE, + "subscription_sharing_user_not_eligible" + | "subscription_sharing_route_not_supported" + | "chatpass_v2_scope_not_authorized" + | "chatpass_v2_invalid_authorization_context" => StatusCode::FORBIDDEN, + "subscription_sharing_invalid_user" => StatusCode::UNAUTHORIZED, + "subscription_sharing_unsupported_capability" => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + }; + return Err(ApiError { + status, + body: json!({"error":error,"event":value}), + request_id: self.request_id.clone(), + retry_after: None, + }); + } + "response.done" => { + return Err(protocol( + "Expected response.completed, received legacy response.done", + )); + } + "response.output_item.added" if value["item"]["type"] == "function_call" => { + let index = value["output_index"] + .as_u64() + .ok_or_else(|| protocol("Tool call missing output_index"))?; + let id = value["item"]["call_id"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| protocol("Tool call missing call_id"))?; + let name = value["item"]["name"].as_str().unwrap_or(""); + if !self.names.contains(name) + || !self.call_ids.insert(id.into()) + || self.calls.contains_key(&index) + { + return Err(protocol("Unknown tool or duplicate tool call ID/index")); + } + let args = value["item"]["arguments"] + .as_str() + .unwrap_or("") + .to_string(); + self.calls.insert( + index, + Call { + id: id.into(), + name: name.into(), + item_id: value["item"]["id"].as_str().unwrap_or("").into(), + args, + done: false, + }, + ); + } + "response.function_call_arguments.delta" | "response.function_call_arguments.done" => { + let call = value["output_index"] + .as_u64() + .and_then(|i| self.calls.get_mut(&i)) + .ok_or_else(|| protocol("Tool arguments reference an unknown call"))?; + if value + .get("item_id") + .and_then(Value::as_str) + .is_some_and(|id| id != call.item_id) + || call.done + { + return Err(protocol("Tool argument item ID/order mismatch")); + } + if kind.ends_with(".delta") { + call.args.push_str( + value["delta"] + .as_str() + .ok_or_else(|| protocol("Invalid tool argument delta"))?, + ); + } else if let Some(args) = value["arguments"].as_str() { + if !call.args.is_empty() && call.args != args { + return Err(protocol("Tool argument completion does not match deltas")); + } + call.args = args.into(); + } + if call.args.len() > MAX_FRAME { + return Err(protocol("Tool arguments exceed size limit")); + } + } + "response.output_item.done" if value["item"]["type"] == "function_call" => { + let call = value["output_index"] + .as_u64() + .and_then(|i| self.calls.get_mut(&i)) + .ok_or_else(|| protocol("Tool completion has no matching call"))?; + if call.done + || value["item"]["call_id"] != call.id + || value["item"]["name"] != call.name + || value["item"]["id"] != call.item_id + { + return Err(protocol("Tool completion identity mismatch")); + } + let args = value["item"]["arguments"] + .as_str() + .ok_or_else(|| protocol("Tool completion missing arguments"))?; + if !call.args.is_empty() && call.args != args { + return Err(protocol("Tool completion arguments mismatch")); + } + if !serde_json::from_str::(args).is_ok_and(|v| v.is_object()) { + return Err(protocol("Tool arguments must be a valid JSON object")); + } + call.args = args.into(); + call.done = true; + } + "response.output_item.added" | "response.output_item.done" + if !matches!( + value["item"]["type"].as_str(), + Some("message" | "reasoning") + ) => + { + return Err(protocol("Unsupported Responses output item")); + } + "response.completed" => { + if value + .pointer("/response/status") + .is_some_and(|s| s != "completed") + || self.calls.values().any(|c| !c.done) + { + return Err(protocol("Response completed with incomplete tools/status")); + } + if let Some(output) = value.pointer("/response/output").and_then(Value::as_array) { + let mut terminal_calls = HashSet::new(); + for item in output { + match item["type"].as_str() { + Some("function_call") => { + if !terminal_calls.insert(item["call_id"].as_str().unwrap_or("")) + || !self.calls.values().any(|c| { + c.done + && item["id"] == c.item_id + && item["call_id"] == c.id + && item["name"] == c.name + && item["arguments"] == c.args + }) + { + return Err(protocol( + "Terminal response contains an unmatched or changed tool call", + )); + } + } + Some("message" | "reasoning") => {} + _ => return Err(protocol("Unsupported terminal output item")), + } + } + } + self.complete = true; + } + _ => {} + } + Ok(()) + } +} + +pub(super) async fn response( + response: reqwest::Response, + names: HashSet, + model: String, + stream_requested: bool, + estimated: u64, + pause: UsagePause, + auth: Auth, +) -> Response { + let reader = match Reader::new(response, names, auth) { + Ok(reader) => reader, + Err(e) => return e.response(), + }; + let id = format!("msg_{}", uuid::Uuid::new_v4().simple()); + if !stream_requested { + let mut reader = reader; + let mut bytes = Vec::new(); + loop { + match reader.next().await { + Ok(Some(event)) => { + bytes.extend(crate::anthropic::sse::encode_sse_event( + None, + &event.to_string(), + )); + if bytes.len() > MAX_BUFFERED_RESPONSE { + return protocol("Buffered response exceeds limit; use streaming") + .response(); + } + } + Ok(None) => break, + Err(e) => { + pause_on_limit(&pause, &e); + return e.response(); + } + } + } + return match accumulate_response(&bytes, &id, &model) { + Ok(value) => axum::Json(value).into_response(), + Err(_) => protocol("Could not translate completed response").response(), + }; + } + struct Live { + reader: Reader, + translator: LiveStreamTranslator, + done: bool, + pause: UsagePause, + outcome: StreamOutcome, + } + let outcome = StreamOutcome::default(); + let live = Live { + reader, + translator: LiveStreamTranslator::with_estimated_input_tokens(id, model, estimated) + .with_read_stall_repair(false), + done: false, + pause, + outcome: outcome.clone(), + }; + let stream = stream::unfold(live, |mut state| async move { + if state.done { + return None; + } + loop { + match state.reader.next().await { + Ok(Some(event)) => match state.translator.accept(&event, None) { + Ok(bytes) if !bytes.is_empty() => { + return Some(( + Ok::<_, std::convert::Infallible>(Bytes::from(bytes)), + state, + )); + } + Ok(_) => {} + Err(_) => { + state.done = true; + state.outcome.fail(); + let e = protocol("Could not translate Responses stream"); + let bytes = crate::anthropic::sse::encode_sse_event( + Some("error"), + &e.envelope().to_string(), + ); + return Some((Ok(Bytes::from(bytes)), state)); + } + }, + Ok(None) => return None, + Err(e) => { + state.done = true; + state.outcome.fail(); + pause_on_limit(&state.pause, &e); + let bytes = crate::anthropic::sse::encode_sse_event( + Some("error"), + &e.envelope().to_string(), + ); + return Some((Ok(Bytes::from(bytes)), state)); + } + } + } + }); + let mut response = ( + [ + ("content-type", "text/event-stream"), + ("cache-control", "no-cache"), + ("x-accel-buffering", "no"), + ], + Body::from_stream(stream), + ) + .into_response(); + response.extensions_mut().insert(outcome); + response +} diff --git a/src/providers/chatgpt_siwc/tests.rs b/src/providers/chatgpt_siwc/tests.rs new file mode 100644 index 00000000..220525ff --- /dev/null +++ b/src/providers/chatgpt_siwc/tests.rs @@ -0,0 +1,1232 @@ +use super::*; +use super::{ + auth::{Attempt, Endpoints, ISSUER, RESOURCE, now}, + store::Store, +}; +use axum::{ + Router, + body::Body, + extract::State, + http::{HeaderMap, Request, StatusCode}, + response::IntoResponse, + routing::any, +}; +use base64::Engine; +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde_json::{Value, json}; +use std::{ + collections::{HashMap, VecDeque}, + sync::{Arc, Mutex}, +}; +use tower::ServiceExt; + +#[derive(Clone)] +struct Recorded { + path: String, + headers: HeaderMap, + body: Vec, +} +struct MockData { + base: String, + requests: Vec, + tokens: VecDeque<(StatusCode, Value)>, + responses: VecDeque<(StatusCode, String)>, + models: Value, + jwks: Value, + revoke: StatusCode, + redirect: Option, + slow_stream: bool, +} +struct Mock { + data: Arc>, + endpoints: Endpoints, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Mock { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Mock { + async fn new() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let endpoints = endpoints(&base); + let data = Arc::new(Mutex::new(MockData { + base, + requests: vec![], + tokens: VecDeque::new(), + responses: VecDeque::new(), + models: json!({"models":[{"slug":"gpt-test","display_name":"Test GPT","visibility":"list"},{"slug":"hidden","visibility":"hide"}]}), + jwks: serde_json::from_str(include_str!("../../../tests/fixtures/siwc/jwks.json")) + .unwrap(), + revoke: StatusCode::OK, + redirect: None, + slow_stream: false, + })); + let app = Router::new() + .fallback(any(mock_handler)) + .with_state(data.clone()); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + Self { + data, + endpoints, + task, + } + } + fn token(&self, status: StatusCode, body: Value) { + self.data.lock().unwrap().tokens.push_back((status, body)); + } + fn reply(&self, status: StatusCode, body: String) { + self.data + .lock() + .unwrap() + .responses + .push_back((status, body)); + } + fn count(&self, path: &str) -> usize { + self.data + .lock() + .unwrap() + .requests + .iter() + .filter(|r| r.path == path) + .count() + } +} +fn endpoints(base: &str) -> Endpoints { + Endpoints { + authorize: format!("{base}/authorize"), + token: format!("{base}/token"), + discovery: format!("{base}/discovery"), + jwks: format!("{base}/jwks"), + revoke: format!("{base}/revoke"), + api: format!("{base}/v1"), + } +} +async fn mock_handler( + State(data): State>>, + req: Request, +) -> axum::response::Response { + let path = req.uri().path().to_string(); + let headers = req.headers().clone(); + let body = axum::body::to_bytes(req.into_body(), 4 * 1024 * 1024) + .await + .unwrap() + .to_vec(); + let mut data = data.lock().unwrap(); + data.requests.push(Recorded { + path: path.clone(), + headers, + body, + }); + match path.as_str() { + "/discovery" => Json(json!({"issuer":ISSUER,"jwks_uri":format!("{}/jwks",data.base),"token_endpoint":format!("{}/token",data.base),"revocation_endpoint":format!("{}/revoke",data.base)})).into_response(), + "/jwks" => Json(data.jwks.clone()).into_response(), + "/token" => { let (status, body) = data.tokens.pop_front().expect("unexpected token request"); (status, Json(body)).into_response() }, + "/revoke" => data.revoke.into_response(), + "/v1/models" => Json(data.models.clone()).into_response(), + "/v1/responses" => { + if let Some(url) = &data.redirect { return (StatusCode::TEMPORARY_REDIRECT, [("location",url.clone())]).into_response(); } + let (status, body) = data.responses.pop_front().expect("unexpected inference request"); + if data.slow_stream && status.is_success() { + let cut = body.rfind("data:").unwrap_or(0); + let chunks = [body[..cut].to_string(), body[cut..].to_string()]; + let stream = futures_util::stream::unfold((0, chunks), |(index, chunks)| async move { + if index == 2 { return None; } + if index == 1 { tokio::time::sleep(std::time::Duration::from_millis(800)).await; } + Some((Ok::<_,std::io::Error>(bytes::Bytes::from(chunks[index].clone())), (index+1,chunks))) + }); + return (status, [("content-type","text/event-stream"),("x-request-id","req_siwc_mock")], Body::from_stream(stream)).into_response(); + } + (status, [("content-type",if status.is_success() {"text/event-stream"} else {"application/json"}), ("x-request-id","req_siwc_mock")], body).into_response() + } + _ => StatusCode::NOT_FOUND.into_response(), + } +} +fn claims(nonce: &str) -> Value { + json!({"iss":ISSUER,"sub":"test-user","aud":"oaiapp_test","exp":now()+3600,"iat":now(),"nonce":nonce,"email":"test@example.invalid"}) +} +fn sign(claims: Value) -> String { + let mut header = Header::new(Algorithm::RS256); + header.kid = Some("siwc-test-key".into()); + encode( + &header, + &claims, + &EncodingKey::from_rsa_der(include_bytes!("../../../tests/fixtures/siwc/test-rsa.der")), + ) + .unwrap() +} +fn token(nonce: &str) -> Value { + json!({"access_token":"mock-access","refresh_token":"mock-refresh","id_token":sign(claims(nonce)),"token_type":"Bearer","expires_in":3600,"scope":"openid profile email offline_access resource.invoke chatgpt.tokens.use.direct"}) +} +async fn pending(auth: &Auth) -> Attempt { + let locked = auth.store.lock().await.unwrap(); + Attempt::new( + "http://127.0.0.1:1455/auth/callback".into(), + locked.state.ext_agent_host_id.clone(), + locked.state.profiles.get(&auth.profile).cloned(), + ) +} +fn callback(attempt: &Attempt) -> HashMap { + HashMap::from([ + ("state".into(), attempt.state.clone()), + ("code".into(), "mock-code".into()), + ("client_id".into(), "oaiapp_test".into()), + ]) +} +async fn signed_in(mock: &Mock, store: Store) -> Auth { + let auth = Auth::for_test(store, mock.endpoints.clone()); + let mut attempt = pending(&auth).await; + let code = attempt.callback(&callback(&attempt)).unwrap(); + mock.token(StatusCode::OK, token(&attempt.nonce)); + assert!(auth.exchange(&attempt, &code).await.unwrap()); + auth +} +async fn expire(auth: &Auth) { + let mut locked = auth.store.lock().await.unwrap(); + locked + .state + .profiles + .get_mut(&auth.profile) + .unwrap() + .tokens + .as_mut() + .unwrap() + .expires_at = 0; + locked.save().unwrap(); +} +fn request(stream: bool) -> MessagesRequest { + serde_json::from_value(json!({"model":"gpt-test","stream":stream,"max_tokens":100,"messages":[{"role":"user","content":"hello"}]})).unwrap() +} +fn event(v: Value) -> String { + format!("data: {v}\n\n") +} +fn text_events() -> String { + [json!({"type":"response.created","response":{"id":"resp_test"}}), json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"item_text","role":"assistant","content":[]}}), json!({"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"你好"}), json!({"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"item_text","content":[{"type":"output_text","text":"你好"}]}}), json!({"type":"response.completed","response":{"id":"resp_test","status":"completed","output":[],"usage":{"input_tokens":12,"output_tokens":3}}})].into_iter().map(event).collect() +} +fn tool_events() -> String { + [json!({"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"Bash","arguments":""}}), json!({"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"command\":\"pwd\"}"}), json!({"type":"response.function_call_arguments.done","output_index":0,"item_id":"fc_1","arguments":"{\"command\":\"pwd\"}"}), json!({"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"Bash","arguments":"{\"command\":\"pwd\"}"}}), json!({"type":"response.completed","response":{"status":"completed","output":[],"usage":{"input_tokens":10,"output_tokens":5}}})].into_iter().map(event).collect() +} +async fn body(response: axum::response::Response) -> String { + String::from_utf8( + axum::body::to_bytes(response.into_body(), 10 * 1024 * 1024) + .await + .unwrap() + .to_vec(), + ) + .unwrap() +} +fn context() -> RequestContext { + RequestContext { + req_id: "test".into(), + session_id: None, + session_seq: None, + provider: "siwc".into(), + traffic: None, + monitor: None, + } +} +fn provider(auth: Auth) -> SiwcProvider { + SiwcProvider { + client: Client::new(auth), + default_model: Some("gpt-test".into()), + } +} + +#[tokio::test] +async fn dynamic_registration_pkce_and_persistent_host() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = Auth::for_test(Store::new(dir.path().join("siwc")), mock.endpoints.clone()); + let attempt = pending(&auth).await; + let other = pending(&auth).await; + assert_ne!(attempt.state, other.state); + assert_ne!(attempt.nonce, other.nonce); + assert_ne!(attempt.verifier, other.verifier); + assert_eq!(attempt.host_id, other.host_id); + let url = attempt.authorize_url(&mock.endpoints, false, true).unwrap(); + let q: HashMap<_, _> = url.query_pairs().into_owned().collect(); + assert_eq!(q["client_id"], "dynamic_agent_client"); + assert_eq!(q["resource"], RESOURCE); + assert_eq!(q["agent_name_hint"], "claude-code-proxy"); + assert_eq!(q["redirect_uri"], attempt.redirect_uri); + use sha2::Digest; + assert_eq!( + q["code_challenge"], + base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(sha2::Sha256::digest(attempt.verifier.as_bytes())) + ); + assert_eq!(q["code_challenge_method"], "S256"); + assert!(q["scope"].contains("chatgpt.tokens.use.direct")); +} + +#[tokio::test] +async fn state_client_id_and_cancel_validation() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = Auth::for_test(Store::new(dir.path().join("siwc")), mock.endpoints.clone()); + let mut a = pending(&auth).await; + let mut q = callback(&a); + q.insert("state".into(), "wrong".into()); + assert!(a.callback(&q).is_err()); + let mut q = callback(&a); + q.insert("error".into(), "access_denied".into()); + assert!(a.callback(&q).is_err()); + let mut q = callback(&a); + q.remove("client_id"); + assert!(a.callback(&q).is_err()); + let mut q = callback(&a); + q.insert("client_id".into(), "dynamic_agent_client".into()); + assert!(a.callback(&q).is_err()); + assert_eq!(mock.count("/token"), 0); +} + +#[tokio::test] +async fn callback_http_rejects_unrelated_requests_and_wrong_state() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let task = tokio::spawn(auth::wait_callback(listener, "expected".into())); + let client = reqwest::Client::new(); + assert_eq!( + client + .get(format!("http://{addr}/other?state=expected")) + .send() + .await + .unwrap() + .status(), + 404 + ); + assert_eq!( + client + .get(format!("http://{addr}/auth/callback?state=wrong&code=bad")) + .send() + .await + .unwrap() + .status(), + 400 + ); + let result = client + .get(format!( + "http://{addr}/auth/callback?state=expected&code=good&client_id=oaiapp_test" + )) + .send() + .await + .unwrap(); + assert_eq!(result.status(), 200); + assert_eq!(result.headers()["referrer-policy"], "no-referrer"); + assert_eq!(task.await.unwrap().unwrap()["code"], "good"); +} + +#[tokio::test] +async fn validates_signature_nonce_issuer_audience_expiry_and_subject() { + for variant in [ + "nonce", + "issuer", + "audience", + "expired", + "future_iat", + "empty_sub", + "signature", + "unknown_key", + "azp", + ] { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = Auth::for_test(Store::new(dir.path().join("siwc")), mock.endpoints.clone()); + let mut a = pending(&auth).await; + let code = a.callback(&callback(&a)).unwrap(); + let mut c = claims(&a.nonce); + match variant { + "nonce" => c["nonce"] = json!("wrong"), + "issuer" => c["iss"] = json!("https://evil.invalid"), + "audience" => c["aud"] = json!("another-client"), + "expired" => { + c["exp"] = json!(now() - 100); + c["iat"] = json!(now() - 200) + } + "future_iat" => c["iat"] = json!(now() + 1000), + "empty_sub" => c["sub"] = json!(""), + "azp" => c["azp"] = json!("other"), + _ => {} + } + let mut t = token(&a.nonce); + t["id_token"] = json!(sign(c)); + if variant == "signature" { + let s = t["id_token"].as_str().unwrap(); + let mut bytes = s.as_bytes().to_vec(); + let start = s.rfind('.').unwrap() + 1; + bytes[start] = if bytes[start] == b'A' { b'B' } else { b'A' }; + t["id_token"] = json!(String::from_utf8(bytes).unwrap()); + } + if variant == "unknown_key" { + mock.data.lock().unwrap().jwks["keys"][0]["kid"] = json!("other-key"); + } + mock.token(StatusCode::OK, t); + assert!(auth.exchange(&a, &code).await.is_err(), "{variant}"); + assert!(auth.store.lock().await.unwrap().state.profiles.is_empty()); + } +} + +#[tokio::test] +async fn successful_registration_and_returning_identity_isolation() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let mut a = pending(&auth).await; + let q: HashMap<_, _> = a + .authorize_url(&mock.endpoints, false, true) + .unwrap() + .query_pairs() + .into_owned() + .collect(); + assert_eq!(q["client_id"], "oaiapp_test"); + assert!(!q.contains_key("agent_name_hint")); + assert!(q.contains_key("id_token_hint")); + let mut wrong = callback(&a); + wrong.insert("client_id".into(), "oaiapp_other".into()); + assert!(a.callback(&wrong).is_err()); + let mut valid = callback(&a); + valid.remove("client_id"); + let code = a.callback(&valid).unwrap(); + let mut t = token(&a.nonce); + let mut c = claims(&a.nonce); + c["sub"] = json!("different-user"); + t["id_token"] = json!(sign(c)); + mock.token(StatusCode::OK, t); + assert!(auth.exchange(&a, &code).await.is_err()); + assert_eq!(auth.access_token().await.unwrap(), "mock-access"); + let status = auth.status().await.unwrap().to_string(); + assert!(!status.contains("mock-access")); + assert!(!status.contains("mock-refresh")); + let requests = &mock.data.lock().unwrap().requests; + let form: HashMap<_, _> = + url::form_urlencoded::parse(&requests.iter().find(|r| r.path == "/token").unwrap().body) + .into_owned() + .collect(); + assert_eq!(form["client_id"], "oaiapp_test"); + assert_eq!(form["resource"], RESOURCE); + assert!(!form.contains_key("client_secret")); +} + +#[tokio::test] +async fn missing_scope_saves_identity_but_blocks_inference() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = Auth::for_test(Store::new(dir.path().join("siwc")), mock.endpoints.clone()); + let mut a = pending(&auth).await; + let code = a.callback(&callback(&a)).unwrap(); + let mut t = token(&a.nonce); + t["scope"] = json!("openid profile email"); + mock.token(StatusCode::OK, t); + assert!(!auth.exchange(&a, &code).await.unwrap()); + assert!(auth.access_token().await.is_err()); + assert_eq!(mock.count("/v1/responses"), 0); + assert_eq!( + auth.status().await.unwrap()["profiles"][0]["plan_permission"], + false + ); +} + +#[tokio::test] +async fn concurrent_refresh_rotates_once_and_restart_reads_new_tokens() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let store = Store::new(dir.path().join("siwc")); + let auth = signed_in(&mock, store.clone()).await; + expire(&auth).await; + let mut t = token("unused"); + t.as_object_mut().unwrap().remove("id_token"); + t.as_object_mut().unwrap().remove("scope"); + t["access_token"] = json!("rotated-access"); + t["refresh_token"] = json!("rotated-refresh"); + mock.token(StatusCode::OK, t); + let other = Auth::for_test(store.clone(), mock.endpoints.clone()); + let (a, b) = tokio::join!(auth.access_token(), other.access_token()); + assert_eq!(a.unwrap(), "rotated-access"); + assert_eq!(b.unwrap(), "rotated-access"); + assert_eq!(mock.count("/token"), 2); + let restarted = Auth::for_test(store, mock.endpoints.clone()); + assert_eq!(restarted.access_token().await.unwrap(), "rotated-access"); + let locked = restarted.store.lock().await.unwrap(); + assert_eq!( + locked.state.profiles["default"] + .tokens + .as_ref() + .unwrap() + .refresh_token, + "rotated-refresh" + ); +} + +#[tokio::test] +async fn refresh_terminal_errors_clear_tokens_and_keep_registration() { + for code in [ + "invalid_grant", + "invalid_refresh_token", + "token_expired", + "refresh_token_expired", + "refresh_token_invalidated", + "refresh_token_reused", + ] { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + expire(&auth).await; + mock.token(StatusCode::BAD_REQUEST, json!({"error":code})); + assert!(auth.access_token().await.is_err()); + let locked = auth.store.lock().await.unwrap(); + let p = &locked.state.profiles["default"]; + assert!(p.tokens.is_none()); + assert_eq!(p.client_id, "oaiapp_test"); + } +} + +#[tokio::test] +async fn uncertain_refresh_is_not_reused_and_temporary_error_retains_credentials() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + expire(&auth).await; + mock.token( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error":"temporarily_unavailable"}), + ); + assert!(auth.access_token().await.is_err()); + { + let mut locked = auth.store.lock().await.unwrap(); + let p = locked.state.profiles.get_mut("default").unwrap(); + assert!(p.tokens.is_some()); + assert!(!p.refresh_pending); + p.refresh_pending = true; + locked.save().unwrap(); + } + assert!( + auth.access_token() + .await + .unwrap_err() + .to_string() + .contains("uncertain") + ); + assert_eq!(mock.count("/token"), 2); +} + +#[tokio::test] +async fn logout_revokes_and_preserves_host_and_registration() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let before = pending(&auth).await.host_id; + assert!(auth.logout().await.unwrap()); + assert!(auth.access_token().await.is_err()); + let after = pending(&auth).await; + assert_eq!(before, after.host_id); + assert_eq!(after.selected.unwrap().client_id, "oaiapp_test"); + let data = mock.data.lock().unwrap(); + let r = data.requests.iter().find(|r| r.path == "/revoke").unwrap(); + let form: HashMap<_, _> = url::form_urlencoded::parse(&r.body).into_owned().collect(); + assert_eq!(form["token_type_hint"], "refresh_token"); + assert_eq!(form["client_id"], "oaiapp_test"); +} + +#[tokio::test] +async fn failed_revocation_still_clears_local_tokens() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + mock.data.lock().unwrap().revoke = StatusCode::SERVICE_UNAVAILABLE; + assert!(!auth.logout().await.unwrap()); + assert_eq!(mock.count("/revoke"), 3); + assert!(auth.access_token().await.is_err()); +} + +#[tokio::test] +async fn discovery_and_outbound_requests_use_only_allowed_headers_and_paths() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let client = Client::new(auth); + let models = client.models().await.unwrap(); + assert_eq!(models.len(), 1); + assert_eq!(models[0]["display_name"], "Test GPT"); + mock.reply(StatusCode::OK, text_events()); + let p = provider(client.auth.clone()); + let result = p.handle_messages(request(false), context()).await; + assert_eq!(result.status(), 200); + assert!(body(result).await.contains("你好")); + for r in &mock.data.lock().unwrap().requests { + assert!(!r.path.contains("backend-api")); + if r.path.starts_with("/v1/") { + assert_eq!(r.headers["authorization"], "Bearer mock-access"); + assert!(!r.headers.contains_key("originator")); + assert!(!r.headers.contains_key("chatgpt-account-id")); + assert!(!r.headers.contains_key("openai-beta")); + assert!( + r.headers["user-agent"] + .to_str() + .unwrap() + .starts_with("claude-code-proxy/") + ); + } + } +} + +#[test] +fn request_whitelist_and_additional_tools_preserve_full_history() { + let mut req = request(true); + req.extra.insert("temperature".into(), json!(0.9)); + req.extra + .insert("metadata".into(), json!({"user_id":"secret"})); + req.extra.insert("top_p".into(), json!(0.4)); + req.extra.insert("system".into(), json!("developer rules")); + req.extra.insert("tools".into(),json!([{"name":"Bash","description":"run local command","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}])); + req.messages.extend(serde_json::from_value::>(json!([{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"Bash","input":{"command":"pwd"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"/test"}]},{"role":"developer","content":"more rules"}])).unwrap()); + let prepared = translate::prepare(&req, "gpt-test".into()).unwrap(); + let wire = prepared.body; + assert_eq!(wire["store"], false); + assert_eq!(wire["stream"], true); + assert_eq!(wire["input"][0]["type"], "additional_tools"); + assert_eq!(wire["input"][0]["role"], "developer"); + for field in [ + "max_tokens", + "max_output_tokens", + "temperature", + "top_p", + "metadata", + "client_metadata", + "previous_response_id", + "service_tier", + "tools", + ] { + assert!(wire.get(field).is_none(), "{field}"); + } + assert_eq!(wire["instructions"], "developer rules"); + assert_eq!(wire["input"][2]["call_id"], wire["input"][3]["call_id"]); + assert_eq!(wire["input"][4]["role"], "developer"); + assert_eq!( + wire, + translate::prepare(&req, "gpt-test".into()).unwrap().body + ); +} + +#[test] +fn rejects_forbidden_parameters_hosted_tools_and_broken_tool_history() { + for field in [ + "max_output_tokens", + "background", + "conversation", + "previous_response_id", + "client_metadata", + "tool_search", + "service_tier", + ] { + let mut req = request(true); + if field == "tool_search" { + req.extra.insert( + "tools".into(), + json!([{"type":"tool_search","name":"tool_search"}]), + ); + } else { + req.extra.insert(field.into(), json!("x")); + } + assert!( + translate::prepare(&req, "gpt-test".into()).is_err(), + "{field}" + ); + } + for kind in [ + "mcp", + "image_generation", + "file_search", + "code_interpreter", + "computer_use", + "programmatic_tool_calling", + ] { + let mut req = request(true); + req.extra.insert( + "tools".into(), + json!([{"type":kind,"name":"hosted","input_schema":{}}]), + ); + assert!(translate::prepare(&req, "gpt-test".into()).is_err()); + } + for messages in [ + json!([{"role":"user","content":[{"type":"tool_result","tool_use_id":"missing","content":"x"}]}]), + json!([{"role":"assistant","content":[{"type":"tool_use","id":"call","name":"Bash","input":{}}]}]), + json!([{"role":"user","content":[{"type":"document","source":{}}]}]), + ] { + let mut req = request(true); + req.messages = serde_json::from_value(messages).unwrap(); + assert!(translate::prepare(&req, "gpt-test".into()).is_err()); + } +} + +#[tokio::test] +async fn streaming_text_and_tool_calls_produce_valid_anthropic_events() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.reply(StatusCode::OK, text_events()); + let text = body(p.handle_messages(request(true), context()).await).await; + assert!(text.contains("你好")); + assert!(text.contains("message_stop")); + mock.reply(StatusCode::OK, tool_events()); + let mut req = request(true); + req.extra.insert( + "tools".into(), + json!([{"name":"Bash","input_schema":{"type":"object"}}]), + ); + let text = body(p.handle_messages(req, context()).await).await; + assert!(text.contains("call_1")); + assert!(text.contains("tool_use")); + assert!(text.contains("message_stop")); + assert!(!text.contains("siwc_stream_error"), "{text}"); +} + +#[tokio::test] +async fn http_errors_preserve_status_shape_codes_and_request_id_without_retrying_limits() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let client = Client::new(auth); + for (status, error) in [ + (401, json!({"detail":"identity refused"})), + ( + 403, + json!({"error":{"code":"subscription_sharing_user_not_eligible","message":"not eligible"}}), + ), + ( + 404, + json!({"error":{"code":"model_not_found","message":"model unavailable","param":"model"}}), + ), + ( + 429, + json!({"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"app limit"}}), + ), + ] { + mock.reply(StatusCode::from_u16(status).unwrap(), error.to_string()); + let e = client.responses(&json!({})).await.unwrap_err(); + assert_eq!(e.status.as_u16(), status); + assert_eq!(e.body, error); + assert_eq!(e.request_id.as_deref(), Some("req_siwc_mock")); + } + assert_eq!(mock.count("/v1/responses"), 4); + assert_eq!(mock.count("/token"), 1); +} + +#[tokio::test] +async fn unavailable_route_retries_at_most_three_times_and_redirects_are_blocked() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let client = Client::new(auth); + for _ in 0..3 { + mock.reply( + StatusCode::SERVICE_UNAVAILABLE, + json!({"detail":"route unavailable"}).to_string(), + ); + } + assert_eq!(client.responses(&json!({})).await.unwrap_err().status, 503); + assert_eq!(mock.count("/v1/responses"), 3); + let other = Mock::new().await; + mock.data.lock().unwrap().redirect = + Some(format!("{}/v1/responses", other.data.lock().unwrap().base)); + assert_eq!(client.responses(&json!({})).await.unwrap_err().status, 307); + assert_eq!(other.count("/v1/responses"), 0); +} + +#[tokio::test] +async fn failed_incomplete_and_disconnected_streams_never_emit_success() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + for ending in ["", "data: [DONE]\n\n", "data: broken\n\n"] { + mock.reply( + StatusCode::OK, + format!( + "{}{}", + event( + json!({"type":"response.output_text.delta","output_index":0,"delta":"partial"}) + ), + ending + ), + ); + let text = body(p.handle_messages(request(true), context()).await).await; + assert!(text.contains("event: error")); + assert!(!text.contains("message_stop")); + } + for kind in ["response.failed", "response.incomplete"] { + *p.client.usage_pause.lock().unwrap() = None; + mock.reply(StatusCode::OK,event(json!({"type":kind,"response":{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"limit reached"}}}))); + let text = body(p.handle_messages(request(true), context()).await).await; + assert!(text.contains("subscription_sharing_usage_limit_exceeded")); + assert!(!text.contains("message_stop")); + } + *p.client.usage_pause.lock().unwrap() = None; + mock.reply(StatusCode::OK, String::new()); + assert_eq!( + p.handle_messages(request(false), context()).await.status(), + 502 + ); +} + +#[tokio::test] +async fn output_tool_identity_mismatch_is_an_error() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + let events = tool_events().replacen("\"item_id\":\"fc_1\"", "\"item_id\":\"wrong\"", 1); + mock.reply(StatusCode::OK, events); + let mut req = request(true); + req.extra.insert( + "tools".into(), + json!([{"name":"Bash","input_schema":{"type":"object"}}]), + ); + let result = body(p.handle_messages(req, context()).await).await; + assert!(result.contains("siwc_stream_error")); + assert!(!result.contains("message_stop")); +} + +#[test] +fn sse_decoder_handles_utf8_fragmentation_crlf_and_multiline_data() { + let bytes="event: response.output_text.delta\r\ndata: {\"type\":\"response.output_text.delta\",\r\ndata: \"delta\":\"你好\"}\r\n\r\n".as_bytes(); + let mut decoder = stream::Decoder::default(); + for b in bytes { + decoder.push(&[*b]).unwrap(); + } + assert_eq!(decoder.events.pop_front().unwrap()["delta"], "你好"); +} + +#[tokio::test] +async fn isolated_router_discovers_models_disables_native_routes_and_replays_after_restart() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let store = Store::new(dir.path().join("siwc")); + let auth = signed_in(&mock, store.clone()).await; + let make_app = |auth| { + let registry = crate::Registry::from_providers( + crate::config::AliasProvider::Siwc, + [Arc::new(provider(auth)) as Arc], + ); + crate::server::app_with_features( + Arc::new(registry), + None, + crate::server::AppFeatures { + responses_api: true, + images_api: true, + transcriptions_api: true, + }, + ) + }; + let app = make_app(auth); + let result = app + .clone() + .oneshot( + Request::builder() + .uri("/v1/models") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(result.status(), 200); + assert!(body(result).await.contains("Test GPT")); + for path in [ + "/v1/responses", + "/v1/chat/completions", + "/v1/images/generations", + "/v1/audio/transcriptions", + ] { + let result = app + .clone() + .oneshot( + Request::builder() + .uri(path) + .method("POST") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(result.status(), 404); + } + let mut req = request(false); + req.messages.extend(serde_json::from_value::>(json!([{"role":"assistant","content":"earlier answer"},{"role":"user","content":"continue resumed conversation"}])).unwrap()); + for app in [app, make_app(Auth::for_test(store, mock.endpoints.clone()))] { + mock.reply(StatusCode::OK, text_events()); + let result = app + .oneshot( + Request::builder() + .uri("/v1/messages") + .method("POST") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&req).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(result.status(), 200); + assert!(body(result).await.contains("你好")); + } + let data = mock.data.lock().unwrap(); + let bodies: Vec = data + .requests + .iter() + .filter(|r| r.path == "/v1/responses") + .map(|r| serde_json::from_slice(&r.body).unwrap()) + .collect(); + assert_eq!(bodies[0], bodies[1]); + assert_eq!(bodies[0]["input"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn isolated_router_rejects_browser_origin_and_dns_rebinding() { + let registry = crate::Registry::new(crate::config::AliasProvider::Siwc); + assert_eq!(registry.list_provider_names(), vec!["siwc"]); + assert_eq!( + registry + .provider_for_model("gpt-future", None) + .unwrap() + .name(), + "siwc" + ); + let app = crate::server::app(Arc::new(registry)); + for (key, value) in [ + ("origin", "https://evil.invalid"), + ("host", "evil.invalid:18765"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/healthz") + .header(key, value) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), 403); + } +} + +#[cfg(unix)] +#[tokio::test] +async fn credentials_are_private_atomic_and_reject_symlinks() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("siwc"); + let store = Store::new(root.clone()); + let locked = store.lock().await.unwrap(); + drop(locked); + assert_eq!( + std::fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(root.join("state.json")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + let target = dir.path().join("target"); + std::fs::write(&target, "sensitive").unwrap(); + std::fs::remove_file(root.join("state.json")).unwrap(); + symlink(&target, root.join("state.json")).unwrap(); + assert!(store.lock().await.is_err()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "sensitive"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn refresh_is_serialized_across_os_processes() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("siwc"); + let auth = signed_in(&mock, Store::new(root.clone())).await; + expire(&auth).await; + let mut t = token("unused"); + t.as_object_mut().unwrap().remove("id_token"); + t["access_token"] = json!("process-rotated"); + t["refresh_token"] = json!("process-refresh"); + mock.token(StatusCode::OK, t); + let mut children = vec![]; + for _ in 0..2 { + children.push( + std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "providers::chatgpt_siwc::tests::refresh_child", + "--nocapture", + ]) + .env("SIWC_TEST_CHILD_ROOT", &root) + .env("SIWC_TEST_CHILD_URL", &mock.data.lock().unwrap().base) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(), + ); + } + for child in children { + let out = tokio::task::spawn_blocking(move || child.wait_with_output().unwrap()) + .await + .unwrap(); + assert!( + out.status.success(), + "{} {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + } + assert_eq!(mock.count("/token"), 2); // One login plus one rotation across both processes. +} + +#[tokio::test] +async fn refresh_child() { + let Ok(root) = std::env::var("SIWC_TEST_CHILD_ROOT") else { + return; + }; + let base = std::env::var("SIWC_TEST_CHILD_URL").unwrap(); + assert!(base.starts_with("http://127.0.0.1:")); + let auth = Auth::for_test(Store::new(root.into()), endpoints(&base)); + assert_eq!(auth.access_token().await.unwrap(), "process-rotated"); +} + +#[tokio::test] +async fn profiles_with_the_same_email_keep_distinct_client_ids_and_tokens() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let first = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let mut second = first.clone(); + second.profile = "second".into(); + let mut a = pending(&second).await; + let mut q = callback(&a); + q.insert("client_id".into(), "oaiapp_second".into()); + let code = a.callback(&q).unwrap(); + let mut t = token(&a.nonce); + let mut c = claims(&a.nonce); + c["aud"] = json!("oaiapp_second"); + t["id_token"] = json!(sign(c)); + t["access_token"] = json!("second-access"); + mock.token(StatusCode::OK, t); + second.exchange(&a, &code).await.unwrap(); + assert_eq!(first.access_token().await.unwrap(), "mock-access"); + assert_eq!(second.access_token().await.unwrap(), "second-access"); + assert!(second.logout().await.unwrap()); + assert_eq!(first.access_token().await.unwrap(), "mock-access"); +} + +#[tokio::test] +async fn quota_failure_pauses_new_inference_and_logout_stops_streams() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth.clone()); + mock.reply(StatusCode::OK,event(json!({"type":"response.failed","response":{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"app limit"}}}))); + let text = body(p.handle_messages(request(true), context()).await).await; + assert!(text.contains("rate_limit_error")); + assert_eq!( + p.handle_messages(request(true), context()).await.status(), + 429 + ); + assert_eq!(mock.count("/v1/responses"), 1); + let p = provider(auth.clone()); + mock.reply(StatusCode::OK, text_events()); + let response = p.handle_messages(request(true), context()).await; + auth.logout().await.unwrap(); + let text = body(response).await; + assert!(text.contains("signed out")); + assert!(!text.contains("message_stop")); +} + +#[tokio::test] +async fn refresh_scope_reduction_blocks_requests_and_earliest_refresh_is_respected() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + { + let mut locked = auth.store.lock().await.unwrap(); + let t = locked + .state + .profiles + .get_mut("default") + .unwrap() + .tokens + .as_mut() + .unwrap(); + t.expires_at = now() + 30; + t.earliest_refresh_at = Some(now() + 20); + locked.save().unwrap(); + } + assert_eq!(auth.access_token().await.unwrap(), "mock-access"); + assert_eq!(mock.count("/token"), 1); + { + let mut locked = auth.store.lock().await.unwrap(); + let t = locked + .state + .profiles + .get_mut("default") + .unwrap() + .tokens + .as_mut() + .unwrap(); + t.expires_at = 0; + t.earliest_refresh_at = None; + locked.save().unwrap(); + } + let mut t = token("unused"); + t.as_object_mut().unwrap().remove("id_token"); + t["scope"] = json!("openid profile email"); + mock.token(StatusCode::OK, t); + assert!(auth.access_token().await.is_err()); + assert!(auth.access_token().await.is_err()); + assert_eq!(mock.count("/token"), 2); +} + +#[test] +fn production_endpoints_are_fixed_official_urls() { + let e = Endpoints::default(); + assert_eq!(e.api, "https://api.openai.com/v1"); + assert_eq!( + e.authorize, + "https://auth.openai.com/api/accounts/authorize" + ); + assert_eq!(e.token, "https://auth.openai.com/api/accounts/oauth/token"); + let registry = crate::Registry::new(crate::config::AliasProvider::Siwc); + for model in [ + "gpt-test", + "siwc/gpt-test", + "claude-sonnet-4-6", + "unknown-model", + ] { + assert_eq!( + registry.provider_for_model(model, None).unwrap().name(), + "siwc" + ); + } + assert!(registry.provider("codex").is_none()); +} + +#[test] +fn local_tools_errors_compaction_and_reasoning_history_are_handled_explicitly() { + let mut req = request(false); + req.extra.insert( + "system".into(), + json!("You are a helpful AI assistant tasked with summarizing conversations"), + ); + req.extra.insert("tools".into(),json!([{"name":"Read","input_schema":{"type":"object"}},{"name":"Write","input_schema":{"type":"object"}},{"name":"mcp__local__lookup","input_schema":{"type":"object"},"defer_loading":true}])); + req.messages=serde_json::from_value(json!([{"role":"assistant","content":[{"type":"thinking","thinking":"private","signature":"opaque-old-provider-signature"},{"type":"tool_use","id":"id_1","name":"Read","input":{"file_path":"test.txt"}},{"type":"tool_use","id":"id_2","name":"Write","input":{"file_path":"out.txt","content":"ok"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"id_1","is_error":true,"content":"file not found"},{"type":"tool_result","tool_use_id":"id_2","content":"written"}]}])).unwrap(); + let result = translate::prepare(&req, "gpt-test".into()).unwrap(); + assert_eq!( + result.body["input"][0]["tools"].as_array().unwrap().len(), + 3 + ); + let serialized = result.body.to_string(); + assert!(serialized.contains("[tool execution error]")); + assert!(!serialized.contains("opaque-old-provider-signature")); + assert!(!serialized.contains("compaction_trigger")); + assert!(result.estimated_tokens > 0); +} + +#[tokio::test] +async fn live_output_arrives_before_upstream_terminal_event() { + use http_body_util::BodyExt; + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.data.lock().unwrap().slow_stream = true; + mock.reply(StatusCode::OK, text_events()); + let response = p.handle_messages(request(true), context()).await; + let mut response = response.into_body(); + let partial = tokio::time::timeout(std::time::Duration::from_millis(400), async { + let mut text = String::new(); + loop { + let frame = response.frame().await.unwrap().unwrap(); + if let Some(bytes) = frame.data_ref() { + text.push_str(&String::from_utf8_lossy(bytes)); + } + if text.contains("你好") { + break text; + } + } + }) + .await + .expect("text should not wait for response.completed"); + assert!(!partial.contains("message_stop")); + let rest = response.collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&rest).contains("message_stop")); +} + +#[tokio::test] +async fn top_level_error_event_preserves_code_param_and_request_id() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.reply(StatusCode::OK,event(json!({"type":"error","code":"subscription_sharing_unsupported_capability","param":"tools","message":"unsupported"}))); + let response = p.handle_messages(request(false), context()).await; + assert_eq!(response.status(), 400); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(error["error"]["param"], "tools"); + assert_eq!(error["upstream_request_id"], "req_siwc_mock"); +} + +#[tokio::test] +async fn stalled_read_never_finishes_before_a_late_usage_error() { + for stream in [true, false] { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + let mut req = request(stream); + req.extra.insert( + "tools".into(), + json!([{"name":"Read","input_schema":{"type":"object"}}]), + ); + let events = [ + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_read","call_id":"call_read","name":"Read","arguments":""}}), + json!({"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_read","delta":format!("{{\"file_path\":\"test.txt\",\"pages\":\"\"{}", " ".repeat(1024))}), + json!({"type":"response.failed","response":{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"plan limit"}}}), + ].into_iter().map(event).collect(); + mock.reply(StatusCode::OK, events); + let response = p.handle_messages(req.clone(), context()).await; + assert_eq!(response.status(), if stream { 200 } else { 429 }); + let text = body(response).await; + assert!(text.contains("subscription_sharing_usage_limit_exceeded")); + assert!(!text.contains("message_stop")); + assert!(!text.contains("input_json_delta")); + assert_eq!(p.handle_messages(req, context()).await.status(), 429); + assert_eq!(mock.count("/v1/responses"), 1); + } +} + +#[tokio::test] +async fn terminal_tool_call_cannot_change_completed_arguments() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + for stream in [true, false] { + let mut req = request(stream); + req.extra.insert( + "tools".into(), + json!([{"name":"Bash","input_schema":{"type":"object"}}]), + ); + let mut events = tool_events(); + events.truncate(events.rfind("data:").unwrap()); + events.push_str(&event(json!({"type":"response.completed","response":{"status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"Bash","arguments":"{\"command\":\"different\"}"}]}}))); + mock.reply(StatusCode::OK, events); + let response = p.handle_messages(req, context()).await; + assert_eq!(response.status(), if stream { 200 } else { 502 }); + let text = body(response).await; + assert!(text.contains("changed tool call")); + assert!(!text.contains("message_stop")); + } +} diff --git a/src/providers/chatgpt_siwc/translate.rs b/src/providers/chatgpt_siwc/translate.rs new file mode 100644 index 00000000..bc6772fa --- /dev/null +++ b/src/providers/chatgpt_siwc/translate.rs @@ -0,0 +1,279 @@ +use crate::{ + anthropic::schema::MessagesRequest, + providers::codex::translate::request::{ + ResponsesInputItem, translate_openai_compatible_request, + }, +}; +use anyhow::{Result, bail, ensure}; +use serde_json::{Value, json}; +use std::collections::HashSet; + +const FORBIDDEN: &[&str] = &[ + "background", + "conversation", + "max_output_tokens", + "max_tool_calls", + "moderation", + "multi_agent", + "prompt", + "prompt_cache_retention", + "safety_identifier", + "top_logprobs", + "truncation", + "user", + "previous_response_id", + "client_metadata", + "service_tier", + "additional_tools", + "input", + "instructions", + "store", +]; + +pub(super) struct Prepared { + pub body: Value, + pub estimated_tokens: u64, + pub tool_names: HashSet, +} + +pub(super) fn prepare(request: &MessagesRequest, model: String) -> Result { + for key in FORBIDDEN { + ensure!( + !request.extra.contains_key(*key), + "SIWC does not support request field '{key}'" + ); + } + // Anthropic max_tokens, metadata, temperature, top_p, top_k, stop_sequences + // and cache controls are accepted for client compatibility, never forwarded. + let mut request = request.clone(); + let mut tool_names = HashSet::new(); + if let Some(tools) = request.extra.get_mut("tools") { + for tool in tools + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("tools must be an array"))? + { + let kind = tool.get("type").and_then(Value::as_str).unwrap_or("custom"); + ensure!( + matches!( + kind, + "custom" + | "function" + | "bash_20250124" + | "text_editor_20250124" + | "text_editor_20250429" + ), + "SIWC unsupported hosted tool type '{kind}'; use client-executed function tools" + ); + let name = tool.get("name").and_then(Value::as_str).unwrap_or(""); + ensure!( + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')), + "Invalid function tool name" + ); + ensure!( + tool_names.insert(name.to_string()), + "Duplicate function tool name" + ); + ensure!( + tool.get("input_schema").is_some_and(Value::is_object), + "Function tool requires input_schema; implicit hosted tool schemas are unsupported" + ); + ensure!( + tool.get("allowed_callers").is_none_or(|v| v + .as_array() + .is_some_and(|a| a.iter().all(|v| v == "direct"))), + "Programmatic tool calling is unsupported" + ); + // All advertised local tools are supplied together. Claude executes them. + if let Some(tool) = tool.as_object_mut() { + tool.remove("defer_loading"); + } + } + } + if let Some(choice) = request.extra.get("tool_choice") { + let kind = choice + .as_str() + .or_else(|| choice.get("type").and_then(Value::as_str)) + .unwrap_or(""); + ensure!( + matches!(kind, "auto" | "none" | "any" | "required" | "tool"), + "Unsupported tool_choice" + ); + if kind == "tool" { + ensure!( + choice + .get("name") + .and_then(Value::as_str) + .is_some_and(|n| tool_names.contains(n)), + "tool_choice references an unregistered tool" + ); + } + } + validate_content(&mut request)?; + let mut translated = translate_openai_compatible_request(&request, model, None)?; + // Reuse only the pure translation entry point, with no Codex configuration. + // Signed reasoning from another provider is not a portable conversation item. + translated + .input + .retain(|item| !matches!(item, ResponsesInputItem::Reasoning { .. })); + let estimated_tokens = + crate::providers::codex::count_tokens::count_translated_tokens(&translated); + let mut input = serde_json::to_value(&translated.input)? + .as_array() + .cloned() + .unwrap_or_default(); + if let Some(tools) = translated.tools { + input.insert( + 0, + json!({"type":"additional_tools", "role":"developer", "tools":tools}), + ); + } + let mut body = json!({"model":translated.model,"input":input,"store":false,"stream":true,"parallel_tool_calls":translated.parallel_tool_calls}); + if let Some(instructions) = translated.instructions { + body["instructions"] = json!(instructions); + } + if let Some(choice) = translated.tool_choice { + body["tool_choice"] = serde_json::to_value(choice)?; + } + if let Some(reasoning) = translated.reasoning { + body["reasoning"] = serde_json::to_value(reasoning)?; + } + if let Some(format) = translated.text.format { + body["text"] = json!({"format":format}); + } + Ok(Prepared { + body, + estimated_tokens, + tool_names, + }) +} + +fn validate_content(request: &mut MessagesRequest) -> Result<()> { + let mut calls = HashSet::new(); + let mut unresolved = HashSet::new(); + for message in &mut request.messages { + ensure!( + matches!( + message.role.as_str(), + "user" | "assistant" | "system" | "developer" + ), + "Unsupported message role" + ); + if message.role == "developer" { + message.role = "system".into(); + } // Shared converter emits developer. + if message.content.is_string() { + continue; + } + let blocks = message + .content + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("Message content must be text or an array"))?; + for block in blocks { + match block.get("type").and_then(Value::as_str).unwrap_or("") { + "text" => { + ensure!(block["text"].is_string(), "Text block must contain text"); + } + "thinking" | "redacted_thinking" if message.role == "assistant" => { + // Reasoning is not user-visible history. Preserve assistant text and tool calls. + if let Some(object) = block.as_object_mut() { + object.remove("signature"); + } + } + "image" if message.role == "user" => validate_image(block, false)?, + "tool_use" if message.role == "assistant" => { + let id = block["id"] + .as_str() + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Tool call missing ID"))?; + ensure!( + block["name"].as_str().is_some_and(|n| !n.is_empty()) + && block["input"].is_object(), + "Invalid tool call name or arguments" + ); + ensure!( + calls.insert(id.to_string()), + "Duplicate tool call ID in history" + ); + unresolved.insert(id.to_string()); + } + "tool_result" if message.role == "user" => { + let id = block["tool_use_id"].as_str().unwrap_or(""); + ensure!( + unresolved.remove(id), + "Tool result has no matching prior unresolved call ID" + ); + if let Some(content) = block.get("content") { + if let Some(items) = content.as_array() { + for item in items { + match item["type"].as_str() { + Some("text") if item["text"].is_string() => {} + Some("image") => validate_image(item, true)?, + _ => bail!( + "Unsupported tool result content; refusing to drop it" + ), + } + } + } else { + ensure!( + content.is_string(), + "Tool result must contain text or content blocks" + ); + } + } + } + kind => bail!("Unsupported message content type '{kind}'; refusing to drop it"), + } + } + } + ensure!( + unresolved.is_empty(), + "History contains tool calls without results; complete or repair the client conversation before retrying" + ); + if let Some(system) = request.extra.get("system") + && !system.is_string() + { + ensure!( + system.as_array().is_some_and(|blocks| blocks + .iter() + .all(|b| b["type"] == "text" && b["text"].is_string())), + "System instructions must contain only text" + ); + } + Ok(()) +} + +fn validate_image(block: &Value, tool_result: bool) -> Result<()> { + let source = &block["source"]; + match source["type"].as_str() { + Some("base64") => { + use base64::Engine; + ensure!( + matches!( + source["media_type"].as_str(), + Some("image/png" | "image/jpeg" | "image/webp" | "image/gif") + ), + "Unsupported image media type" + ); + ensure!( + source["data"].as_str().is_some_and(|s| !s.is_empty() + && base64::engine::general_purpose::STANDARD.decode(s).is_ok()), + "Invalid base64 image" + ); + } + Some("url") if !tool_result => { + ensure!( + source["url"] + .as_str() + .and_then(|s| url::Url::parse(s).ok()) + .is_some_and(|u| u.scheme() == "https"), + "Image URL must use HTTPS" + ); + } + _ => bail!("Unsupported image source; tool results require base64 images"), + } + Ok(()) +} diff --git a/src/providers/codex/translate/live_stream.rs b/src/providers/codex/translate/live_stream.rs index d8e868f9..fc886029 100644 --- a/src/providers/codex/translate/live_stream.rs +++ b/src/providers/codex/translate/live_stream.rs @@ -71,6 +71,7 @@ pub struct LiveStreamTranslator { // authoritative usage in the terminal message_delta. estimated_input_tokens: u64, incomplete_response_policy: IncompleteResponsePolicy, + read_stall_repair: bool, finished: bool, } @@ -101,6 +102,7 @@ impl LiveStreamTranslator { semantic_output_started: false, estimated_input_tokens, incomplete_response_policy: IncompleteResponsePolicy::Error, + read_stall_repair: true, finished: false, } } @@ -113,6 +115,12 @@ impl LiveStreamTranslator { self } + /// Disable the legacy Read repair that can finish before an upstream terminal event. + pub(crate) fn with_read_stall_repair(mut self, enabled: bool) -> Self { + self.read_stall_repair = enabled; + self + } + pub fn accept( &mut self, payload: &serde_json::Value, @@ -566,8 +574,9 @@ impl LiveStreamTranslator { "Buffered {name} tool arguments exceeded safe limits" )); } - if let Some(repaired) = - repair_whitespace_stalled_read_args(name, args_accum, Some(call_id.as_str())) + if self.read_stall_repair + && let Some(repaired) = + repair_whitespace_stalled_read_args(name, args_accum, Some(call_id.as_str())) { *args_accum = repaired.clone(); *emitted_args = true; diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 8c4f82fe..3f54cc8e 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,3 +1,4 @@ +pub mod chatgpt_siwc; pub mod codex; pub mod cursor; pub mod grok; diff --git a/src/registry.rs b/src/registry.rs index 46960816..ae84cbf3 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -67,6 +67,18 @@ pub struct Registry { impl Registry { pub fn new(alias_provider: AliasProvider) -> Self { + if alias_provider == AliasProvider::Siwc { + return Self::from_providers( + alias_provider, + [Arc::new( + crate::providers::chatgpt_siwc::SiwcProvider::new( + "default".into(), + crate::config::siwc_model(), + ) + .expect("valid default SIWC profile"), + ) as Arc], + ); + } let mut models: BTreeMap> = BTreeMap::new(); models.insert("codex".into(), expand_codex_models()); models.insert( @@ -110,6 +122,10 @@ impl Registry { Self::new(crate::config::alias_provider()) } + pub fn is_siwc(&self) -> bool { + self.handlers.len() == 1 && self.handlers.contains_key("siwc") + } + pub fn from_providers( alias_provider: AliasProvider, providers: impl IntoIterator>, @@ -174,6 +190,9 @@ impl Registry { raw_model: &str, session_affinity: Option<&AliasProvider>, ) -> Option> { + if self.is_siwc() { + return (!raw_model.is_empty()).then(|| self.handlers["siwc"].clone()); + } let normalized = normalize_incoming_model(raw_model); if is_anthropic_alias(&normalized) { let target = session_affinity.unwrap_or(&self.alias_provider); diff --git a/src/server.rs b/src/server.rs index a12db0c8..9a1e975d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -92,7 +92,7 @@ fn apply_auto_review_model( configured_model: Option<&str>, original_provider: &str, ) -> Option { - if count_tokens || !is_claude_auto_review_request(body) { + if count_tokens || original_provider == "siwc" || !is_claude_auto_review_request(body) { return None; } @@ -137,6 +137,11 @@ pub async fn bind_proxy_listener(bind_address: &str, port: u16) -> anyhow::Resul .parse::() .map_err(|err| anyhow::anyhow!("invalid proxy bind address {bind_address:?}: {err}"))?; let addr = std::net::SocketAddr::new(ip, port); + if crate::config::alias_provider() == crate::config::AliasProvider::Siwc + && !ip.to_canonical().is_loopback() + { + anyhow::bail!("SIWC proxy must listen on a loopback address"); + } TcpListener::bind(addr) .await .map_err(|err| anyhow::anyhow!("failed to bind proxy listener on {addr}: {err}")) @@ -148,6 +153,11 @@ pub async fn serve_listener( shutdown: impl Future + Send + 'static, ) -> anyhow::Result<()> { let local_addr = listener.local_addr()?; + if crate::config::alias_provider() == crate::config::AliasProvider::Siwc + && !local_addr.ip().to_canonical().is_loopback() + { + anyhow::bail!("SIWC proxy must listen on a loopback address"); + } let port = local_addr.port(); create_logger("server").info( "server listening", @@ -229,6 +239,12 @@ pub fn app_with_features( monitor: Option, features: AppFeatures, ) -> Router { + let siwc = registry.is_siwc(); + let features = if siwc { + AppFeatures::default() + } else { + features + }; let native_responses = features .responses_api .then(|| Arc::new(CodexNativeBackend::new())); @@ -292,7 +308,50 @@ pub fn app_with_features( } else { router }; - router.fallback(fallback_handler).with_state(state) + let router = router.fallback(fallback_handler).with_state(state); + if siwc { + router.layer(axum::middleware::from_fn(siwc_local_requests)) + } else { + router + } +} + +async fn siwc_local_requests(req: Request, next: axum::middleware::Next) -> Response { + let peer_ok = req + .extensions() + .get::>() + .is_none_or(|peer| peer.0.ip().to_canonical().is_loopback()); + let host_ok = req.headers().get(http::header::HOST).is_none_or(|host| { + host.to_str() + .ok() + .and_then(|host| format!("http://{host}").parse::().ok()) + .is_some_and(|url| matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))) + }); + if !peer_ok || !host_ok || req.headers().contains_key(http::header::ORIGIN) { + return json_error( + StatusCode::FORBIDDEN, + "permission_error", + "SIWC accepts local CLI connections only", + ); + } + if req.method() == http::Method::POST + && !req + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.split(';') + .next() + .is_some_and(|v| v.trim().eq_ignore_ascii_case("application/json")) + }) + { + return json_error( + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "invalid_request_error", + "SIWC requests require application/json", + ); + } + next.run(req).await } #[derive(Clone)] @@ -356,7 +415,13 @@ struct ModelsQuery { async fn handler_models( State(state): State>, Query(query): Query, -) -> Json { +) -> Response { + if state.registry.is_siwc() + && let Some(provider) = state.registry.provider("siwc") + && let Some(response) = provider.model_catalog(query.limit).await + { + return response; + } let mut data: Vec = state .registry .all_supported_models() @@ -381,6 +446,7 @@ async fn handler_models( "first_id": data.first().and_then(|entry| entry.get("id")).cloned(), "last_id": data.last().and_then(|entry| entry.get("id")).cloned(), })) + .into_response() } async fn handler_messages(State(state): State>, req: Request) -> Response { @@ -1828,7 +1894,8 @@ async fn dispatch_request( }, ); let status = response.status(); - if status.is_success() { + if status.is_success() || provider.name() == "siwc" { + // Upstream diagnostics may echo project content; SIWC never writes them to logs. return monitor_response_body(response, request_guard); } @@ -1893,21 +1960,32 @@ fn monitor_response_body(response: Response, guard: RequestMonitorGuard) -> Resp .extensions() .get::() .cloned(); + let siwc_outcome = response + .extensions() + .get::() + .cloned(); let (mut parts, body) = response.into_parts(); // Stamped on the parts before the body is streamed, so a streaming SSE // response carries the header too. stamp_request_id(&mut parts.headers, &guard.req_id); let stream = futures_util::stream::unfold( - (body, guard, outcome), - move |(mut body, mut guard, outcome)| async move { + (body, guard, outcome, siwc_outcome), + move |(mut body, mut guard, outcome, siwc_outcome)| async move { match body.frame().await { - Some(Ok(frame)) => Some((Ok(frame), (body, guard, outcome))), + Some(Ok(frame)) => Some((Ok(frame), (body, guard, outcome, siwc_outcome))), Some(Err(err)) => { guard.failed(status, err.to_string()); - Some((Err(err), (body, guard, outcome))) + Some((Err(err), (body, guard, outcome, siwc_outcome))) } None => { - if let Some(message) = outcome.as_ref().and_then(NativeResponseOutcome::failure) + if let Some(message) = outcome + .as_ref() + .and_then(NativeResponseOutcome::failure) + .or_else(|| { + siwc_outcome + .as_ref() + .and_then(crate::providers::chatgpt_siwc::StreamOutcome::failure) + }) { guard.failed(status, message); } else if status.is_success() { diff --git a/src/session.rs b/src/session.rs index 6b97e964..9c197a1c 100644 --- a/src/session.rs +++ b/src/session.rs @@ -92,6 +92,7 @@ pub(crate) fn record_session_request_with_affinity_update( && !crate::registry::is_anthropic_alias(normalize_incoming_model(model).as_str()) { next.affinity_provider = Some(match provider_name { + "siwc" => AliasProvider::Siwc, "codex" => AliasProvider::Codex, "kimi" => AliasProvider::Kimi, _ => next.affinity_provider.unwrap_or(AliasProvider::Codex), @@ -115,7 +116,7 @@ pub(crate) fn record_session_request_with_affinity_update( } fn is_alias_routable_provider(name: &str) -> bool { - matches!(name, "codex" | "kimi") + matches!(name, "codex" | "kimi" | "siwc") } #[cfg(test)] diff --git a/src/traffic.rs b/src/traffic.rs index 4d7936e3..6a167500 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -42,6 +42,9 @@ pub fn traffic_capture_enabled_for_env(env: &std::collections::HashMap Option { + if opts.provider.as_deref() == Some("siwc") { + return None; + } if !traffic_capture_enabled() { return None; } diff --git a/tests/cli.rs b/tests/cli.rs index 3d3ab176..2cf759ba 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -256,3 +256,50 @@ fn kimi_auth_status_reads_stored_auth() -> Result<(), Box cmd.assert().success().stdout(contains("User: u")); Ok(()) } + +#[test] +fn siwc_cli_uses_separate_storage_and_requires_login() -> Result<(), Box> { + let temp = TempDir::new()?; + for args in [ + vec!["siwc", "--help"], + vec!["siwc", "auth", "login", "--help"], + vec!["siwc", "auth", "status"], + ] { + Command::cargo_bin("claude-code-proxy")? + .args(args) + .env("CCP_CONFIG_DIR", temp.path()) + .assert() + .success(); + } + let state_path = temp.path().join("siwc/state.json"); + let before: serde_json::Value = serde_json::from_slice(&std::fs::read(&state_path)?)?; + Command::cargo_bin("claude-code-proxy")? + .args(["siwc", "auth", "logout"]) + .env("CCP_CONFIG_DIR", temp.path()) + .assert() + .success(); + let after: serde_json::Value = serde_json::from_slice(&std::fs::read(state_path)?)?; + assert_eq!(before["ext_agent_host_id"], after["ext_agent_host_id"]); + assert!(!temp.path().join("codex").exists()); + Command::cargo_bin("claude-code-proxy")? + .args(["siwc", "serve", "--port", "0"]) + .env("CCP_CONFIG_DIR", temp.path()) + .assert() + .failure() + .stderr(contains("not signed in")); + Command::cargo_bin("claude-code-proxy")? + .args(["siwc", "auth", "status", "--profile", "../unsafe"]) + .env("CCP_CONFIG_DIR", temp.path()) + .assert() + .failure() + .stderr(contains("Profile label")); + Command::cargo_bin("claude-code-proxy")? + .args(["serve", "--no-monitor", "--port", "0"]) + .env("CCP_CONFIG_DIR", temp.path()) + .env("CCP_ALIAS_PROVIDER", "siwc") + .env("CCP_BIND_ADDRESS", "0.0.0.0") + .assert() + .failure() + .stderr(contains("loopback")); + Ok(()) +} diff --git a/tests/fixtures/siwc/README.md b/tests/fixtures/siwc/README.md new file mode 100644 index 00000000..e8b64473 --- /dev/null +++ b/tests/fixtures/siwc/README.md @@ -0,0 +1,4 @@ +# SIWC test keys + +This RSA private key and its public JWKS were generated solely for local mock +OAuth tests. They are public test fixtures, never production credentials. diff --git a/tests/fixtures/siwc/jwks.json b/tests/fixtures/siwc/jwks.json new file mode 100644 index 00000000..9bc846eb --- /dev/null +++ b/tests/fixtures/siwc/jwks.json @@ -0,0 +1 @@ +{"keys": [{"kty": "RSA", "kid": "siwc-test-key", "alg": "RS256", "use": "sig", "n": "q7Wd5CBdar8L09Pb8SG8a2ECRENoOJg9nSA6h3nc2j6JzpL4Aha3ZMhGpIkil-1MUI17I1itMGgwhcW4OTASqvhF9im0tw2aPVBOSSBKCMde5FDnmDtYQNyz18iMOMkMvKyZ1uBektGNkbMLiJN0_qyTpE82C27jZEZAhBgBRBMc_6DgpigpOpnkaKlIRi5A5h9BjqfTGSkuPotranf9Rt855SCpT9l21ak4crSLtyku73Q4IgIkumFxcgnn4pQdFNpnFeUe3HE53aoDMR2FUt3EQR0QPtBCveQfkhY0zWaAUGhmd2A2Wlf7AR9IeKVD3DuQj3uJqASDKfmaLaHjKw", "e": "AQAB"}]} \ No newline at end of file diff --git a/tests/fixtures/siwc/test-rsa.der b/tests/fixtures/siwc/test-rsa.der new file mode 100644 index 0000000000000000000000000000000000000000..c679bbbd756f6eec3930419d1abf255b2e2f0bec GIT binary patch literal 1193 zcmV;a1XlYnf&`@k0RRGm0RaH3wVmW3U24Az)6?7WA-rp00z^Y-IG8=1AUcP6+}b{g z&XV{77Pn-`Mx==%m+eeYje8?ltuSaXg~hlzFcPZxMfNGQw+)&-P)Sl&Ito zYsGGpMv+pqJ*Rdq0k;mIe+XoJjN;X2B2lA)xHeqy@(;y1n@GwV;gFT4NwP>!cW~Pm z5_DT&*Rc(a^SaR9EpNCe%aRp z-|=J~xJt-AWl~cD=hz!he*)|g=P>A>fZl#=6-?v)#(+%+G~%M(EOv8Mdw=MXGomh{ zbf3F+j$x%o@G{*#{Ov-$Gkoex55OKLjk!;?DOfP+isR;`2Tl1#(a^zn+z7q90)c@5 z!wRD^V#YO8qS_RJ@H}_n+frnvG(Ri)&G<}f-knghE|Otrs_%v_qL`zRk8(WFM<6J%9+65T(z_++DNJCO1RMD))lkY)kndCK({x0+nm7)&MgE^w1%# z#RVV4on2|iBUdE&kvL>Cj(B8lP)=b{0)c@5&Zu3h8}T`&lcOuBPAV~%q*-R4F$pet zW9Z(V-X;8oX1cr1>m7&Oyf)t?5A@&uvYnVv+1x7Bblakwb?hUxo%+ieDsUR*p>FaO zO`k(w*UY#Y$Ot8bM{azLOJhs5Eyp((NdyBTg~SY?v!0lXExtH8&hFyM+l{U{ciYqr z0)c@5m@s;~Tsx6%qfJZaKu^xIoAIQ()70NOYVoe16)NY)i8YIGqQMvt*Q*C76RB=4 zj`k+f-c~Y>XCG}(0?^XuZ2`2ftTxs5i%&{;|2FJgnm$|&wy$Z6UT+B|8`JH}0yfF>+M0ta*=e_v zMtmHn9hAX86d$qPr(E!*&NS&t@pg+EPt2Y28Q`~iS*hfnjpY{I`(h7g1JH_xDb@%8 z-sJY?(8!hbk Date: Wed, 28 Oct 2026 18:40:37 -0700 Subject: [PATCH 3/5] fix: validate SIWC streams and preserve upstream failures Accept media type parameters and verified headerless SSE, including large first events from native Claude Code. Keep status/code/param diagnostics, redact request data, require response.completed, and suppress automatic inference replays. --- src/providers/chatgpt_siwc/client.rs | 359 ++++++++++++- src/providers/chatgpt_siwc/stream.rs | 260 +++++++-- src/providers/chatgpt_siwc/tests.rs | 525 ++++++++++++++++++- src/providers/chatgpt_siwc/translate.rs | 38 ++ tests/fixtures/siwc/README.md | 10 +- tests/fixtures/siwc/claude-code-2.1.310.json | 484 +++++++++++++++++ 6 files changed, 1584 insertions(+), 92 deletions(-) create mode 100644 tests/fixtures/siwc/claude-code-2.1.310.json diff --git a/src/providers/chatgpt_siwc/client.rs b/src/providers/chatgpt_siwc/client.rs index 754c0c11..0fe8774e 100644 --- a/src/providers/chatgpt_siwc/client.rs +++ b/src/providers/chatgpt_siwc/client.rs @@ -5,7 +5,252 @@ use axum::{ response::{IntoResponse, Response}, }; use serde_json::{Value, json}; -use std::time::Duration; +use sha2::{Digest, Sha256}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +type FailureCache = Arc>>; + +#[derive(Clone)] +struct ReplayGuard { + failures: FailureCache, + key: [u8; 32], +} +impl ReplayGuard { + fn cached(&self) -> Option { + let mut failures = self.failures.lock().expect("SIWC replay guard"); + failures.retain(|(at, _, _)| at.elapsed() < Duration::from_secs(60)); + failures + .iter() + .rev() + .find(|(_, key, _)| key == &self.key) + .map(|(_, _, error)| { + let mut error = error.clone(); + error.body["replay_blocked"] = json!(true); + error + }) + } + fn remember(&self, error: &ApiError) { + let mut failures = self.failures.lock().expect("SIWC replay guard"); + failures.retain(|(_, key, _)| key != &self.key); + if failures.len() >= 16 { + failures.pop_front(); + } + failures.push_back((Instant::now(), self.key, error.clone())); + } +} + +#[derive(Debug, Clone)] +pub(super) struct ResponseInfo { + pub status: StatusCode, + pub content_type: Option, + pub request_id: Option, + pub response_class: Option<&'static str>, + pub first_event_type: Option<&'static str>, + pub probe_bytes: usize, +} + +#[derive(Clone)] +pub(super) struct ResponseContext { + pub info: ResponseInfo, + token: String, + redactions: Vec, + replay: Option, +} + +impl ResponseContext { + fn new(response: &reqwest::Response, token: &str, body: Option<&Value>) -> Self { + let mut redactions = Vec::new(); + if let Some(body) = body { + redactions.push(body.to_string()); + collect_request_text(body, &mut redactions); + } + redactions.retain(|s| !s.is_empty()); + redactions.sort_by_key(|s| std::cmp::Reverse(s.len())); + redactions.dedup(); + let mut context = Self { + info: ResponseInfo { + status: response.status(), + content_type: None, + request_id: None, + response_class: None, + first_event_type: None, + probe_bytes: 0, + }, + token: token.to_string(), + redactions, + replay: None, + }; + context.info.content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .map(|v| context.text(v, 256, true)); + context.info.request_id = ["x-request-id", "x-oai-request-id", "x-openai-request-id"] + .iter() + .find_map(|key| response.headers().get(*key).and_then(|v| v.to_str().ok())) + .map(|v| context.text(v, 256, true)); + context + } + + pub fn from_response(response: &reqwest::Response) -> Self { + response + .extensions() + .get::() + .cloned() + .unwrap_or_else(|| Self::new(response, "", None)) + } + + fn text(&self, value: &str, limit: usize, redact_request: bool) -> String { + static CREDENTIAL: std::sync::LazyLock = + std::sync::LazyLock::new(|| { + regex_lite::Regex::new( + r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*|\b(?:sk|sess)-[A-Za-z0-9_-]+", + ) + .expect("credential redaction pattern") + }); + let mut safe = value.to_string(); + if !self.token.is_empty() { + safe = safe.replace(&self.token, "[REDACTED]"); + } + if redact_request { + for secret in &self.redactions { + safe = safe.replace(secret, "[REDACTED]"); + } + } + CREDENTIAL + .replace_all(&safe, "[REDACTED]") + .chars() + .filter(|c| !c.is_control() || *c == '\n') + .take(limit) + .collect() + } + + pub fn sanitize(&self, value: Value) -> Value { + match value { + Value::String(s) => Value::String(self.text(&s, 4096, true)), + Value::Array(values) => { + Value::Array(values.into_iter().map(|v| self.sanitize(v)).collect()) + } + Value::Object(values) => Value::Object( + values + .into_iter() + .map(|(k, v)| { + let sensitive = matches!( + k.to_ascii_lowercase().as_str(), + "authorization" + | "access_token" + | "refresh_token" + | "id_token" + | "api_key" + | "request" + | "request_body" + | "headers" + | "input" + | "messages" + | "instructions" + | "prompt" + ); + let identifier = matches!(k.as_str(), "code" | "param" | "type"); + ( + k, + if sensitive { + json!("[REDACTED]") + } else if identifier && let Some(text) = v.as_str() { + // A prompt may itself discuss an API error code or parameter. + // Preserve machine identifiers while still removing credentials. + json!(self.text(text, 4096, false)) + } else { + self.sanitize(v) + }, + ) + }) + .collect(), + ), + value => value, + } + } + + pub fn http_error(&self, body: Value) -> ApiError { + ApiError { + status: self.info.status, + body: self.sanitize(body), + request_id: self.info.request_id.clone(), + retry_after: None, + upstream: Some(Box::new(self.info.clone())), + } + } + + pub fn unexpected_type(&self, body: Value) -> ApiError { + if let Some(error) = body.get("error").filter(|e| e.is_object()) { + let mut result = self.http_error(json!({"error":error})); + result.status = response_error_status(error["code"].as_str().unwrap_or("")); + self.remember_failure(&result); + return result; + } + let mut error = ApiError::local( + StatusCode::BAD_GATEWAY, + "siwc_unexpected_content_type", + "Responses API returned a successful HTTP status without a valid SSE stream", + ); + error.body["upstream_error"] = self.sanitize(body); + let error = error.with_upstream(&self.info); + self.remember_failure(&error); + error + } + pub fn remember_failure(&self, error: &ApiError) { + if let Some(replay) = &self.replay { + replay.remember(error); + } + } +} + +pub(super) fn response_error_status(code: &str) -> StatusCode { + match code { + "subscription_sharing_unsupported_capability" | "invalid_request_error" => { + StatusCode::BAD_REQUEST + } + "subscription_sharing_invalid_user" | "invalid_api_key" => StatusCode::UNAUTHORIZED, + "subscription_sharing_user_not_eligible" + | "subscription_sharing_route_not_supported" + | "chatpass_v2_scope_not_authorized" + | "chatpass_v2_invalid_authorization_context" + | "permission_denied" => StatusCode::FORBIDDEN, + "model_not_found" => StatusCode::NOT_FOUND, + "subscription_sharing_usage_limit_exceeded" + | "rate_limit_exceeded" + | "insufficient_quota" => StatusCode::TOO_MANY_REQUESTS, + "subscription_sharing_usage_unavailable" + | "subscription_sharing_user_unavailable" + | "server_is_overloaded" => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::BAD_GATEWAY, + } +} + +fn collect_request_text(value: &Value, out: &mut Vec) { + match value { + Value::Object(values) => { + for (key, value) in values { + if !matches!( + key.as_str(), + "model" | "type" | "role" | "name" | "call_id" | "id" + ) { + collect_request_text(value, out); + } + } + } + Value::Array(values) => { + for value in values { + collect_request_text(value, out); + } + } + Value::String(text) if text.len() >= 8 => out.push(text.clone()), + _ => {} + } +} #[derive(Debug, Clone)] pub(super) struct ApiError { @@ -13,6 +258,7 @@ pub(super) struct ApiError { pub body: Value, pub request_id: Option, pub retry_after: Option, + pub upstream: Option>, } impl ApiError { @@ -22,8 +268,42 @@ impl ApiError { body: json!({"error":{"code":code,"message":message}}), request_id: None, retry_after: None, + upstream: None, } } + pub fn with_upstream(mut self, info: &ResponseInfo) -> Self { + self.request_id = info.request_id.clone(); + self.upstream = Some(Box::new(info.clone())); + self + } + pub fn diagnostics(&self) -> Value { + let code = self + .body + .pointer("/upstream_error/error/code") + .or_else(|| self.body.pointer("/error/code")) + .and_then(Value::as_str); + let safe_code = code.filter(|s| { + !s.is_empty() + && s.len() <= 128 + && !s.starts_with("sk-") + && s.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.') + }) + }); + json!({"upstream_status":self.upstream.as_ref().map(|u| u.status.as_u16()), + "upstream_content_type":self.upstream.as_ref().and_then(|u| u.content_type.as_deref()), + "upstream_request_id":self.request_id,"upstream_error_code":safe_code, + "response_class":self.upstream.as_ref().and_then(|u| u.response_class), + "first_event_type":self.upstream.as_ref().and_then(|u| u.first_event_type), + "probe_bytes":self.upstream.as_ref().map(|u| u.probe_bytes), + "proxy_status":self.status.as_u16()}) + } + pub fn report(&self) { + crate::logging::create_logger("siwc").warn( + "siwc_upstream_error", + self.diagnostics().as_object().cloned(), + ); + } pub fn error_type(&self) -> &'static str { match self.status.as_u16() { 400 | 404 | 422 => "invalid_request_error", @@ -41,10 +321,23 @@ impl ApiError { .or_else(|| self.body.get("detail")) .and_then(Value::as_str) .unwrap_or("SIWC upstream request failed"); - json!({"type":"error","error":{"type":self.error_type(),"message":message,"code":self.body.pointer("/error/code"),"param":self.body.pointer("/error/param")},"upstream_status":self.status.as_u16(),"upstream_body":self.body,"upstream_request_id":self.request_id}) + let mut envelope = self.diagnostics(); + envelope["type"] = json!("error"); + envelope["error"] = json!({"type":self.error_type(),"message":message,"code":self.body.pointer("/error/code"),"param":self.body.pointer("/error/param")}); + envelope["upstream_body"] = self.body.clone(); + envelope } pub fn response(&self) -> Response { let mut response = (self.status, Json(self.envelope())).into_response(); + response + .headers_mut() + .insert("x-should-retry", "false".parse().expect("static header")); + if self.body["replay_blocked"] == true { + response.headers_mut().insert( + "x-siwc-replay-blocked", + "true".parse().expect("static header"), + ); + } for (key, value) in [ ("x-openai-request-id", &self.request_id), ("retry-after", &self.retry_after), @@ -65,8 +358,8 @@ impl std::fmt::Display for ApiError { f, "SIWC HTTP {} ({})", self.status.as_u16(), - self.body - .pointer("/error/code") + self.diagnostics() + .get("upstream_error_code") .and_then(Value::as_str) .unwrap_or("upstream_error") ) @@ -79,6 +372,7 @@ pub(super) struct Client { pub auth: Auth, http: reqwest::Client, pub usage_pause: std::sync::Arc>>, + failures: FailureCache, } impl Client { @@ -87,6 +381,7 @@ impl Client { auth, http: http_client(), usage_pause: Default::default(), + failures: Default::default(), } } async fn request(&self, body: Option<&Value>) -> Result { @@ -102,6 +397,17 @@ impl Client { &e.to_string(), ) })?; + let replay = body.map(|body| ReplayGuard { + failures: self.failures.clone(), + key: Sha256::digest(body.to_string().as_bytes()).into(), + }); + if let Some(error) = replay.as_ref().and_then(ReplayGuard::cached) { + crate::logging::create_logger("siwc").warn( + "siwc_replay_blocked", + error.diagnostics().as_object().cloned(), + ); + return Err(error); + } for attempt in 0..3 { if body.is_some() && let Some(error) = self.usage_pause.lock().expect("usage pause").clone() @@ -126,22 +432,24 @@ impl Client { let result = tokio::time::timeout(Duration::from_secs(60), request.bearer_auth(&token).send()) .await; - let response = result.ok().and_then(Result::ok).ok_or_else(|| { - ApiError::local( + let mut response = result.ok().and_then(Result::ok).ok_or_else(|| { + let error = ApiError::local( StatusCode::BAD_GATEWAY, "siwc_network_error", "SIWC request failed or timed out before headers; no automatic replay", - ) + ); + if let Some(replay) = &replay { + replay.remember(&error); + } + error })?; + let mut context = ResponseContext::new(&response, &token, body); + context.replay = replay.clone(); if response.status().is_success() { + response.extensions_mut().insert(context); return Ok(response); } let status = response.status(); - let request_id = response - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(String::from); let retry_after = response .headers() .get("retry-after") @@ -154,16 +462,15 @@ impl Client { .unwrap_or_else( || json!({"detail":"Upstream returned an unreadable error response"}), ); - // Error payloads are untrusted; never return a reflected bearer token. - let raw = serde_json::to_string(&raw) - .unwrap_or_default() - .replace(&token, "[REDACTED]"); - let error = ApiError { - status, - body: serde_json::from_str(&raw).unwrap_or(Value::Null), - request_id, - retry_after, - }; + let mut error = context.http_error(raw); + if let Some(info) = error.upstream.as_mut() { + info.response_class = Some("http_error"); + } + error.retry_after = retry_after; + error.report(); + if let Some(replay) = &replay { + replay.remember(&error); + } if error.body.pointer("/error/code").and_then(Value::as_str) == Some("subscription_sharing_usage_limit_exceeded") { @@ -180,7 +487,9 @@ impl Client { "" | "subscription_sharing_usage_unavailable" | "subscription_sharing_user_unavailable" ); - if !temporary || attempt == 2 { + // Never replay inference automatically, even if the client retries a 503. + // Model-catalog reads retain bounded backoff. + if body.is_some() || !temporary || attempt == 2 { return Err(error); } let delay = error @@ -197,6 +506,10 @@ impl Client { unreachable!("bounded request loop") } pub async fn responses(&self, body: &Value) -> Result { + crate::logging::create_logger("siwc").info( + "siwc_request_shape", + super::translate::request_shape(body).as_object().cloned(), + ); self.request(Some(body)).await } pub async fn models(&self) -> Result, ApiError> { diff --git a/src/providers/chatgpt_siwc/stream.rs b/src/providers/chatgpt_siwc/stream.rs index 46ebcc3f..739a2de4 100644 --- a/src/providers/chatgpt_siwc/stream.rs +++ b/src/providers/chatgpt_siwc/stream.rs @@ -1,5 +1,8 @@ //! Incremental, bounded SSE parsing plus SIWC completion/tool integrity checks. -use super::{auth::Auth, client::ApiError}; +use super::{ + auth::{Auth, read_json}, + client::{ApiError, ResponseContext, ResponseInfo, response_error_status}, +}; pub(super) type UsagePause = std::sync::Arc>>; @@ -39,6 +42,21 @@ use std::{ const MAX_FRAME: usize = 8 * 1024 * 1024; const MAX_BUFFERED_RESPONSE: usize = 32 * 1024 * 1024; +const PROBE_TIMEOUT: Duration = Duration::from_secs(90); + +fn safe_event_type(kind: &str) -> &'static str { + match kind { + "response.created" => "response.created", + "response.in_progress" => "response.in_progress", + "response.output_text.delta" => "response.output_text.delta", + "response.output_item.added" => "response.output_item.added", + "response.completed" => "response.completed", + "response.failed" => "response.failed", + "response.incomplete" => "response.incomplete", + "error" => "error", + _ => "other_event", + } +} #[derive(Default)] pub(super) struct Decoder { @@ -47,6 +65,7 @@ pub(super) struct Decoder { size: usize, skip_lf: bool, event: Option, + started: bool, pub events: VecDeque, } impl Decoder { @@ -62,6 +81,12 @@ impl Decoder { self.skip_lf = byte == b'\r'; let line = String::from_utf8(std::mem::take(&mut self.line)) .map_err(|_| protocol("Invalid UTF-8 in SSE"))?; + let line = if !self.started { + self.started = true; + line.trim_start_matches('\u{feff}') + } else { + &line + }; if line.is_empty() { if !self.data.is_empty() { let data = self.data.join("\n"); @@ -72,6 +97,9 @@ impl Decoder { } let mut value: Value = serde_json::from_str(&data) .map_err(|_| protocol("Invalid JSON in SSE event"))?; + if !value.is_object() { + return Err(protocol("SSE event data must be an object")); + } if value.get("type").is_none() && let Some(event) = &self.event { @@ -83,7 +111,7 @@ impl Decoder { self.event = None; self.size = 0; } else if !line.starts_with(':') { - let (field, value) = line.split_once(':').unwrap_or((&line, "")); + let (field, value) = line.split_once(':').unwrap_or((line, "")); let value = value.strip_prefix(' ').unwrap_or(value); match field { "data" => self.data.push(value.into()), @@ -107,6 +135,18 @@ fn protocol(message: &str) -> ApiError { ApiError::local(StatusCode::BAD_GATEWAY, "siwc_stream_error", message) } +fn with_request_id(mut response: Response, info: &ResponseInfo) -> Response { + response + .headers_mut() + .insert("x-should-retry", "false".parse().expect("static header")); + if let Some(id) = &info.request_id + && let Ok(id) = id.parse() + { + response.headers_mut().insert("x-openai-request-id", id); + } + response +} + struct Call { id: String, name: String, @@ -117,57 +157,165 @@ struct Call { pub(super) struct Reader { response: reqwest::Response, + pending: Option, decoder: Decoder, names: HashSet, calls: HashMap, call_ids: HashSet, pub complete: bool, - request_id: Option, + context: ResponseContext, auth: Auth, last_check: tokio::time::Instant, last_chunk: tokio::time::Instant, last_ping: tokio::time::Instant, } impl Reader { - pub fn new( + pub async fn new( response: reqwest::Response, names: HashSet, auth: Auth, ) -> Result { - if !response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .is_some_and(|s| { - s.split(';') - .next() - .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/event-stream")) - }) - { - return Err(protocol("Responses API did not return text/event-stream")); + let mut context = ResponseContext::from_response(&response); + // HTTP admission errors must be classified before applying the stream contract. + if !response.status().is_success() { + context.info.response_class = Some("http_error"); + let raw = tokio::time::timeout(Duration::from_secs(15), read_json(response)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(Value::Null); + return Err(context.http_error(raw)); + } + if context.info.content_type.as_deref().is_some_and(|s| { + !s.split(';') + .next() + .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/event-stream")) + }) { + let raw = tokio::time::timeout(Duration::from_secs(15), read_json(response)) + .await + .ok() + .and_then(Result::ok) + .unwrap_or(Value::Null); + context.info.response_class = Some(if raw.get("error").is_some() { + "json_error" + } else if !raw.is_null() { + "json" + } else { + "non_sse" + }); + return Err(context.unexpected_type(raw)); } - let request_id = response - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .map(String::from); - Ok(Self { + let missing_type = context.info.content_type.is_none(); + let mut reader = Self { response, + pending: None, decoder: Decoder::default(), names, calls: HashMap::new(), call_ids: HashSet::new(), complete: false, - request_id, + context, auth, last_check: tokio::time::Instant::now() - Duration::from_secs(1), last_chunk: tokio::time::Instant::now(), last_ping: tokio::time::Instant::now(), - }) + }; + if missing_type { + // The first response.created can echo large instructions/tools. Its bound + // must match the ordinary SSE frame bound, not a smaller HTTP-chunk cap. + reader.detect_sse().await?; + reader.report_classification(); + } + Ok(reader) + } + async fn detect_sse(&mut self) -> Result<(), ApiError> { + let deadline = tokio::time::Instant::now() + PROBE_TIMEOUT; + let mut prefix = Vec::new(); + loop { + let result = tokio::time::timeout_at(deadline, self.response.chunk()).await; + let classification = match &result { + Err(_) => "probe_timeout", + Ok(Err(_)) => "transport_error", + Ok(Ok(None)) if prefix.is_empty() => "empty", + _ => "incomplete_sse", + }; + let chunk = result.ok().and_then(Result::ok).flatten(); + let Some(chunk) = chunk else { + return Err(self.classification_error(classification, &prefix)); + }; + // Feed complete line segments only until the first complete event. Keep + // the rest of this transport chunk for next(), including later failures. + let mut offset = 0; + while offset < chunk.len() { + let end = chunk[offset..] + .iter() + .position(|b| matches!(b, b'\r' | b'\n')) + .map(|i| offset + i + 1) + .unwrap_or(chunk.len()); + if prefix.len() + end - offset > MAX_FRAME { + return Err(self.classification_error("probe_limit", &prefix)); + } + prefix.extend_from_slice(&chunk[offset..end]); + let first = prefix.iter().copied().find(|b| !b.is_ascii_whitespace()); + if matches!(first, Some(b'{' | b'[')) { + if serde_json::from_slice::(&prefix).is_ok() { + return Err(self.classification_error("json", &prefix)); + } + } else { + if first == Some(b'<') { + return Err(self.classification_error("html_or_xml", &prefix)); + } + if self.decoder.push(&chunk[offset..end]).is_err() { + return Err(self.classification_error("invalid_sse", &prefix)); + } + if let Some(event) = self.decoder.events.front() { + let kind = event["type"].as_str().unwrap_or(""); + if !(kind.starts_with("response.") || kind == "error") { + return Err(self.classification_error("unrecognized_event", &prefix)); + } + self.context.info.response_class = Some("responses_sse"); + self.context.info.first_event_type = Some(safe_event_type(kind)); + self.context.info.probe_bytes = prefix.len(); + self.pending = (end < chunk.len()).then(|| chunk.slice(end..)); + self.last_chunk = tokio::time::Instant::now(); + return Ok(()); + } + } + offset = end; + } + } + } + fn classification_error(&mut self, classification: &'static str, prefix: &[u8]) -> ApiError { + let raw = serde_json::from_slice::(prefix).unwrap_or(Value::Null); + self.context.info.response_class = Some(if raw.get("error").is_some() { + "json_error" + } else { + classification + }); + self.context.info.probe_bytes = prefix.len(); + self.context.unexpected_type(raw) + } + fn report_classification(&self) { + let diagnostic = ApiError::local(StatusCode::OK, "", "").with_upstream(&self.context.info); + let logger = crate::logging::create_logger("siwc"); + if self.context.info.content_type.is_none() { + logger.warn( + "siwc_response_classification", + diagnostic.diagnostics().as_object().cloned(), + ); + } else { + logger.info( + "siwc_response_classification", + diagnostic.diagnostics().as_object().cloned(), + ); + } } pub async fn next(&mut self) -> Result, ApiError> { self.next_inner().await.map_err(|mut error| { - error.request_id = self.request_id.clone(); + error.body = self.context.sanitize(error.body); + error = error.with_upstream(&self.context.info); + self.context.remember_failure(&error); + error.report(); error }) } @@ -185,15 +333,22 @@ impl Reader { self.last_check = tokio::time::Instant::now(); } if let Some(mut value) = self.decoder.events.pop_front() { + if self.context.info.first_event_type.is_none() { + self.context.info.response_class = Some("responses_sse"); + self.context.info.first_event_type = + Some(safe_event_type(value["type"].as_str().unwrap_or(""))); + self.report_classification(); + } if value["type"] == "response.refusal.delta" { value["type"] = json!("response.output_text.delta"); } - if let Err(mut e) = self.validate(&value) { - e.request_id = self.request_id.clone(); - return Err(e); - } + self.validate(&value)?; return Ok(Some(value)); } + if let Some(bytes) = self.pending.take() { + self.decoder.push(&bytes)?; + continue; + } if self.last_chunk.elapsed() >= Duration::from_secs(90) { return Err(protocol( "SIWC stream idle timeout before response.completed", @@ -226,25 +381,13 @@ impl Reader { match kind { "error" | "response.failed" | "response.incomplete" => { let error = value.get("error").or_else(|| value.pointer("/response/error")).filter(|v| !v.is_null()).or_else(|| (kind == "error" && (value.get("code").is_some() || value.get("message").is_some())).then_some(value)).cloned().unwrap_or_else(|| json!({"code":"response_incomplete","message":"SIWC response did not complete"})); - let status = match error["code"].as_str().unwrap_or("") { - "subscription_sharing_usage_limit_exceeded" | "rate_limit_exceeded" => { - StatusCode::TOO_MANY_REQUESTS - } - "subscription_sharing_usage_unavailable" - | "subscription_sharing_user_unavailable" => StatusCode::SERVICE_UNAVAILABLE, - "subscription_sharing_user_not_eligible" - | "subscription_sharing_route_not_supported" - | "chatpass_v2_scope_not_authorized" - | "chatpass_v2_invalid_authorization_context" => StatusCode::FORBIDDEN, - "subscription_sharing_invalid_user" => StatusCode::UNAUTHORIZED, - "subscription_sharing_unsupported_capability" => StatusCode::BAD_REQUEST, - _ => StatusCode::BAD_GATEWAY, - }; + let status = response_error_status(error["code"].as_str().unwrap_or("")); return Err(ApiError { status, body: json!({"error":error,"event":value}), - request_id: self.request_id.clone(), + request_id: self.context.info.request_id.clone(), retry_after: None, + upstream: Some(Box::new(self.context.info.clone())), }); } "response.done" => { @@ -392,11 +535,16 @@ pub(super) async fn response( pause: UsagePause, auth: Auth, ) -> Response { - let reader = match Reader::new(response, names, auth) { + let reader = match Reader::new(response, names, auth).await { Ok(reader) => reader, - Err(e) => return e.response(), + Err(e) => { + pause_on_limit(&pause, &e); + e.report(); + return e.response(); + } }; let id = format!("msg_{}", uuid::Uuid::new_v4().simple()); + let info = reader.context.info.clone(); if !stream_requested { let mut reader = reader; let mut bytes = Vec::new(); @@ -408,8 +556,10 @@ pub(super) async fn response( &event.to_string(), )); if bytes.len() > MAX_BUFFERED_RESPONSE { - return protocol("Buffered response exceeds limit; use streaming") - .response(); + let error = protocol("Buffered response exceeds limit; use streaming") + .with_upstream(&info); + reader.context.remember_failure(&error); + return error.response(); } } Ok(None) => break, @@ -419,10 +569,15 @@ pub(super) async fn response( } } } - return match accumulate_response(&bytes, &id, &model) { + let response = match accumulate_response(&bytes, &id, &model) { Ok(value) => axum::Json(value).into_response(), - Err(_) => protocol("Could not translate completed response").response(), + Err(_) => { + let error = protocol("Could not translate completed response").with_upstream(&info); + reader.context.remember_failure(&error); + error.response() + } }; + return with_request_id(response, &info); } struct Live { reader: Reader, @@ -457,7 +612,10 @@ pub(super) async fn response( Err(_) => { state.done = true; state.outcome.fail(); - let e = protocol("Could not translate Responses stream"); + let e = protocol("Could not translate Responses stream") + .with_upstream(&state.reader.context.info); + state.reader.context.remember_failure(&e); + e.report(); let bytes = crate::anthropic::sse::encode_sse_event( Some("error"), &e.envelope().to_string(), @@ -489,5 +647,5 @@ pub(super) async fn response( ) .into_response(); response.extensions_mut().insert(outcome); - response + with_request_id(response, &info) } diff --git a/src/providers/chatgpt_siwc/tests.rs b/src/providers/chatgpt_siwc/tests.rs index 220525ff..22211e61 100644 --- a/src/providers/chatgpt_siwc/tests.rs +++ b/src/providers/chatgpt_siwc/tests.rs @@ -36,6 +36,11 @@ struct MockData { revoke: StatusCode, redirect: Option, slow_stream: bool, + response_content_type: Option>, + request_id_header: String, + disconnect_stream: bool, + response_chunk_bytes: Option, + initial_stream_delay: std::time::Duration, } struct Mock { data: Arc>, @@ -63,6 +68,11 @@ impl Mock { revoke: StatusCode::OK, redirect: None, slow_stream: false, + response_content_type: None, + request_id_header: "x-request-id".into(), + disconnect_stream: false, + response_chunk_bytes: None, + initial_stream_delay: std::time::Duration::ZERO, })); let app = Router::new() .fallback(any(mock_handler)) @@ -131,6 +141,31 @@ async fn mock_handler( "/v1/responses" => { if let Some(url) = &data.redirect { return (StatusCode::TEMPORARY_REDIRECT, [("location",url.clone())]).into_response(); } let (status, body) = data.responses.pop_front().expect("unexpected inference request"); + let mut headers = HeaderMap::new(); + if let Some(content_type) = data.response_content_type.clone().unwrap_or_else(|| Some(if status.is_success() { "text/event-stream" } else { "application/json" }.into())) { + headers.insert("content-type", content_type.parse().unwrap()); + } + headers.insert(data.request_id_header.parse::().unwrap(), "req_siwc_mock".parse().unwrap()); + if let Some(chunk_bytes) = data.response_chunk_bytes { + let chunks = body.as_bytes().chunks(chunk_bytes).map(bytes::Bytes::copy_from_slice).collect::>(); + let delay = data.initial_stream_delay; + let stream = futures_util::stream::unfold((0, chunks), move |(index, chunks)| async move { + if index >= chunks.len() { return None; } + if index == 0 { tokio::time::sleep(delay).await; } + Some((Ok::<_,std::io::Error>(chunks[index].clone()), (index + 1, chunks))) + }); + return (status, headers, Body::from_stream(stream)).into_response(); + } + if data.disconnect_stream { + let stream = futures_util::stream::unfold((0, body), |(index, body)| async move { + match index { + 0 => Some((Ok(bytes::Bytes::from(body.clone())), (1, body))), + 1 => { tokio::time::sleep(std::time::Duration::from_millis(50)).await; Some((Err(std::io::Error::from(std::io::ErrorKind::ConnectionReset)), (2, body))) }, + _ => None, + } + }); + return (status, headers, Body::from_stream(stream)).into_response(); + } if data.slow_stream && status.is_success() { let cut = body.rfind("data:").unwrap_or(0); let chunks = [body[..cut].to_string(), body[cut..].to_string()]; @@ -139,9 +174,9 @@ async fn mock_handler( if index == 1 { tokio::time::sleep(std::time::Duration::from_millis(800)).await; } Some((Ok::<_,std::io::Error>(bytes::Bytes::from(chunks[index].clone())), (index+1,chunks))) }); - return (status, [("content-type","text/event-stream"),("x-request-id","req_siwc_mock")], Body::from_stream(stream)).into_response(); + return (status, headers, Body::from_stream(stream)).into_response(); } - (status, [("content-type",if status.is_success() {"text/event-stream"} else {"application/json"}), ("x-request-id","req_siwc_mock")], body).into_response() + (status, headers, Body::from(body)).into_response() } _ => StatusCode::NOT_FOUND.into_response(), } @@ -569,6 +604,15 @@ async fn discovery_and_outbound_requests_use_only_allowed_headers_and_paths() { .unwrap() .starts_with("claude-code-proxy/") ); + if r.path == "/v1/responses" { + let wire: Value = serde_json::from_slice(&r.body).unwrap(); + assert_eq!(wire["model"], "gpt-test"); + assert_eq!(wire["store"], false); + assert_eq!(wire["stream"], true); + assert!(wire["input"].is_array()); + assert!(wire.get("max_output_tokens").is_none()); + assert!(wire.get("previous_response_id").is_none()); + } } } } @@ -690,7 +734,6 @@ async fn http_errors_preserve_status_shape_codes_and_request_id_without_retrying let mock = Mock::new().await; let dir = tempfile::tempdir().unwrap(); let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; - let client = Client::new(auth); for (status, error) in [ (401, json!({"detail":"identity refused"})), ( @@ -706,6 +749,7 @@ async fn http_errors_preserve_status_shape_codes_and_request_id_without_retrying json!({"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"app limit"}}), ), ] { + let client = Client::new(auth.clone()); mock.reply(StatusCode::from_u16(status).unwrap(), error.to_string()); let e = client.responses(&json!({})).await.unwrap_err(); assert_eq!(e.status.as_u16(), status); @@ -717,23 +761,31 @@ async fn http_errors_preserve_status_shape_codes_and_request_id_without_retrying } #[tokio::test] -async fn unavailable_route_retries_at_most_three_times_and_redirects_are_blocked() { +async fn inference_503_is_not_replayed_and_redirects_are_blocked() { let mock = Mock::new().await; let dir = tempfile::tempdir().unwrap(); let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; let client = Client::new(auth); - for _ in 0..3 { - mock.reply( - StatusCode::SERVICE_UNAVAILABLE, - json!({"detail":"route unavailable"}).to_string(), - ); - } - assert_eq!(client.responses(&json!({})).await.unwrap_err().status, 503); - assert_eq!(mock.count("/v1/responses"), 3); + mock.reply( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error":{"code":"subscription_sharing_usage_unavailable","message":"try later"}}) + .to_string(), + ); + let error = client.responses(&json!({})).await.unwrap_err(); + assert_eq!(error.status, 503); + assert_eq!(error.response().headers()["x-should-retry"], "false"); + assert_eq!(mock.count("/v1/responses"), 1); let other = Mock::new().await; mock.data.lock().unwrap().redirect = Some(format!("{}/v1/responses", other.data.lock().unwrap().base)); - assert_eq!(client.responses(&json!({})).await.unwrap_err().status, 307); + assert_eq!( + client + .responses(&json!({"different_request":true})) + .await + .unwrap_err() + .status, + 307 + ); assert_eq!(other.count("/v1/responses"), 0); } @@ -742,8 +794,8 @@ async fn failed_incomplete_and_disconnected_streams_never_emit_success() { let mock = Mock::new().await; let dir = tempfile::tempdir().unwrap(); let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; - let p = provider(auth); for ending in ["", "data: [DONE]\n\n", "data: broken\n\n"] { + let p = provider(auth.clone()); mock.reply( StatusCode::OK, format!( @@ -759,13 +811,13 @@ async fn failed_incomplete_and_disconnected_streams_never_emit_success() { assert!(!text.contains("message_stop")); } for kind in ["response.failed", "response.incomplete"] { - *p.client.usage_pause.lock().unwrap() = None; + let p = provider(auth.clone()); mock.reply(StatusCode::OK,event(json!({"type":kind,"response":{"error":{"code":"subscription_sharing_usage_limit_exceeded","message":"limit reached"}}}))); let text = body(p.handle_messages(request(true), context()).await).await; assert!(text.contains("subscription_sharing_usage_limit_exceeded")); assert!(!text.contains("message_stop")); } - *p.client.usage_pause.lock().unwrap() = None; + let p = provider(auth); mock.reply(StatusCode::OK, String::new()); assert_eq!( p.handle_messages(request(false), context()).await.status(), @@ -799,6 +851,17 @@ fn sse_decoder_handles_utf8_fragmentation_crlf_and_multiline_data() { decoder.push(&[*b]).unwrap(); } assert_eq!(decoder.events.pop_front().unwrap()["delta"], "你好"); + let mut decoder = stream::Decoder::default(); + for b in "\u{feff}event: response.created\ndata: {}\n\n".as_bytes() { + decoder.push(&[*b]).unwrap(); + } + assert_eq!( + decoder.events.pop_front().unwrap()["type"], + "response.created" + ); + for invalid in ["data: []\n\n", "event: response.created\ndata: 42\n\n"] { + assert!(stream::Decoder::default().push(invalid.as_bytes()).is_err()); + } } #[tokio::test] @@ -1212,8 +1275,8 @@ async fn terminal_tool_call_cannot_change_completed_arguments() { let mock = Mock::new().await; let dir = tempfile::tempdir().unwrap(); let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; - let p = provider(auth); for stream in [true, false] { + let p = provider(auth.clone()); let mut req = request(stream); req.extra.insert( "tools".into(), @@ -1230,3 +1293,431 @@ async fn terminal_tool_call_cannot_change_completed_arguments() { assert!(!text.contains("message_stop")); } } + +#[tokio::test] +async fn response_http_errors_are_preserved_before_sse_validation() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + for status in [400, 401, 403, 429] { + for content_type in [ + Some("application/json"), + Some("text/event-stream; charset=utf-8"), + None, + ] { + mock.data.lock().unwrap().response_content_type = Some(content_type.map(String::from)); + let code = if status == 429 { + "subscription_sharing_usage_limit_exceeded" + } else { + "invalid_request_error" + }; + let raw = json!({"error":{"code":code,"param":"model","message":"request refused"}}); + mock.reply(StatusCode::from_u16(status).unwrap(), raw.to_string()); + let p = provider(auth.clone()); + let response = p.handle_messages(request(true), context()).await; + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers()["x-openai-request-id"], "req_siwc_mock"); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(error["error"]["code"], code); + assert_eq!(error["error"]["param"], "model"); + assert_eq!(error["upstream_status"], status); + assert_eq!(error["proxy_status"], status); + assert_eq!(error["upstream_body"], raw); + assert_eq!(error["upstream_content_type"], json!(content_type)); + assert_eq!(error["upstream_request_id"], "req_siwc_mock"); + } + } + assert_eq!(mock.count("/v1/responses"), 12); + assert_eq!(mock.count("/token"), 1); +} + +#[tokio::test] +async fn response_sse_accepts_media_type_parameters_and_missing_header() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + for content_type in [ + Some("text/event-stream"), + Some("text/event-stream; charset=utf-8"), + Some("Text/Event-Stream ; charset=UTF-8"), + None, + ] { + mock.data.lock().unwrap().response_content_type = Some(content_type.map(String::from)); + mock.data.lock().unwrap().request_id_header = "x-oai-request-id".into(); + for stream in [true, false] { + mock.reply(StatusCode::OK, text_events()); + let response = p.handle_messages(request(stream), context()).await; + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["x-openai-request-id"], "req_siwc_mock"); + let text = body(response).await; + assert!(text.contains("你好")); + assert!(!text.contains("siwc_unexpected_content_type")); + assert_eq!(text.contains("message_stop"), stream); + } + } +} + +#[tokio::test] +async fn successful_http_json_errors_keep_real_error_codes_and_metadata() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + for content_type in [Some("application/json; charset=utf-8"), None] { + mock.data.lock().unwrap().response_content_type = Some(content_type.map(String::from)); + mock.data.lock().unwrap().request_id_header = "x-oai-request-id".into(); + for stream in [true, false] { + let p = provider(auth.clone()); + mock.reply(StatusCode::OK, json!({"error":{"code":"subscription_sharing_unsupported_capability","param":"input","message":"unsupported input"}}).to_string()); + let response = p.handle_messages(request(stream), context()).await; + assert_eq!(response.status(), 400); + assert_eq!(response.headers()["x-should-retry"], "false"); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!( + error["error"]["code"], + "subscription_sharing_unsupported_capability" + ); + assert_eq!(error["error"]["param"], "input"); + assert_eq!(error["upstream_status"], 200); + assert_eq!(error["proxy_status"], 400); + assert_eq!(error["response_class"], "json_error"); + assert_eq!( + error["upstream_error_code"], + "subscription_sharing_unsupported_capability" + ); + assert_eq!(error["upstream_content_type"], json!(content_type)); + assert_eq!(error["upstream_request_id"], "req_siwc_mock"); + assert_eq!(error["upstream_body"]["error"]["param"], "input"); + } + } +} + +#[tokio::test] +async fn response_errors_redact_bearer_and_project_data_from_display_and_diagnostics() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let client = Client::new(auth); + let project_text = "private-project-secret-canary"; + let request_body = json!({"model":"gpt-test","input":[{"role":"user","content":project_text}],"store":false,"stream":true}); + mock.reply(StatusCode::BAD_REQUEST, json!({"error":{"code":"invalid_request_error","param":"input","message":format!("Bearer mock-access: rejected {project_text}; sk-secret-canary")},"request":request_body,"authorization":"Bearer mock-access","refresh_token":"secret-refresh-canary"}).to_string()); + let error = client.responses(&request_body).await.unwrap_err(); + let envelope = error.envelope().to_string(); + for secret in [ + "mock-access", + "secret-refresh-canary", + "sk-secret-canary", + project_text, + ] { + assert!(!envelope.contains(secret)); + assert!(!error.to_string().contains(secret)); + assert!(!error.diagnostics().to_string().contains(secret)); + } + assert_eq!(error.diagnostics().as_object().unwrap().len(), 8); + assert_eq!( + error.diagnostics()["upstream_error_code"], + "invalid_request_error" + ); + assert!(error.diagnostics().get("message").is_none()); + assert_eq!(error.envelope()["error"]["param"], "input"); + let discussed_code = "invalid_request_error"; + let request_body = json!({"input":[{"role":"user","content":discussed_code}]}); + mock.reply( + StatusCode::BAD_REQUEST, + json!({"error":{"code":discussed_code,"param":"input","message":"refused"}}).to_string(), + ); + let error = client.responses(&request_body).await.unwrap_err(); + assert_eq!(error.envelope()["error"]["code"], discussed_code); +} + +#[tokio::test] +async fn response_disconnect_and_missing_completion_keep_http_metadata_without_success() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + for disconnect in [false, true] { + let p = provider(auth.clone()); + mock.data.lock().unwrap().disconnect_stream = disconnect; + mock.data.lock().unwrap().request_id_header = "x-oai-request-id".into(); + mock.reply( + StatusCode::OK, + event(json!({"type":"response.output_text.delta","output_index":0,"delta":"partial"})), + ); + let text = body(p.handle_messages(request(true), context()).await).await; + assert!(!text.contains("message_stop")); + let error = text + .lines() + .filter_map(|line| line.strip_prefix("data: ")) + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|event| event["type"] == "error") + .unwrap(); + assert_eq!(error["upstream_status"], 200); + assert_eq!(error["proxy_status"], 502); + assert_eq!(error["upstream_content_type"], "text/event-stream"); + assert_eq!(error["upstream_request_id"], "req_siwc_mock"); + } +} + +#[tokio::test] +async fn missing_content_type_probe_rejects_non_responses_and_oversized_preludes() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + mock.data.lock().unwrap().response_content_type = Some(None); + for value in [ + String::new(), + "not SSE".into(), + event(json!({"type":"unrelated_event"})), + format!(":{}\n\n{}", "x".repeat(8 * 1024 * 1024 + 1), text_events()), + ] { + let p = provider(auth.clone()); + mock.reply(StatusCode::OK, value); + let response = p.handle_messages(request(true), context()).await; + assert_eq!(response.status(), 502); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(error["upstream_status"], 200); + assert_eq!(error["error"]["code"], "siwc_unexpected_content_type"); + } +} + +fn native_claude_request() -> MessagesRequest { + serde_json::from_str(include_str!( + "../../../tests/fixtures/siwc/claude-code-2.1.310.json" + )) + .unwrap() +} + +#[test] +fn native_claude_request_uses_supported_responses_fields_and_safe_shape_diagnostics() { + let mut req = native_claude_request(); + let prepared = translate::prepare(&req, "gpt-test".into()).unwrap(); + let wire = &prepared.body; + assert_eq!(wire["model"], "gpt-test"); + assert_eq!(wire["store"], false); + assert_eq!(wire["stream"], true); + assert!(wire["instructions"].is_string()); + assert_eq!(wire["input"][0]["type"], "additional_tools"); + assert_eq!(wire["input"][0]["role"], "developer"); + let tools = wire["input"][0]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 12); + assert!(tools.iter().all(|t| t["type"] == "function")); + assert!( + wire["input"] + .as_array() + .unwrap() + .iter() + .all(|i| i["role"] != "system") + ); + for field in [ + "tools", + "metadata", + "context_management", + "thinking", + "output_config", + "temperature", + "max_tokens", + "max_output_tokens", + "previous_response_id", + ] { + assert!(wire.get(field).is_none(), "{field}"); + } + for (choice, expected) in [ + (json!({"type":"auto"}), json!("auto")), + (json!({"type":"none"}), json!("none")), + (json!({"type":"any"}), json!("required")), + ( + json!({"type":"tool","name":"Read"}), + json!({"type":"function","name":"Read"}), + ), + ] { + req.extra.insert("tool_choice".into(), choice); + assert_eq!( + translate::prepare(&req, "gpt-test".into()).unwrap().body["tool_choice"], + expected + ); + } + let mut wire = prepared.body; + wire["model"] = json!("private-project-secret-canary"); + wire["instructions"] = json!("private-project-secret-canary"); + wire["input"][0]["tools"][0]["name"] = json!("private-project-secret-canary"); + let shape = translate::request_shape(&wire); + assert!(!shape.to_string().contains("private-project-secret-canary")); + assert_eq!(shape["system_items"], 0); + assert_eq!(shape["tool_count"], 12); + assert_eq!(shape["top_level_tools"], false); + assert!(shape["model"].is_null()); +} + +#[tokio::test] +async fn missing_type_large_native_response_created_survives_transport_fragmentation() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.data.lock().unwrap().response_content_type = Some(None); + let mut req = native_claude_request(); + req.extra.get_mut("system").unwrap()[0]["text"] = + json!("Synthetic system size fixture. ".repeat(4000)); + let wire = translate::prepare(&req, "gpt-test".into()).unwrap().body; + let created = event( + json!({"type":"response.created","response":{"id":"resp_fixture","status":"in_progress","instructions":wire["instructions"],"tools":wire["input"][0]["tools"]}}), + ); + assert!(created.len() > 64 * 1024); + for chunk_bytes in [None, Some(127), Some(64 * 1024)] { + mock.data.lock().unwrap().response_chunk_bytes = chunk_bytes; + for stream in [true, false] { + req.stream = stream; + mock.reply(StatusCode::OK, format!("{created}{}", text_events())); + let response = p.handle_messages(req.clone(), context()).await; + assert_eq!(response.status(), 200); + let text = body(response).await; + assert!(text.contains("你好")); + assert!(!text.contains("siwc_unexpected_content_type")); + assert_eq!(text.contains("message_stop"), stream); + } + } + assert_eq!(mock.count("/v1/responses"), 6); +} + +#[tokio::test] +async fn missing_type_sse_does_not_drop_events_after_the_first_frame_in_a_chunk() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.data.lock().unwrap().response_content_type = Some(None); + let delta = event( + json!({"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"x"}), + ); + let events = format!( + "{}{}{}{}{}", + event(json!({"type":"response.created","response":{"id":"resp_fixture"}})), + event( + json!({"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"item_text","role":"assistant","content":[]}}) + ), + delta.repeat(2048), + event( + json!({"type":"response.output_item.done","output_index":0,"item":{"type":"message","id":"item_text","content":[{"type":"output_text","text":"x".repeat(2048)}]}}) + ), + event( + json!({"type":"response.completed","response":{"id":"resp_fixture","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"x".repeat(2048)}]}],"usage":{"input_tokens":1,"output_tokens":2048}}}) + ) + ); + assert!(events.len() > 64 * 1024); + mock.reply(StatusCode::OK, events); + let response = p.handle_messages(request(false), context()).await; + assert_eq!(response.status(), 200); + let message: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(message["content"][0]["text"], "x".repeat(2048)); +} + +#[tokio::test] +async fn missing_type_valid_sse_can_start_after_ten_seconds() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + { + let mut data = mock.data.lock().unwrap(); + data.response_content_type = Some(None); + data.response_chunk_bytes = Some(1024); + data.initial_stream_delay = std::time::Duration::from_secs(11); + } + mock.reply(StatusCode::OK, text_events()); + let response = p.handle_messages(request(false), context()).await; + assert_eq!(response.status(), 200); + assert!(body(response).await.contains("你好")); +} + +#[tokio::test] +async fn successful_http_non_sse_is_classified_and_json_error_codes_are_preserved() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + for (raw, status, code, class) in [ + (json!({"object":"response","status":"completed"}).to_string(), 502, "siwc_unexpected_content_type", "json"), + ("failure".into(), 502, "siwc_unexpected_content_type", "html_or_xml"), + (String::new(), 502, "siwc_unexpected_content_type", "empty"), + (json!({"error":{"code":"invalid_request_error","param":"input","message":"invalid input"}}).to_string(), 400, "invalid_request_error", "json_error"), + (json!({"error":{"code":"subscription_sharing_invalid_user","param":"input","message":"invalid identity"}}).to_string(), 401, "subscription_sharing_invalid_user", "json_error"), + (json!({"error":{"code":"subscription_sharing_user_not_eligible","param":"input","message":"ineligible"}}).to_string(), 403, "subscription_sharing_user_not_eligible", "json_error"), + (json!({"error":{"code":"subscription_sharing_usage_limit_exceeded","param":"input","message":"limit"}}).to_string(), 429, "subscription_sharing_usage_limit_exceeded", "json_error"), + (json!({"error":{"code":"future_unknown_error","param":"input","message":"refused"}}).to_string(), 502, "future_unknown_error", "json_error"), + ] { + mock.data.lock().unwrap().response_content_type = Some(None); + mock.reply(StatusCode::OK, raw); + let p = provider(auth.clone()); + let response = p.handle_messages(request(true), context()).await; + assert_eq!(response.status().as_u16(), status); + assert_eq!(response.headers()["x-should-retry"], "false"); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(error["error"]["code"], code); + assert_eq!(error["response_class"], class); + assert_eq!(error["upstream_status"], 200); + assert_eq!(error["proxy_status"], status); + if class == "json_error" { + assert_eq!(error["error"]["param"], "input"); + } + if status == 429 { + let response = p.handle_messages(request(true), context()).await; + assert_eq!(response.status(), 429); // Paused locally, no extra mock reply. + } + } + assert_eq!(mock.count("/v1/responses"), 8); +} + +#[tokio::test] +async fn first_event_diagnostics_do_not_echo_untrusted_event_type() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + mock.data.lock().unwrap().response_content_type = Some(None); + mock.reply( + StatusCode::OK, + event(json!({"type":"response.private-project-secret-canary"})), + ); + let text = body( + provider(auth) + .handle_messages(request(true), context()) + .await, + ) + .await; + assert!(text.contains("other_event")); + assert!(!text.contains("private-project-secret-canary")); + assert!(!text.contains("message_stop")); +} + +#[tokio::test] +async fn native_stream_to_buffered_fallback_is_blocked_after_ambiguous_failure() { + let mock = Mock::new().await; + let dir = tempfile::tempdir().unwrap(); + let auth = signed_in(&mock, Store::new(dir.path().join("siwc"))).await; + let p = provider(auth); + mock.data.lock().unwrap().response_content_type = Some(None); + mock.reply(StatusCode::OK, format!("{}{}", event(json!({"type":"response.created","response":{"id":"resp_failure"}})), event(json!({"type":"response.failed","response":{"error":{"code":"server_is_overloaded","param":"model","message":"temporarily overloaded"}}})))); + let mut req = native_claude_request(); + req.stream = true; + let response = p.handle_messages(req.clone(), context()).await; + assert_eq!(response.headers()["x-should-retry"], "false"); + let text = body(response).await; + assert!(text.contains("server_is_overloaded")); + assert!(!text.contains("message_stop")); + req.stream = false; + let response = p.handle_messages(req.clone(), context()).await; + assert_eq!(response.status(), 503); + assert_eq!(response.headers()["x-siwc-replay-blocked"], "true"); + assert_eq!(response.headers()["x-should-retry"], "false"); + let error: Value = serde_json::from_str(&body(response).await).unwrap(); + assert_eq!(error["error"]["code"], "server_is_overloaded"); + assert_eq!(error["error"]["param"], "model"); + assert_eq!(error["upstream_status"], 200); + assert_eq!(error["upstream_request_id"], "req_siwc_mock"); + assert_eq!(error["upstream_body"]["replay_blocked"], true); + assert_eq!(mock.count("/v1/responses"), 1); + req.messages[0].content = json!("A changed user request"); + mock.reply(StatusCode::OK, text_events()); + let response = p.handle_messages(req, context()).await; + assert_eq!(response.status(), 200); + assert!(body(response).await.contains("你好")); + assert_eq!(mock.count("/v1/responses"), 2); +} diff --git a/src/providers/chatgpt_siwc/translate.rs b/src/providers/chatgpt_siwc/translate.rs index bc6772fa..bfcac00b 100644 --- a/src/providers/chatgpt_siwc/translate.rs +++ b/src/providers/chatgpt_siwc/translate.rs @@ -36,6 +36,44 @@ pub(super) struct Prepared { pub tool_names: HashSet, } +/// Only structural counts and fixed categories; never prompts, names or arguments. +pub(super) fn request_shape(body: &Value) -> Value { + let input = body["input"].as_array().cloned().unwrap_or_default(); + let tools: Vec<_> = input + .iter() + .filter(|v| v["type"] == "additional_tools") + .filter_map(|v| v["tools"].as_array()) + .flatten() + .collect(); + let count = |kind: &str| input.iter().filter(|v| v["type"] == kind).count(); + let role_count = |role: &str| input.iter().filter(|v| v["role"] == role).count(); + let choice = match body["tool_choice"] + .as_str() + .or_else(|| body["tool_choice"]["type"].as_str()) + { + Some("auto") => "auto", + Some("none") => "none", + Some("required") => "required", + Some("function") => "function", + None => "absent", + _ => "other", + }; + // A caller-provided unknown model must not become arbitrary diagnostic text. + let model = body["model"].as_str().filter(|s| { + s.len() <= 128 + && s.starts_with("gpt-") + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.')) + }); + json!({"model":model,"store":body["store"].as_bool(),"stream":body["stream"].as_bool(), + "body_bytes":body.to_string().len(),"input_count":input.len(),"instructions_bytes":body["instructions"].as_str().map(str::len).unwrap_or(0), + "message_count":count("message"),"function_call_count":count("function_call"),"function_output_count":count("function_call_output"), + "system_items":role_count("system"),"developer_items":role_count("developer"),"user_items":role_count("user"),"assistant_items":role_count("assistant"), + "additional_tools_items":count("additional_tools"),"tool_count":tools.len(), + "tool_schema_bytes":tools.iter().map(|v| v["parameters"].to_string().len()).sum::(), + "top_level_tools":body.get("tools").is_some(),"tool_choice":choice}) +} + pub(super) fn prepare(request: &MessagesRequest, model: String) -> Result { for key in FORBIDDEN { ensure!( diff --git a/tests/fixtures/siwc/README.md b/tests/fixtures/siwc/README.md index e8b64473..507c34b1 100644 --- a/tests/fixtures/siwc/README.md +++ b/tests/fixtures/siwc/README.md @@ -1,4 +1,12 @@ -# SIWC test keys +# SIWC test fixtures This RSA private key and its public JWKS were generated solely for local mock OAuth tests. They are public test fixtures, never production credentials. + +`claude-code-2.1.310.json` preserves the request structure emitted by the +installed native Claude Code 2.1.310 in an isolated, empty test project. System +and message text and tool descriptions are replaced with synthetic text, the +model is `gpt-test`, and metadata uses a fixture identity. It contains the +observed 12 built-in tool schemas, cache-control blocks, system-role message, +thinking/context-management/output-config fields, and streaming flag. It +contains no OAuth credentials, user project content, or original instructions. diff --git a/tests/fixtures/siwc/claude-code-2.1.310.json b/tests/fixtures/siwc/claude-code-2.1.310.json new file mode 100644 index 00000000..cd6e3043 --- /dev/null +++ b/tests/fixtures/siwc/claude-code-2.1.310.json @@ -0,0 +1,484 @@ +{ + "model": "gpt-test", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Synthetic fixture text." + }, + { + "type": "text", + "text": "Synthetic fixture text." + } + ] + }, + { + "role": "system", + "content": [ + { + "type": "text", + "text": "Synthetic fixture text.", + "cache_control": { + "type": "ephemeral" + } + } + ] + } + ], + "system": [ + { + "type": "text", + "text": "Synthetic fixture text." + }, + { + "type": "text", + "text": "Synthetic fixture text.", + "cache_control": { + "type": "ephemeral" + } + }, + { + "type": "text", + "text": "Synthetic fixture text.", + "cache_control": { + "type": "ephemeral" + } + } + ], + "tools": [ + { + "name": "Agent", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "description": "Synthetic fixture text.", + "prompt": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "subagent_type": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "model": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "sonnet", + "opus", + "haiku", + "fable" + ] + }, + "effort": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "run_in_background": { + "description": "Synthetic fixture text.", + "type": "boolean" + }, + "isolation": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "worktree", + "remote" + ] + } + }, + "required": [ + "description", + "prompt" + ], + "additionalProperties": false + } + }, + { + "name": "Bash", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "command": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "timeout": { + "description": "Synthetic fixture text.", + "type": "number" + }, + "description": "Synthetic fixture text.", + "run_in_background": { + "description": "Synthetic fixture text.", + "type": "boolean" + }, + "dangerouslyDisableSandbox": { + "description": "Synthetic fixture text.", + "type": "boolean" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + }, + { + "name": "Edit", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "file_path": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "old_string": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "new_string": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "replace_all": { + "description": "Synthetic fixture text.", + "default": false, + "type": "boolean" + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ], + "additionalProperties": false + } + }, + { + "name": "ListAgents", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "channel": { + "description": "Synthetic fixture text.", + "type": "string", + "maxLength": 256 + }, + "q": { + "description": "Synthetic fixture text.", + "type": "string", + "maxLength": 256 + } + }, + "additionalProperties": false + } + }, + { + "name": "Read", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "file_path": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "offset": { + "description": "Synthetic fixture text.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "limit": { + "description": "Synthetic fixture text.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "pages": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "allow_large": { + "description": "Synthetic fixture text.", + "type": "boolean" + } + }, + "required": [ + "file_path" + ], + "additionalProperties": false + } + }, + { + "name": "ReportFindings", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "level": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + }, + "findings": { + "description": "Synthetic fixture text.", + "maxItems": 32, + "type": "array", + "items": { + "type": "object", + "properties": { + "file": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "line": { + "description": "Synthetic fixture text.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "summary": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "short_summary": { + "description": "Synthetic fixture text.", + "type": "string", + "maxLength": 60 + }, + "failure_scenario": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "category": { + "description": "Synthetic fixture text.", + "type": "string", + "maxLength": 40 + }, + "verdict": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "CONFIRMED", + "PLAUSIBLE" + ] + }, + "outcome": { + "description": "Synthetic fixture text.", + "type": "string", + "enum": [ + "fixed", + "skipped", + "no_change_needed" + ] + } + }, + "required": [ + "file", + "summary", + "failure_scenario" + ], + "additionalProperties": false + } + } + }, + "required": [ + "findings" + ], + "additionalProperties": false + } + }, + { + "name": "ScheduleWakeup", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "delaySeconds": { + "description": "Synthetic fixture text.", + "type": "number" + }, + "reason": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "prompt": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "stop": { + "description": "Synthetic fixture text.", + "type": "boolean" + }, + "noop": { + "description": "Synthetic fixture text.", + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + { + "name": "Skill", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "skill": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "args": { + "description": "Synthetic fixture text.", + "type": "string" + } + }, + "required": [ + "skill" + ], + "additionalProperties": false + } + }, + { + "name": "ToolSearch", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "max_results": { + "description": "Synthetic fixture text.", + "default": 5, + "type": "number" + } + }, + "required": [ + "query", + "max_results" + ], + "additionalProperties": false + } + }, + { + "name": "Workflow", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "script": { + "description": "Synthetic fixture text.", + "type": "string", + "maxLength": 524288 + }, + "name": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "description": "Synthetic fixture text.", + "title": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "args": { + "description": "Synthetic fixture text." + }, + "scriptPath": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "resumeFromRunId": { + "description": "Synthetic fixture text.", + "type": "string", + "pattern": "^wf_[a-z0-9-]{6,}$" + } + }, + "additionalProperties": false + } + }, + { + "name": "DeferredToolPlaceholder", + "description": "Synthetic fixture text.", + "input_schema": { + "type": "object", + "properties": {} + }, + "defer_loading": true + }, + { + "name": "Write", + "description": "Synthetic fixture text.", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "file_path": { + "description": "Synthetic fixture text.", + "type": "string" + }, + "content": { + "description": "Synthetic fixture text.", + "type": "string" + } + }, + "required": [ + "file_path", + "content" + ], + "additionalProperties": false + } + } + ], + "metadata": { + "user_id": "fixture" + }, + "max_tokens": 32000, + "thinking": { + "type": "adaptive", + "display": "omitted" + }, + "context_management": { + "edits": [ + { + "type": "clear_thinking_20251015", + "keep": "all" + } + ] + }, + "output_config": { + "effort": "high" + }, + "stream": true +} From a78303c7ea902d1df69cd24fa48fafbf99a79dad Mon Sep 17 00:00:00 2001 From: staklab <177474538+staklab@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:44:51 -0700 Subject: [PATCH 4/5] docs: document official SIWC setup and safety boundaries --- README.md | 7 + docs/astro.config.mjs | 1 + .../content/docs/providers/chatgpt-siwc.md | 147 ++++++++++++++++++ .../docs/providers/choosing-a-provider.md | 8 +- .../docs/reference/command-reference.md | 19 +++ .../compatibility-and-limitations.md | 12 +- .../content/docs/reference/configuration.md | 22 ++- .../docs/reference/files-and-storage.md | 14 ++ docs/src/content/docs/reference/http-api.md | 12 ++ 9 files changed, 238 insertions(+), 4 deletions(-) create mode 100644 docs/src/content/docs/providers/chatgpt-siwc.md diff --git a/README.md b/README.md index 30ab8260..d6727221 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ The opt-in Images API returns base64 image data and consumes the signed-in accou | Provider | Account | Model selection | | ------------ | ------------------------------ | ----------------------------------------------- | +| [Sign in with ChatGPT](docs/src/content/docs/providers/chatgpt-siwc.md) | Eligible ChatGPT account with plan permission | Account-specific official catalog; isolated `siwc serve` | | Codex | ChatGPT Plus or Pro | Registered `gpt-*` models, `-fast`/`-ultrafast` | | Kimi | kimi.com with Kimi Code access | `kimi-for-coding` and aliases | | Grok | grok.com | Registered Grok models | @@ -105,6 +106,12 @@ The opt-in Images API returns base64 image data and consumes the signed-in accou Run `claude-code-proxy models` for the current catalog or `claude-code-proxy models --full` for every dynamic Cursor alias. +For the official public Responses API, use the opt-in **Sign in with ChatGPT** +provider: `claude-code-proxy siwc auth login --consent`, then +`claude-code-proxy siwc models`. Follow the +[SIWC guide](docs/src/content/docs/providers/chatgpt-siwc.md) to start an isolated +local proxy and configure Claude Code with an available model. + > [!WARNING] > The proxy accepts local requests without client authentication. It binds to > `127.0.0.1` by default. Protect any non-loopback listener with a firewall or diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 93880081..d39a508a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -44,6 +44,7 @@ export default defineConfig({ label: 'Providers', items: [ { label: 'Choosing a provider', slug: 'providers/choosing-a-provider' }, + { label: 'Sign in with ChatGPT', slug: 'providers/chatgpt-siwc' }, { label: 'Codex', slug: 'providers/codex' }, { label: 'Kimi', slug: 'providers/kimi' }, { label: 'Grok', slug: 'providers/grok' }, diff --git a/docs/src/content/docs/providers/chatgpt-siwc.md b/docs/src/content/docs/providers/chatgpt-siwc.md new file mode 100644 index 00000000..201ab731 --- /dev/null +++ b/docs/src/content/docs/providers/chatgpt-siwc.md @@ -0,0 +1,147 @@ +--- +title: Sign in with ChatGPT +description: Authorize ChatGPT plan usage through official OAuth and the public Responses API in an isolated local proxy. +--- + +The opt-in `siwc` provider uses OpenAI's official Sign in with ChatGPT flow for +eligible ChatGPT plan usage. It connects to `https://api.openai.com/v1/responses` +with the selected account's OAuth access token. This project is independent of +OpenAI and Anthropic. + +OpenAI documents this flow for open-source, locally hosted tools. Account, +workspace, model access, and usage limits remain under OpenAI's control. See +the [official overview](https://developers.openai.com/siwc/token-sharing-open-source). + +## Authorize and discover models + +```sh +claude-code-proxy siwc auth login --consent +claude-code-proxy siwc auth status +claude-code-proxy siwc models +``` + +Complete sign-in and plan permission in your browser. `plan_permission: true` +means the saved grant contains both inference scopes. Identity-only sign-in +does not permit inference; repeat login with `--consent` if needed. + +Choose an exact `id` from `siwc models`; there is no assumed default GPT model. +The catalog is fetched for your account rather than copied from the Codex +registry. See OpenAI's [models and inference guide](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference). + +Use `--profile