From 87b55708ef858e52c50cd6872a5d95fa16223f53 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Wed, 8 Jul 2026 22:12:27 +0900 Subject: [PATCH 1/7] refactor: depend on goat-sdk for provider and auth stack --- Cargo.lock | 17 +- Cargo.toml | 52 +- crates/goat-agent/src/compaction.rs | 3 + crates/goat-agent/src/retry.rs | 3 + crates/goat-auth/Cargo.toml | 22 - crates/goat-auth/src/lib.rs | 762 ---------- crates/goat-code/src/main.rs | 4 + crates/goat-config/Cargo.toml | 1 + crates/goat-config/src/lib.rs | 64 +- crates/goat-protocol/Cargo.toml | 18 - crates/goat-protocol/src/event.rs | 232 --- crates/goat-protocol/src/lib.rs | 211 --- crates/goat-protocol/src/op.rs | 68 - crates/goat-protocol/src/types.rs | 434 ------ crates/goat-provider-anthropic/Cargo.toml | 24 - crates/goat-provider-anthropic/src/auth.rs | 154 -- crates/goat-provider-anthropic/src/error.rs | 319 ----- crates/goat-provider-anthropic/src/lib.rs | 1274 ----------------- crates/goat-provider-deepseek/Cargo.toml | 16 - crates/goat-provider-deepseek/src/lib.rs | 20 - crates/goat-provider-gemini/Cargo.toml | 24 - crates/goat-provider-gemini/src/codeassist.rs | 223 --- crates/goat-provider-gemini/src/error.rs | 178 --- crates/goat-provider-gemini/src/lib.rs | 486 ------- crates/goat-provider-gemini/src/oauth.rs | 181 --- crates/goat-provider-gemini/src/wire.rs | 809 ----------- crates/goat-provider-groq/Cargo.toml | 16 - crates/goat-provider-groq/src/lib.rs | 40 - crates/goat-provider-kimi-code/Cargo.toml | 20 - crates/goat-provider-kimi-code/src/lib.rs | 242 ---- crates/goat-provider-kimi-code/src/oauth.rs | 337 ----- crates/goat-provider-kimi/Cargo.toml | 16 - crates/goat-provider-kimi/src/lib.rs | 61 - crates/goat-provider-local/Cargo.toml | 17 - crates/goat-provider-local/src/lib.rs | 35 - crates/goat-provider-mistral/Cargo.toml | 16 - crates/goat-provider-mistral/src/lib.rs | 41 - crates/goat-provider-openai-codex/Cargo.toml | 24 - crates/goat-provider-openai-codex/src/lib.rs | 602 -------- crates/goat-provider-openai-compat/Cargo.toml | 21 - .../goat-provider-openai-compat/src/chat.rs | 1024 ------------- .../goat-provider-openai-compat/src/common.rs | 322 ----- .../src/headers.rs | 44 - .../goat-provider-openai-compat/src/hosted.rs | 67 - crates/goat-provider-openai-compat/src/lib.rs | 14 - .../src/responses.rs | 1056 -------------- .../goat-provider-openai-compat/src/vision.rs | 41 - crates/goat-provider-openai/Cargo.toml | 16 - crates/goat-provider-openai/src/lib.rs | 118 -- crates/goat-provider-openrouter/Cargo.toml | 16 - crates/goat-provider-openrouter/src/lib.rs | 55 - crates/goat-provider-qwen/Cargo.toml | 17 - crates/goat-provider-qwen/src/lib.rs | 207 --- crates/goat-provider-xai/Cargo.toml | 21 - crates/goat-provider-xai/src/lib.rs | 440 ------ crates/goat-provider-xai/src/oauth.rs | 523 ------- crates/goat-provider-zai-coding/Cargo.toml | 16 - crates/goat-provider-zai-coding/src/lib.rs | 96 -- crates/goat-provider-zai/Cargo.toml | 16 - crates/goat-provider-zai/src/lib.rs | 122 -- crates/goat-provider/Cargo.toml | 19 - crates/goat-provider/src/lib.rs | 534 ------- crates/goat-providers/Cargo.toml | 31 - crates/goat-providers/src/lib.rs | 100 -- crates/goat-sandbox/src/lib.rs | 6 +- crates/goat-search-provider-brave/Cargo.toml | 17 - crates/goat-search-provider-brave/src/lib.rs | 116 -- .../Cargo.toml | 20 - .../src/lib.rs | 348 ----- .../goat-search-provider-searxng/Cargo.toml | 17 - .../goat-search-provider-searxng/src/lib.rs | 127 -- crates/goat-search-provider-tavily/Cargo.toml | 17 - crates/goat-search-provider-tavily/src/lib.rs | 113 -- crates/goat-search-provider/Cargo.toml | 16 - crates/goat-search-provider/src/lib.rs | 221 --- crates/goat-search-providers/Cargo.toml | 20 - crates/goat-search-providers/src/lib.rs | 214 --- crates/goat-tool-search/Cargo.toml | 1 + crates/goat-tool-search/src/web_search.rs | 8 +- 79 files changed, 85 insertions(+), 13178 deletions(-) delete mode 100644 crates/goat-auth/Cargo.toml delete mode 100644 crates/goat-auth/src/lib.rs delete mode 100644 crates/goat-protocol/Cargo.toml delete mode 100644 crates/goat-protocol/src/event.rs delete mode 100644 crates/goat-protocol/src/lib.rs delete mode 100644 crates/goat-protocol/src/op.rs delete mode 100644 crates/goat-protocol/src/types.rs delete mode 100644 crates/goat-provider-anthropic/Cargo.toml delete mode 100644 crates/goat-provider-anthropic/src/auth.rs delete mode 100644 crates/goat-provider-anthropic/src/error.rs delete mode 100644 crates/goat-provider-anthropic/src/lib.rs delete mode 100644 crates/goat-provider-deepseek/Cargo.toml delete mode 100644 crates/goat-provider-deepseek/src/lib.rs delete mode 100644 crates/goat-provider-gemini/Cargo.toml delete mode 100644 crates/goat-provider-gemini/src/codeassist.rs delete mode 100644 crates/goat-provider-gemini/src/error.rs delete mode 100644 crates/goat-provider-gemini/src/lib.rs delete mode 100644 crates/goat-provider-gemini/src/oauth.rs delete mode 100644 crates/goat-provider-gemini/src/wire.rs delete mode 100644 crates/goat-provider-groq/Cargo.toml delete mode 100644 crates/goat-provider-groq/src/lib.rs delete mode 100644 crates/goat-provider-kimi-code/Cargo.toml delete mode 100644 crates/goat-provider-kimi-code/src/lib.rs delete mode 100644 crates/goat-provider-kimi-code/src/oauth.rs delete mode 100644 crates/goat-provider-kimi/Cargo.toml delete mode 100644 crates/goat-provider-kimi/src/lib.rs delete mode 100644 crates/goat-provider-local/Cargo.toml delete mode 100644 crates/goat-provider-local/src/lib.rs delete mode 100644 crates/goat-provider-mistral/Cargo.toml delete mode 100644 crates/goat-provider-mistral/src/lib.rs delete mode 100644 crates/goat-provider-openai-codex/Cargo.toml delete mode 100644 crates/goat-provider-openai-codex/src/lib.rs delete mode 100644 crates/goat-provider-openai-compat/Cargo.toml delete mode 100644 crates/goat-provider-openai-compat/src/chat.rs delete mode 100644 crates/goat-provider-openai-compat/src/common.rs delete mode 100644 crates/goat-provider-openai-compat/src/headers.rs delete mode 100644 crates/goat-provider-openai-compat/src/hosted.rs delete mode 100644 crates/goat-provider-openai-compat/src/lib.rs delete mode 100644 crates/goat-provider-openai-compat/src/responses.rs delete mode 100644 crates/goat-provider-openai-compat/src/vision.rs delete mode 100644 crates/goat-provider-openai/Cargo.toml delete mode 100644 crates/goat-provider-openai/src/lib.rs delete mode 100644 crates/goat-provider-openrouter/Cargo.toml delete mode 100644 crates/goat-provider-openrouter/src/lib.rs delete mode 100644 crates/goat-provider-qwen/Cargo.toml delete mode 100644 crates/goat-provider-qwen/src/lib.rs delete mode 100644 crates/goat-provider-xai/Cargo.toml delete mode 100644 crates/goat-provider-xai/src/lib.rs delete mode 100644 crates/goat-provider-xai/src/oauth.rs delete mode 100644 crates/goat-provider-zai-coding/Cargo.toml delete mode 100644 crates/goat-provider-zai-coding/src/lib.rs delete mode 100644 crates/goat-provider-zai/Cargo.toml delete mode 100644 crates/goat-provider-zai/src/lib.rs delete mode 100644 crates/goat-provider/Cargo.toml delete mode 100644 crates/goat-provider/src/lib.rs delete mode 100644 crates/goat-providers/Cargo.toml delete mode 100644 crates/goat-providers/src/lib.rs delete mode 100644 crates/goat-search-provider-brave/Cargo.toml delete mode 100644 crates/goat-search-provider-brave/src/lib.rs delete mode 100644 crates/goat-search-provider-duckduckgo/Cargo.toml delete mode 100644 crates/goat-search-provider-duckduckgo/src/lib.rs delete mode 100644 crates/goat-search-provider-searxng/Cargo.toml delete mode 100644 crates/goat-search-provider-searxng/src/lib.rs delete mode 100644 crates/goat-search-provider-tavily/Cargo.toml delete mode 100644 crates/goat-search-provider-tavily/src/lib.rs delete mode 100644 crates/goat-search-provider/Cargo.toml delete mode 100644 crates/goat-search-provider/src/lib.rs delete mode 100644 crates/goat-search-providers/Cargo.toml delete mode 100644 crates/goat-search-providers/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 6d7f281..6b73e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,6 +1558,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "funty" version = "2.0.0" @@ -1844,6 +1854,7 @@ name = "goat-auth" version = "0.1.23" dependencies = [ "base64", + "fs2", "percent-encoding", "rand 0.10.1", "serde", @@ -1950,6 +1961,7 @@ dependencies = [ name = "goat-config" version = "0.1.23" dependencies = [ + "goat-search-provider", "serde", "serde_json", "thiserror 2.0.18", @@ -2013,7 +2025,6 @@ version = "0.1.23" dependencies = [ "schemars", "serde", - "serde_json", ] [[package]] @@ -2106,7 +2117,6 @@ dependencies = [ name = "goat-provider-local" version = "0.1.23" dependencies = [ - "goat-provider", "goat-provider-openai-compat", ] @@ -2272,6 +2282,7 @@ version = "0.1.23" dependencies = [ "goat-auth", "reqwest 0.12.28", + "serde", "thiserror 2.0.18", ] @@ -2323,7 +2334,6 @@ name = "goat-search-providers" version = "0.1.23" dependencies = [ "goat-auth", - "goat-config", "goat-search-provider", "goat-search-provider-brave", "goat-search-provider-duckduckgo", @@ -2415,6 +2425,7 @@ dependencies = [ name = "goat-tool-search" version = "0.1.23" dependencies = [ + "goat-auth", "goat-config", "goat-protocol", "goat-search-provider", diff --git a/Cargo.toml b/Cargo.toml index 31fa8ef..f5f01dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,37 +11,37 @@ repository = "https://github.com/goat-agent/goat-code" authors = ["jbj338033"] [workspace.dependencies] -goat-protocol = { path = "crates/goat-protocol" } +goat-protocol = { path = "../goat-sdk/crates/goat-protocol" } goat-config = { path = "crates/goat-config" } goat-core = { path = "crates/goat-core" } goat-mcp = { path = "crates/goat-mcp" } goat-tui = { path = "crates/goat-tui" } -goat-provider = { path = "crates/goat-provider" } -goat-auth = { path = "crates/goat-auth" } +goat-provider = { path = "../goat-sdk/crates/goat-provider" } +goat-auth = { path = "../goat-sdk/crates/goat-auth" } goat-store = { path = "crates/goat-store" } -goat-provider-openai-compat = { path = "crates/goat-provider-openai-compat" } -goat-provider-openrouter = { path = "crates/goat-provider-openrouter" } -goat-provider-groq = { path = "crates/goat-provider-groq" } -goat-provider-deepseek = { path = "crates/goat-provider-deepseek" } -goat-provider-mistral = { path = "crates/goat-provider-mistral" } -goat-provider-zai = { path = "crates/goat-provider-zai" } -goat-provider-zai-coding = { path = "crates/goat-provider-zai-coding" } -goat-provider-kimi = { path = "crates/goat-provider-kimi" } -goat-provider-qwen = { path = "crates/goat-provider-qwen" } -goat-provider-kimi-code = { path = "crates/goat-provider-kimi-code" } -goat-provider-xai = { path = "crates/goat-provider-xai" } -goat-provider-openai = { path = "crates/goat-provider-openai" } -goat-provider-openai-codex = { path = "crates/goat-provider-openai-codex" } -goat-provider-anthropic = { path = "crates/goat-provider-anthropic" } -goat-provider-gemini = { path = "crates/goat-provider-gemini" } -goat-provider-local = { path = "crates/goat-provider-local" } -goat-providers = { path = "crates/goat-providers" } -goat-search-provider = { path = "crates/goat-search-provider" } -goat-search-provider-duckduckgo = { path = "crates/goat-search-provider-duckduckgo" } -goat-search-provider-searxng = { path = "crates/goat-search-provider-searxng" } -goat-search-provider-brave = { path = "crates/goat-search-provider-brave" } -goat-search-provider-tavily = { path = "crates/goat-search-provider-tavily" } -goat-search-providers = { path = "crates/goat-search-providers" } +goat-provider-openai-compat = { path = "../goat-sdk/crates/goat-provider-openai-compat" } +goat-provider-openrouter = { path = "../goat-sdk/crates/goat-provider-openrouter" } +goat-provider-groq = { path = "../goat-sdk/crates/goat-provider-groq" } +goat-provider-deepseek = { path = "../goat-sdk/crates/goat-provider-deepseek" } +goat-provider-mistral = { path = "../goat-sdk/crates/goat-provider-mistral" } +goat-provider-zai = { path = "../goat-sdk/crates/goat-provider-zai" } +goat-provider-zai-coding = { path = "../goat-sdk/crates/goat-provider-zai-coding" } +goat-provider-kimi = { path = "../goat-sdk/crates/goat-provider-kimi" } +goat-provider-qwen = { path = "../goat-sdk/crates/goat-provider-qwen" } +goat-provider-kimi-code = { path = "../goat-sdk/crates/goat-provider-kimi-code" } +goat-provider-xai = { path = "../goat-sdk/crates/goat-provider-xai" } +goat-provider-openai = { path = "../goat-sdk/crates/goat-provider-openai" } +goat-provider-openai-codex = { path = "../goat-sdk/crates/goat-provider-openai-codex" } +goat-provider-anthropic = { path = "../goat-sdk/crates/goat-provider-anthropic" } +goat-provider-gemini = { path = "../goat-sdk/crates/goat-provider-gemini" } +goat-provider-local = { path = "../goat-sdk/crates/goat-provider-local" } +goat-providers = { path = "../goat-sdk/crates/goat-providers" } +goat-search-provider = { path = "../goat-sdk/crates/goat-search-provider" } +goat-search-provider-duckduckgo = { path = "../goat-sdk/crates/goat-search-provider-duckduckgo" } +goat-search-provider-searxng = { path = "../goat-sdk/crates/goat-search-provider-searxng" } +goat-search-provider-brave = { path = "../goat-sdk/crates/goat-search-provider-brave" } +goat-search-provider-tavily = { path = "../goat-sdk/crates/goat-search-provider-tavily" } +goat-search-providers = { path = "../goat-sdk/crates/goat-search-providers" } goat-agent = { path = "crates/goat-agent" } goat-tool = { path = "crates/goat-tool" } goat-sandbox = { path = "crates/goat-sandbox" } diff --git a/crates/goat-agent/src/compaction.rs b/crates/goat-agent/src/compaction.rs index 3c02ff4..e675b5f 100644 --- a/crates/goat-agent/src/compaction.rs +++ b/crates/goat-agent/src/compaction.rs @@ -434,6 +434,9 @@ async fn compact_inner( tools: env.tool_defs.to_vec(), effort: None, tool_choice: goat_provider::ToolChoice::None, + temperature: None, + max_tokens: None, + system: None, }; match collect_with_retry(ctx, run, env.provider, &request, token).await { Ok(collected) => break collected, diff --git a/crates/goat-agent/src/retry.rs b/crates/goat-agent/src/retry.rs index f6ba278..ae1cdaf 100644 --- a/crates/goat-agent/src/retry.rs +++ b/crates/goat-agent/src/retry.rs @@ -123,6 +123,9 @@ pub(crate) async fn run_round_with_retry( tools: env.tool_defs.to_vec(), effort: env.target.effort, tool_choice: goat_provider::ToolChoice::Auto, + temperature: None, + max_tokens: None, + system: None, }; let result = run_round(ctx, run, env.provider, request, token).await; let RoundEnd::Failed(error) = &result.end else { diff --git a/crates/goat-auth/Cargo.toml b/crates/goat-auth/Cargo.toml deleted file mode 100644 index 75b5f93..0000000 --- a/crates/goat-auth/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "goat-auth" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -base64 = { workspace = true } -percent-encoding = { workspace = true } -rand = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -sha2 = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-auth/src/lib.rs b/crates/goat-auth/src/lib.rs deleted file mode 100644 index 2e5efb0..0000000 --- a/crates/goat-auth/src/lib.rs +++ /dev/null @@ -1,762 +0,0 @@ -use std::{ - collections::HashMap, - fmt, fs, - path::{Path, PathBuf}, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use base64::Engine; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, -}; - -pub const BASE64URL: base64::engine::general_purpose::GeneralPurpose = - base64::engine::general_purpose::URL_SAFE_NO_PAD; - -pub fn now_secs() -> i64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .ok() - .and_then(|elapsed| i64::try_from(elapsed.as_secs()).ok()) - .unwrap_or(0) -} - -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub struct SecretString(String); - -impl SecretString { - pub fn expose(&self) -> &str { - &self.0 - } -} - -impl fmt::Debug for SecretString { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("SecretString(***)") - } -} - -impl From for SecretString { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for SecretString { - fn from(value: &str) -> Self { - Self(value.to_owned()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum CredentialService { - #[default] - Model, - Search, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct CredentialKey { - #[serde(default)] - pub service: CredentialService, - pub provider: String, - pub account: String, -} - -impl CredentialKey { - pub fn model(provider: impl Into, account: impl Into) -> Self { - Self { - service: CredentialService::Model, - provider: provider.into(), - account: account.into(), - } - } - - pub fn search(provider: impl Into, account: impl Into) -> Self { - Self { - service: CredentialService::Search, - provider: provider.into(), - account: account.into(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CredentialKind { - ApiKey, - OAuth, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TokenSet { - pub access_token: SecretString, - pub refresh_token: Option, - pub expires_at: Option, -} - -impl TokenSet { - pub fn from_parts( - access: String, - refresh: Option, - expires_in: Option, - fallback_refresh: Option<&str>, - ) -> Self { - let expires_at = expires_in.map(|secs| now_secs() + secs); - Self { - access_token: SecretString::from(access), - refresh_token: refresh - .map(SecretString::from) - .or_else(|| fallback_refresh.map(SecretString::from)), - expires_at, - } - } - - pub fn is_expired(&self) -> bool { - self.expires_at.is_some_and(|exp| exp <= now_secs() + 60) - } -} - -fn refresh_locks() -> &'static std::sync::Mutex>>> -{ - static LOCKS: std::sync::OnceLock< - std::sync::Mutex>>>, - > = std::sync::OnceLock::new(); - LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new())) -} - -fn refresh_lock_for(key: &CredentialKey) -> Arc> { - let mut map = refresh_locks() - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - map.entry(key.clone()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() -} - -const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - -pub async fn ensure_valid( - tokens: TokenSet, - store: &CredentialStore, - key: &CredentialKey, - refresh: F, -) -> Option -where - F: FnOnce(String) -> Fut, - Fut: std::future::Future>, -{ - if !tokens.is_expired() { - return Some(tokens); - } - let lock = refresh_lock_for(key); - let _guard = lock.lock().await; - if let Some(Credential::OAuth(current)) = store.file_get(key) { - let changed = current.access_token.expose() != tokens.access_token.expose(); - if changed && !current.is_expired() { - return Some(current); - } - } - let refresh_token = tokens.refresh_token.as_ref()?.expose().to_owned(); - match tokio::time::timeout(REFRESH_TIMEOUT, refresh(refresh_token)).await { - Ok(Ok(fresh)) => { - if let Err(err) = store.store(key, Credential::OAuth(fresh.clone())) { - tracing::warn!(%err, "failed to persist refreshed oauth tokens"); - } - Some(fresh) - } - Ok(Err(err)) => { - tracing::warn!(%err, "token refresh failed; treating as logged out"); - None - } - Err(_) => { - tracing::warn!("token refresh timed out; treating as logged out"); - None - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Credential { - ApiKey(SecretString), - ApiKeyWithEndpoint { - secret: SecretString, - endpoint: String, - }, - OAuth(TokenSet), -} - -impl Credential { - pub fn kind(&self) -> CredentialKind { - match self { - Credential::ApiKey(_) | Credential::ApiKeyWithEndpoint { .. } => CredentialKind::ApiKey, - Credential::OAuth(_) => CredentialKind::OAuth, - } - } - - pub fn bearer(&self) -> &str { - match self { - Credential::ApiKey(secret) | Credential::ApiKeyWithEndpoint { secret, .. } => { - secret.expose() - } - Credential::OAuth(tokens) => tokens.access_token.expose(), - } - } - - pub fn endpoint(&self) -> Option<&str> { - match self { - Credential::ApiKeyWithEndpoint { endpoint, .. } => Some(endpoint), - Credential::ApiKey(_) | Credential::OAuth(_) => None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum StoredValue { - ApiKey { - secret: SecretString, - }, - ApiKeyWithEndpoint { - secret: SecretString, - endpoint: String, - }, - OAuth { - tokens: TokenSet, - }, -} - -impl From for StoredValue { - fn from(value: Credential) -> Self { - match value { - Credential::ApiKey(secret) => StoredValue::ApiKey { secret }, - Credential::ApiKeyWithEndpoint { secret, endpoint } => { - StoredValue::ApiKeyWithEndpoint { secret, endpoint } - } - Credential::OAuth(tokens) => StoredValue::OAuth { tokens }, - } - } -} - -impl From for Credential { - fn from(value: StoredValue) -> Self { - match value { - StoredValue::ApiKey { secret } => Credential::ApiKey(secret), - StoredValue::ApiKeyWithEndpoint { secret, endpoint } => { - Credential::ApiKeyWithEndpoint { secret, endpoint } - } - StoredValue::OAuth { tokens } => Credential::OAuth(tokens), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredEntry { - key: CredentialKey, - value: StoredValue, -} - -#[derive(Debug, Default, Serialize, Deserialize)] -struct AuthFile { - credentials: Vec, -} - -#[derive(Debug, thiserror::Error)] -pub enum AuthError { - #[error("json error: {0}")] - Json(#[from] serde_json::Error), - #[error("io error: {0}")] - Io(#[from] std::io::Error), - #[error("credential store at {path} is corrupt: {source}")] - Corrupt { - path: PathBuf, - source: serde_json::Error, - }, - #[error("oauth error: {0}")] - OAuth(String), -} - -pub struct Pkce { - pub verifier: String, - pub challenge: String, -} - -impl Pkce { - pub fn generate() -> Self { - let bytes: [u8; 32] = std::array::from_fn(|_| rand::random::()); - let verifier = BASE64URL.encode(bytes); - let challenge = BASE64URL.encode(Sha256::digest(verifier.as_bytes())); - Self { - verifier, - challenge, - } - } -} - -pub fn random_state() -> String { - let bytes: [u8; 32] = std::array::from_fn(|_| rand::random::()); - BASE64URL.encode(bytes) -} - -fn form_urldecode(raw: &str) -> String { - let plus_decoded = raw.replace('+', " "); - percent_encoding::percent_decode_str(&plus_decoded) - .decode_utf8_lossy() - .into_owned() -} - -pub async fn bind_loopback() -> Result<(TcpListener, u16), AuthError> { - let listener = TcpListener::bind(("127.0.0.1", 0)).await?; - let port = listener.local_addr()?.port(); - Ok((listener, port)) -} - -pub async fn capture_loopback_code(port: u16, expected_state: &str) -> Result { - let listener = TcpListener::bind(("127.0.0.1", port)).await?; - capture_on(listener, expected_state).await -} - -const LOGIN_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(5); - -pub async fn capture_on(listener: TcpListener, expected_state: &str) -> Result { - tokio::time::timeout(LOGIN_TIMEOUT, capture_loop(listener, expected_state)) - .await - .map_err(|_| AuthError::OAuth("login timed out".to_owned()))? -} - -async fn capture_loop(listener: TcpListener, expected_state: &str) -> Result { - loop { - let (mut stream, _) = listener.accept().await?; - let mut buf = vec![0u8; 8192]; - let read = stream.read(&mut buf).await?; - let request = String::from_utf8_lossy(&buf[..read]); - let target = request - .lines() - .next() - .and_then(|line| line.split_whitespace().nth(1)); - let Some(query) = target.and_then(|path| path.split_once('?')).map(|(_, q)| q) else { - let _ = stream - .write_all( - b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await; - continue; - }; - let mut code = None; - let mut state = None; - for pair in query.split('&') { - if let Some((key, value)) = pair.split_once('=') { - match form_urldecode(key).as_str() { - "code" => code = Some(form_urldecode(value)), - "state" => state = Some(form_urldecode(value)), - _ => {} - } - } - } - let body = "goat-code login complete. You can close this tab."; - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.flush().await; - if state.as_deref() != Some(expected_state) { - return Err(AuthError::OAuth("state mismatch".to_owned())); - } - return code.ok_or_else(|| AuthError::OAuth("missing authorization code".to_owned())); - } -} - -#[derive(Clone)] -pub struct CredentialStore { - path: PathBuf, -} - -struct TempCleanup { - path: Option, -} - -impl TempCleanup { - fn disarm(mut self) { - self.path = None; - } -} - -impl Drop for TempCleanup { - fn drop(&mut self) { - if let Some(path) = self.path.take() { - let _ = fs::remove_file(path); - } - } -} - -impl CredentialStore { - pub fn new(path: PathBuf) -> Self { - Self { path } - } - - pub fn resolve(&self, key: &CredentialKey, env_var: Option<&str>) -> Option { - if let Some(var) = env_var - && let Ok(value) = std::env::var(var) - && !value.is_empty() - { - return Some(Credential::ApiKey(SecretString::from(value))); - } - self.file_get(key) - } - - pub fn store(&self, key: &CredentialKey, value: Credential) -> Result<(), AuthError> { - self.file_set(key, value) - } - - pub fn get(&self, key: &CredentialKey) -> Option { - self.file_get(key) - } - - pub fn entries(&self) -> Vec<(CredentialKey, CredentialKind)> { - self.read_file() - .credentials - .into_iter() - .map(|entry| { - let resolved: Credential = entry.value.into(); - (entry.key, resolved.kind()) - }) - .collect() - } - - pub fn remove(&self, key: &CredentialKey) -> Result { - let mut file = self.load_file()?; - let before = file.credentials.len(); - file.credentials.retain(|entry| &entry.key != key); - let removed = file.credentials.len() != before; - if removed { - self.save_file(&file)?; - } - Ok(removed) - } - - fn load_file(&self) -> Result { - let raw = match fs::read_to_string(&self.path) { - Ok(raw) => raw, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - return Ok(AuthFile::default()); - } - Err(err) => return Err(err.into()), - }; - serde_json::from_str(&raw).map_err(|source| AuthError::Corrupt { - path: self.path.clone(), - source, - }) - } - - fn read_file(&self) -> AuthFile { - match self.load_file() { - Ok(file) => file, - Err(err) => { - tracing::error!(error = %err, "failed to read credential store; treating as empty"); - AuthFile::default() - } - } - } - - fn save_file(&self, file: &AuthFile) -> Result<(), AuthError> { - if let Some(parent) = self.path.parent() { - fs::create_dir_all(parent)?; - } - let contents = serde_json::to_string_pretty(file)?; - let parent = self.path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = self.path.file_name().map_or_else( - || "auth.json".to_owned(), - |name| name.to_string_lossy().into_owned(), - ); - let tmp_path = parent.join(format!("{file_name}.tmp-{}", std::process::id())); - let cleanup = TempCleanup { - path: Some(tmp_path.clone()), - }; - { - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut handle = match options.open(&tmp_path) { - Ok(handle) => handle, - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - let _ = fs::remove_file(&tmp_path); - options.open(&tmp_path)? - } - Err(err) => return Err(err.into()), - }; - std::io::Write::write_all(&mut handle, contents.as_bytes())?; - handle.sync_all()?; - } - fs::rename(&tmp_path, &self.path)?; - cleanup.disarm(); - #[cfg(unix)] - if let Ok(dir) = fs::File::open(parent) { - let _ = dir.sync_all(); - } - Ok(()) - } - - fn file_get(&self, key: &CredentialKey) -> Option { - self.read_file() - .credentials - .into_iter() - .find(|entry| &entry.key == key) - .map(|entry| entry.value.into()) - } - - fn file_set(&self, key: &CredentialKey, value: Credential) -> Result<(), AuthError> { - let mut file = self.load_file()?; - let stored = StoredValue::from(value); - if let Some(entry) = file.credentials.iter_mut().find(|entry| &entry.key == key) { - entry.value = stored; - } else { - file.credentials.push(StoredEntry { - key: key.clone(), - value: stored, - }); - } - self.save_file(&file) - } -} - -#[cfg(test)] -mod tests { - use super::{ - Credential, CredentialKey, CredentialKind, CredentialService, CredentialStore, Pkce, - SecretString, TokenSet, ensure_valid, now_secs, - }; - - #[tokio::test] - async fn ensure_valid_single_flights_concurrent_refresh() { - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - let path = std::env::temp_dir().join("goat-auth-singleflight-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path.clone()); - let key = CredentialKey::model("goat-singleflight", "a"); - let expired = TokenSet { - access_token: SecretString::from("old"), - refresh_token: Some(SecretString::from("refresh")), - expires_at: Some(now_secs() - 100), - }; - let calls = Arc::new(AtomicUsize::new(0)); - let mut handles = Vec::new(); - for _ in 0..8 { - let store = store.clone(); - let key = key.clone(); - let tokens = expired.clone(); - let calls = calls.clone(); - handles.push(tokio::spawn(async move { - ensure_valid(tokens, &store, &key, |_| { - let calls = calls.clone(); - async move { - calls.fetch_add(1, Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - Ok(TokenSet { - access_token: SecretString::from("new"), - refresh_token: Some(SecretString::from("refresh2")), - expires_at: Some(now_secs() + 3600), - }) - } - }) - .await - })); - } - for handle in handles { - let result = handle.await.unwrap(); - assert!(matches!(result, Some(t) if t.access_token.expose() == "new")); - } - assert_eq!(calls.load(Ordering::SeqCst), 1); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn legacy_key_defaults_to_model_service() { - let key: CredentialKey = - serde_json::from_str(r#"{"provider":"openai","account":"default"}"#).unwrap(); - assert_eq!(key.service, CredentialService::Model); - assert_eq!(key.provider, "openai"); - assert_eq!(key.account, "default"); - } - - #[test] - fn pkce_generates_s256_challenge() { - use base64::Engine; - use sha2::{Digest, Sha256}; - let pkce = Pkce::generate(); - assert_eq!(pkce.verifier.len(), 43); - assert_eq!( - pkce.challenge, - super::BASE64URL.encode(Sha256::digest(pkce.verifier.as_bytes())) - ); - } - - #[test] - fn secret_string_debug_is_redacted() { - let secret = SecretString::from("topsecret"); - assert_eq!(format!("{secret:?}"), "SecretString(***)"); - assert_eq!(secret.expose(), "topsecret"); - } - - #[test] - fn secret_string_serializes_transparently() { - let secret = SecretString::from("abc"); - assert_eq!(serde_json::to_string(&secret).unwrap(), "\"abc\""); - } - - #[test] - fn resolved_credential_kind() { - let cred = Credential::ApiKey(SecretString::from("k")); - assert_eq!(cred.kind(), CredentialKind::ApiKey); - } - - #[cfg(unix)] - #[test] - fn saved_file_is_owner_only_and_atomic() { - use std::os::unix::fs::PermissionsExt; - let path = std::env::temp_dir().join("goat-auth-perms-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path.clone()); - let key = CredentialKey::model("p", "a"); - store - .file_set(&key, Credential::ApiKey(SecretString::from("secret"))) - .unwrap(); - let mode = std::fs::metadata(&path).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - let got = store.file_get(&key).unwrap(); - assert!(matches!(got, Credential::ApiKey(secret) if secret.expose() == "secret")); - let leftover = std::fs::read_dir(path.parent().unwrap()) - .unwrap() - .filter_map(Result::ok) - .any(|e| { - e.file_name() - .to_string_lossy() - .contains("goat-auth-perms-test.json.tmp-") - }); - assert!(!leftover, "temp file should be cleaned up"); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn file_store_roundtrip() { - let path = std::env::temp_dir().join("goat-auth-file-roundtrip-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path.clone()); - let key = CredentialKey::model("p", "a"); - store - .file_set(&key, Credential::ApiKey(SecretString::from("k"))) - .unwrap(); - let got = store.file_get(&key).unwrap(); - assert!(matches!(got, Credential::ApiKey(secret) if secret.expose() == "k")); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn resolve_prefers_env() { - let path = std::env::temp_dir().join("goat-auth-env-pref-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path); - let key = CredentialKey::model("goat-test-noexist", "x"); - let cred = store.resolve(&key, Some("PATH")).unwrap(); - assert!(matches!(cred, Credential::ApiKey(_))); - } - - #[test] - fn resolve_absent_is_none() { - let path = std::env::temp_dir().join("goat-auth-absent-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path); - let key = CredentialKey::model("goat-test-absent-xyz", "none"); - assert!( - store - .resolve(&key, Some("GOAT_DEFINITELY_NOT_SET_VAR_42")) - .is_none() - ); - } - - #[test] - fn corrupt_file_is_not_overwritten_on_store() { - let path = std::env::temp_dir().join("goat-auth-corrupt-test.json"); - std::fs::write(&path, "{ not valid json").unwrap(); - let store = CredentialStore::new(path.clone()); - let key = CredentialKey::model("p", "a"); - let result = store.store(&key, Credential::ApiKey(SecretString::from("k"))); - assert!(matches!(result, Err(super::AuthError::Corrupt { .. }))); - let on_disk = std::fs::read_to_string(&path).unwrap(); - assert_eq!(on_disk, "{ not valid json"); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn missing_file_loads_as_empty() { - let path = std::env::temp_dir().join("goat-auth-missing-test.json"); - let _ = std::fs::remove_file(&path); - let store = CredentialStore::new(path.clone()); - assert!(store.entries().is_empty()); - let key = CredentialKey::model("p", "a"); - store - .store(&key, Credential::ApiKey(SecretString::from("k"))) - .unwrap(); - assert_eq!(store.entries().len(), 1); - let _ = std::fs::remove_file(&path); - } - - #[test] - fn token_set_is_expired() { - let expired = TokenSet { - access_token: SecretString::from("a"), - refresh_token: None, - expires_at: Some(0), - }; - assert!(expired.is_expired()); - let fresh = TokenSet { - access_token: SecretString::from("a"), - refresh_token: None, - expires_at: Some(i64::MAX), - }; - assert!(!fresh.is_expired()); - let no_expiry = TokenSet { - access_token: SecretString::from("a"), - refresh_token: None, - expires_at: None, - }; - assert!(!no_expiry.is_expired()); - } - - #[test] - fn token_set_from_parts() { - let ts = TokenSet::from_parts( - "access".to_owned(), - Some("refresh".to_owned()), - Some(3600), - None, - ); - assert_eq!(ts.access_token.expose(), "access"); - assert_eq!(ts.refresh_token.as_ref().unwrap().expose(), "refresh"); - assert!(ts.expires_at.is_some()); - } - - #[test] - fn token_set_from_parts_fallback_refresh() { - let ts = TokenSet::from_parts("access".to_owned(), None, None, Some("fallback")); - assert_eq!(ts.refresh_token.as_ref().unwrap().expose(), "fallback"); - } - - #[test] - fn form_urldecode_handles_percent_and_plus() { - assert_eq!(super::form_urldecode("a%2Fb%3Dc"), "a/b=c"); - assert_eq!(super::form_urldecode("one+two"), "one two"); - assert_eq!(super::form_urldecode("plain"), "plain"); - } -} diff --git a/crates/goat-code/src/main.rs b/crates/goat-code/src/main.rs index bd17fe1..640bba9 100644 --- a/crates/goat-code/src/main.rs +++ b/crates/goat-code/src/main.rs @@ -21,6 +21,10 @@ use crate::{ async fn main() -> color_eyre::Result<()> { let cli = Cli::parse(); + if let Err(message) = goat_config::check_legacy_layout() { + return Err(eyre!(message)); + } + if cli.print_log_path { reject_worktree(cli.worktree.as_ref())?; reject_continue(cli.r#continue)?; diff --git a/crates/goat-config/Cargo.toml b/crates/goat-config/Cargo.toml index 8f13ce1..d4a2a3e 100644 --- a/crates/goat-config/Cargo.toml +++ b/crates/goat-config/Cargo.toml @@ -11,6 +11,7 @@ publish = false serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +goat-search-provider = { workspace = true } [lints] workspace = true diff --git a/crates/goat-config/src/lib.rs b/crates/goat-config/src/lib.rs index c0328b9..128e359 100644 --- a/crates/goat-config/src/lib.rs +++ b/crates/goat-config/src/lib.rs @@ -28,44 +28,7 @@ pub struct SearchConfig { pub accounts: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "provider", rename_all = "snake_case")] -pub enum SearchAccountConfig { - Duckduckgo { - account: String, - }, - Browser { - account: String, - #[serde(default = "default_browser_search_engine")] - engine: String, - }, - Searxng { - account: String, - endpoint: String, - }, - Brave { - account: String, - }, - Tavily { - account: String, - }, -} - -fn default_browser_search_engine() -> String { - "duckduckgo".to_owned() -} - -impl SearchAccountConfig { - pub fn target(&self) -> String { - match self { - Self::Duckduckgo { account } => format!("duckduckgo/{account}"), - Self::Browser { account, .. } => format!("browser/{account}"), - Self::Searxng { account, .. } => format!("searxng/{account}"), - Self::Brave { account } => format!("brave/{account}"), - Self::Tavily { account } => format!("tavily/{account}"), - } - } -} +pub use goat_search_provider::SearchAccountConfig; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] @@ -136,10 +99,14 @@ pub enum ConfigError { Io(#[from] std::io::Error), } -pub const HOME_NOT_FOUND: &str = "could not resolve ~/.goat-code"; +pub const HOME_NOT_FOUND: &str = "could not resolve ~/.goat/code"; + +fn shared_home() -> Option { + std::env::home_dir().map(|home| home.join(".goat")) +} fn app_home() -> Option { - std::env::home_dir().map(|home| home.join(".goat-code")) + shared_home().map(|home| home.join("code")) } pub fn config_path() -> Option { @@ -155,7 +122,7 @@ pub fn db_path() -> Option { } pub fn auth_path() -> Option { - app_home().map(|home| home.join("auth.json")) + shared_home().map(|home| home.join("credentials.json")) } pub fn log_dir() -> Option { @@ -206,6 +173,21 @@ pub fn rate_limits_path() -> Option { app_home().map(|home| home.join("rate_limits.json")) } +pub fn check_legacy_layout() -> Result<(), String> { + let Some(home) = std::env::home_dir() else { + return Ok(()); + }; + let legacy = home.join(".goat-code"); + if legacy.exists() { + return Err(format!( + "detected the old {} layout: move it to ~/.goat/code, move ~/.goat-code/auth.json \ + to ~/.goat/credentials.json, then remove ~/.goat-code", + legacy.display() + )); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::{Config, RemoteConfig, SearchConfig, ThemeChoice}; diff --git a/crates/goat-protocol/Cargo.toml b/crates/goat-protocol/Cargo.toml deleted file mode 100644 index 7d84bba..0000000 --- a/crates/goat-protocol/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "goat-protocol" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -serde = { workspace = true } -schemars = { workspace = true } - -[dev-dependencies] -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-protocol/src/event.rs b/crates/goat-protocol/src/event.rs deleted file mode 100644 index 65619ac..0000000 --- a/crates/goat-protocol/src/event.rs +++ /dev/null @@ -1,232 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::{ - AccountEntry, InputAttachment, LoginProvider, ModelEntry, ModelTarget, ProcessExitReason, - ProcessId, ProcessInfo, RateLimitSnapshot, SkillInfo, TaskId, ThreadSummary, ToolCall, - ToolCallId, ToolOutcome, TranscriptEntry, Usage, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum NotifyKind { - Info, - Success, - Error, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type")] -pub enum Event { - TaskStarted { - id: TaskId, - }, - TextDelta { - id: TaskId, - chunk: String, - }, - TextDone { - id: TaskId, - text: String, - }, - ToolStarted { - id: TaskId, - call: ToolCall, - }, - ToolDone { - id: TaskId, - call: ToolCallId, - outcome: ToolOutcome, - }, - ShellDone { - id: TaskId, - output: String, - }, - TaskDone { - id: TaskId, - interrupted: bool, - }, - AgentStarted { - id: TaskId, - parent: TaskId, - agent_type: String, - label: String, - }, - AgentDone { - id: TaskId, - ok: bool, - }, - ModelListChanged { - entries: Vec, - }, - ModelSelected { - target: ModelTarget, - }, - ThreadsListed { - threads: Vec, - }, - ConversationRestored { - target: ModelTarget, - entries: Vec, - context_tokens: Option, - compaction_threshold: Option, - }, - ThinkingDelta { - id: TaskId, - chunk: String, - }, - LoginProviders { - providers: Vec, - }, - LoginStatus { - provider: String, - message: String, - done: bool, - ok: bool, - }, - AccountsChanged { - providers: Vec, - }, - SkillsChanged { - skills: Vec, - }, - Error { - id: Option, - message: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - hint: Option, - }, - Notify { - kind: NotifyKind, - message: String, - }, - AskStarted { - id: TaskId, - call: ToolCallId, - questions: Vec, - }, - AskDismissed { - id: TaskId, - call: ToolCallId, - }, - Usage { - id: TaskId, - provider: String, - account: String, - usage: Usage, - context_window: Option, - compaction_threshold: Option, - }, - RateLimits { - provider: String, - account: String, - snapshot: RateLimitSnapshot, - cached_at: i64, - }, - Retrying { - id: TaskId, - attempt: u32, - max_attempts: u32, - delay_ms: u64, - reason: String, - }, - UserMessage { - id: TaskId, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - display: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - attachments: Vec, - }, - MessageDequeued { - id: TaskId, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - display: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - attachments: Vec, - }, - CompactionStarted { - id: TaskId, - }, - CompactionDone { - id: TaskId, - ok: bool, - tokens_before: u32, - tokens_after: u32, - usage: Usage, - }, - ThreadBound { - thread_id: i64, - }, - ProcessStarted { - process: ProcessId, - command: String, - watched: bool, - }, - ProcessOutput { - process: ProcessId, - chunk: String, - }, - ProcessExited { - process: ProcessId, - #[serde(default, skip_serializing_if = "Option::is_none")] - code: Option, - reason: ProcessExitReason, - }, - ProcessObserved { - id: TaskId, - process: ProcessId, - command: String, - output: String, - }, - ProcessListChanged { - processes: Vec, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AskOption { - pub label: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AskQuestion { - pub question: String, - #[serde(default)] - pub options: Vec, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub multiple: bool, -} - -#[cfg(test)] -mod tests { - use super::AskQuestion; - - #[test] - fn multiple_defaults_to_false_when_absent() { - let q: AskQuestion = serde_json::from_str(r#"{"question":"Pick?"}"#).unwrap(); - assert!(!q.multiple); - } - - #[test] - fn multiple_false_is_omitted_from_serialization() { - let q = AskQuestion { - question: "Pick?".to_owned(), - options: Vec::new(), - multiple: false, - }; - let json = serde_json::to_string(&q).unwrap(); - assert!(!json.contains("multiple")); - } - - #[test] - fn multiple_true_round_trips() { - let q: AskQuestion = - serde_json::from_str(r#"{"question":"Pick?","multiple":true}"#).unwrap(); - assert!(q.multiple); - let json = serde_json::to_string(&q).unwrap(); - assert!(json.contains("\"multiple\":true")); - } -} diff --git a/crates/goat-protocol/src/lib.rs b/crates/goat-protocol/src/lib.rs deleted file mode 100644 index 11294bd..0000000 --- a/crates/goat-protocol/src/lib.rs +++ /dev/null @@ -1,211 +0,0 @@ -mod event; -mod op; -mod types; - -pub use event::{AskOption, AskQuestion, Event, NotifyKind}; -pub use op::Op; -pub use types::*; - -#[cfg(test)] -mod tests { - use super::{ - Event, LoginCredential, Op, TaskId, ToolCallId, ToolImageData, ToolOutcome, TranscriptEntry, - }; - - #[test] - fn tool_outcome_image_round_trips() { - let outcome = ToolOutcome { - ok: true, - summary: Some("captured".to_owned()), - image: Some(ToolImageData { - media_type: "image/png".to_owned(), - data: "AAAA".to_owned(), - }), - }; - let json = serde_json::to_string(&outcome).unwrap(); - let back: ToolOutcome = serde_json::from_str(&json).unwrap(); - assert_eq!(outcome, back); - } - - #[test] - fn tool_outcome_without_image_omits_field() { - let outcome = ToolOutcome { - ok: false, - summary: None, - image: None, - }; - let json = serde_json::to_string(&outcome).unwrap(); - assert!(!json.contains("image")); - let back: ToolOutcome = serde_json::from_str(&json).unwrap(); - assert_eq!(outcome, back); - } - - #[test] - fn op_unit_variants_serialize_as_type_object() { - assert_eq!( - serde_json::to_string(&Op::Clear {}).unwrap(), - r#"{"type":"Clear"}"# - ); - assert_eq!( - serde_json::to_string(&Op::ListThreads {}).unwrap(), - r#"{"type":"ListThreads"}"# - ); - assert_eq!( - serde_json::to_string(&Op::ResumeLatest {}).unwrap(), - r#"{"type":"ResumeLatest"}"# - ); - assert_eq!( - serde_json::to_string(&Op::Shutdown {}).unwrap(), - r#"{"type":"Shutdown"}"# - ); - } - - #[test] - fn op_struct_variants_serialize_flat_with_type() { - let op = Op::SubmitMessage { - id: TaskId(1), - text: "hi".to_owned(), - display: None, - attachments: Vec::new(), - }; - let json = serde_json::to_string(&op).unwrap(); - assert_eq!(json, r#"{"type":"SubmitMessage","id":"1","text":"hi"}"#); - let back: Op = serde_json::from_str(&json).unwrap(); - assert_eq!(back, op); - } - - #[test] - fn event_serializes_flat_with_type() { - let ev = Event::TextDelta { - id: TaskId(1), - chunk: "x".to_owned(), - }; - let json = serde_json::to_string(&ev).unwrap(); - assert_eq!(json, r#"{"type":"TextDelta","id":"1","chunk":"x"}"#); - let back: Event = serde_json::from_str(&json).unwrap(); - assert_eq!(back, ev); - } - - #[test] - fn transcript_entry_user_serializes_with_type() { - let entry = TranscriptEntry::User { - text: "hello".to_owned(), - attachments: Vec::new(), - }; - let json = serde_json::to_string(&entry).unwrap(); - assert_eq!(json, r#"{"type":"User","text":"hello"}"#); - let back: TranscriptEntry = serde_json::from_str(&json).unwrap(); - assert_eq!(back, entry); - } - - #[test] - fn login_credential_api_key_serializes_with_type() { - let cred = LoginCredential::ApiKey { - key: "sk-x".to_owned(), - }; - let json = serde_json::to_string(&cred).unwrap(); - assert_eq!(json, r#"{"type":"ApiKey","key":"sk-x"}"#); - let back: LoginCredential = serde_json::from_str(&json).unwrap(); - assert_eq!(back, cred); - } - - #[test] - fn op_answer_roundtrips() { - let op = Op::Answer { - id: TaskId(2), - call: ToolCallId(5), - answers: vec!["yes".to_owned()], - }; - let json = serde_json::to_string(&op).unwrap(); - let back: Op = serde_json::from_str(&json).unwrap(); - assert_eq!(back, op); - } - - #[test] - fn task_id_serializes_as_string() { - assert_eq!(serde_json::to_string(&TaskId(42)).unwrap(), r#""42""#); - } - - #[test] - fn task_id_deserializes_from_string_and_number() { - let from_str: TaskId = serde_json::from_str(r#""42""#).unwrap(); - let from_num: TaskId = serde_json::from_str("42").unwrap(); - assert_eq!(from_str, TaskId(42)); - assert_eq!(from_num, TaskId(42)); - } - - #[test] - fn task_id_above_js_safe_integer_roundtrips() { - let big = TaskId(9_007_199_254_740_993); - let json = serde_json::to_string(&big).unwrap(); - assert_eq!(json, r#""9007199254740993""#); - let back: TaskId = serde_json::from_str(&json).unwrap(); - assert_eq!(back, big); - } - - #[test] - fn process_id_serializes_as_string() { - assert_eq!( - serde_json::to_string(&super::ProcessId(7)).unwrap(), - r#""7""# - ); - } - - #[test] - fn op_process_kill_roundtrips() { - let op = Op::ProcessKill { - process: super::ProcessId(3), - }; - let json = serde_json::to_string(&op).unwrap(); - assert_eq!(json, r#"{"type":"ProcessKill","process":"3"}"#); - let back: Op = serde_json::from_str(&json).unwrap(); - assert_eq!(back, op); - } - - #[test] - fn op_process_watch_roundtrips() { - let op = Op::ProcessWatch { - process: super::ProcessId(4), - on: true, - }; - let json = serde_json::to_string(&op).unwrap(); - let back: Op = serde_json::from_str(&json).unwrap(); - assert_eq!(back, op); - } - - #[test] - fn event_process_started_roundtrips() { - let ev = Event::ProcessStarted { - process: super::ProcessId(1), - command: "pnpm dev".to_owned(), - watched: false, - }; - let json = serde_json::to_string(&ev).unwrap(); - let back: Event = serde_json::from_str(&json).unwrap(); - assert_eq!(back, ev); - } - - #[test] - fn event_process_exited_omits_code_when_absent() { - let ev = Event::ProcessExited { - process: super::ProcessId(1), - code: None, - reason: super::ProcessExitReason::Killed, - }; - let json = serde_json::to_string(&ev).unwrap(); - assert!(!json.contains("code")); - let back: Event = serde_json::from_str(&json).unwrap(); - assert_eq!(back, ev); - } - - #[test] - fn transcript_entry_process_roundtrips() { - let entry = TranscriptEntry::Process { - command: "pnpm dev".to_owned(), - output: "ready".to_owned(), - }; - let json = serde_json::to_string(&entry).unwrap(); - let back: TranscriptEntry = serde_json::from_str(&json).unwrap(); - assert_eq!(back, entry); - } -} diff --git a/crates/goat-protocol/src/op.rs b/crates/goat-protocol/src/op.rs deleted file mode 100644 index c4931b9..0000000 --- a/crates/goat-protocol/src/op.rs +++ /dev/null @@ -1,68 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::{InputAttachment, LoginCredential, ModelTarget, ProcessId, TaskId, ToolCallId}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type")] -pub enum Op { - SubmitMessage { - id: TaskId, - text: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - display: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - attachments: Vec, - }, - SubmitShell { - id: TaskId, - command: String, - }, - Interrupt { - id: TaskId, - }, - Clear {}, - SelectModel { - target: ModelTarget, - }, - Login { - provider: String, - credential: LoginCredential, - }, - AddAccount { - provider: String, - name: String, - credential: LoginCredential, - }, - RemoveAccount { - provider: String, - name: String, - }, - ListThreads {}, - Resume { - thread_id: i64, - }, - ResumeLatest {}, - RenameThread { - title: String, - }, - Answer { - id: TaskId, - call: ToolCallId, - answers: Vec, - }, - Compact { - id: TaskId, - instructions: Option, - }, - DequeueMessage { - id: TaskId, - }, - ProcessKill { - process: ProcessId, - }, - ProcessWatch { - process: ProcessId, - on: bool, - }, - Shutdown {}, -} diff --git a/crates/goat-protocol/src/types.rs b/crates/goat-protocol/src/types.rs deleted file mode 100644 index 122a770..0000000 --- a/crates/goat-protocol/src/types.rs +++ /dev/null @@ -1,434 +0,0 @@ -use std::fmt; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -pub mod id_serde { - use super::{Deserializer, Serializer}; - use serde::de::Visitor; - - pub fn serialize(v: &u64, s: S) -> Result { - s.serialize_str(&v.to_string()) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { - struct V; - impl Visitor<'_> for V { - type Value = u64; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("u64 as string or integer") - } - fn visit_str(self, v: &str) -> Result { - v.parse().map_err(E::custom) - } - fn visit_u64(self, v: u64) -> Result { - Ok(v) - } - fn visit_i64(self, v: i64) -> Result { - u64::try_from(v).map_err(E::custom) - } - } - d.deserialize_any(V) - } -} - -fn id_json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - String::json_schema(generator) -} - -use schemars::JsonSchema; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, schemars::JsonSchema)] -pub struct Usage { - pub input_tokens: u32, - pub output_tokens: u32, - pub cache_read_tokens: u32, - pub cache_write_tokens: u32, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct RateWindow { - pub label: String, - pub used_percent: f32, - pub resets_at: Option, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct RateLimitSnapshot { - pub windows: Vec, - #[serde(default)] - pub representative: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct TaskId(pub u64); - -impl Serialize for TaskId { - fn serialize(&self, s: S) -> Result { - id_serde::serialize(&self.0, s) - } -} - -impl<'de> Deserialize<'de> for TaskId { - fn deserialize>(d: D) -> Result { - id_serde::deserialize(d).map(Self) - } -} - -impl fmt::Display for TaskId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl JsonSchema for TaskId { - fn schema_name() -> std::borrow::Cow<'static, str> { - "TaskId".into() - } - fn schema_id() -> std::borrow::Cow<'static, str> { - concat!(module_path!(), "::TaskId").into() - } - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - id_json_schema(generator) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ToolCallId(pub u64); - -impl Serialize for ToolCallId { - fn serialize(&self, s: S) -> Result { - id_serde::serialize(&self.0, s) - } -} - -impl<'de> Deserialize<'de> for ToolCallId { - fn deserialize>(d: D) -> Result { - id_serde::deserialize(d).map(Self) - } -} - -impl fmt::Display for ToolCallId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl JsonSchema for ToolCallId { - fn schema_name() -> std::borrow::Cow<'static, str> { - "ToolCallId".into() - } - fn schema_id() -> std::borrow::Cow<'static, str> { - concat!(module_path!(), "::ToolCallId").into() - } - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - id_json_schema(generator) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub struct ProcessId(pub u64); - -impl Serialize for ProcessId { - fn serialize(&self, s: S) -> Result { - id_serde::serialize(&self.0, s) - } -} - -impl<'de> Deserialize<'de> for ProcessId { - fn deserialize>(d: D) -> Result { - id_serde::deserialize(d).map(Self) - } -} - -impl fmt::Display for ProcessId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl JsonSchema for ProcessId { - fn schema_name() -> std::borrow::Cow<'static, str> { - "ProcessId".into() - } - fn schema_id() -> std::borrow::Cow<'static, str> { - concat!(module_path!(), "::ProcessId").into() - } - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - id_json_schema(generator) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ProcessState { - Running, - Exited, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ProcessExitReason { - Natural, - Killed, - Timeout, - Shutdown, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ProcessInfo { - pub id: ProcessId, - pub command: String, - pub state: ProcessState, - pub watched: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exit_code: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ToolDisplay { - pub primary: String, - pub detail: Option, -} - -impl ToolDisplay { - pub fn primary(primary: impl Into) -> Self { - Self { - primary: primary.into(), - detail: None, - } - } - - pub fn with_detail(primary: impl Into, detail: impl Into) -> Self { - Self { - primary: primary.into(), - detail: Some(detail.into()), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ToolCall { - pub id: ToolCallId, - pub name: String, - pub display: ToolDisplay, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ToolImageData { - pub media_type: String, - pub data: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct InputAttachment { - pub media_type: String, - pub data: String, - pub label: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ToolOutcome { - pub ok: bool, - pub summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub image: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum Effort { - Off, - Low, - Medium, - High, - Xhigh, - Max, -} - -impl Effort { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Off => "off", - Self::Low => "low", - Self::Medium => "medium", - Self::High => "high", - Self::Xhigh => "xhigh", - Self::Max => "max", - } - } - - #[must_use] - pub fn parse(value: &str) -> Option { - match value { - "off" => Some(Self::Off), - "low" => Some(Self::Low), - "medium" => Some(Self::Medium), - "high" => Some(Self::High), - "xhigh" => Some(Self::Xhigh), - "max" => Some(Self::Max), - _ => None, - } - } -} - -impl fmt::Display for Effort { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ModelTarget { - pub provider: String, - pub model: String, - pub account: String, - #[serde(default)] - pub effort: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AccountChoice { - pub id: String, - pub display: String, - pub target: ModelTarget, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ModelEntry { - pub provider: String, - pub model: String, - pub accounts: Vec, - pub context_window: Option, - #[serde(default)] - pub supports_images: bool, - #[serde(default)] - pub efforts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ThreadSummary { - pub id: i64, - pub title: String, - pub model: String, - pub updated_at: i64, - #[serde(default)] - pub live: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type")] -pub enum TranscriptEntry { - User { - text: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - attachments: Vec, - }, - Assistant { - text: String, - }, - Thinking { - text: String, - }, - Tool { - call: ToolCall, - outcome: ToolOutcome, - }, - Compaction { - tokens_before: u32, - tokens_after: u32, - }, - Shell { - command: String, - output: String, - }, - Process { - command: String, - output: String, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum AuthMethod { - None, - ApiKey, - OAuth, - ApiKeyOrOAuth, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct LoginProvider { - pub id: String, - pub method: AuthMethod, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AccountInfo { - pub name: String, - pub method: AuthMethod, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct AccountEntry { - pub provider: String, - pub display_name: String, - pub accounts: Vec, - pub local: bool, - pub login: AuthMethod, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type")] -pub enum LoginCredential { - ApiKey { key: String }, - OAuth {}, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct SkillInfo { - pub name: String, - pub description: String, - #[serde(default)] - pub command: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SkillCommandShape { - Arguments { items: Vec }, - Subcommands { items: Vec }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct SkillBranchInfo { - pub name: String, - pub description: String, - #[serde(default)] - pub arguments: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct SkillParameterInfo { - pub name: String, - pub description: String, - #[serde(default)] - pub required: bool, - pub value: SkillParameterValue, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum SkillParameterValue { - Word {}, - Integer {}, - Choice { options: Vec }, - TextTail {}, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -pub struct SkillChoiceInfo { - pub value: String, - #[serde(default)] - pub description: Option, -} diff --git a/crates/goat-provider-anthropic/Cargo.toml b/crates/goat-provider-anthropic/Cargo.toml deleted file mode 100644 index 0e20cce..0000000 --- a/crates/goat-provider-anthropic/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "goat-provider-anthropic" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-auth = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -futures = { workspace = true } -eventsource-stream = { workspace = true } -tokio = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -open = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-anthropic/src/auth.rs b/crates/goat-provider-anthropic/src/auth.rs deleted file mode 100644 index 59530cf..0000000 --- a/crates/goat-provider-anthropic/src/auth.rs +++ /dev/null @@ -1,154 +0,0 @@ -use goat_auth::{ - Credential, CredentialKey, CredentialStore, Pkce, TokenSet, ensure_valid, random_state, -}; -use serde::Deserialize; -use serde_json::json; -use tokio::sync::mpsc; - -use crate::{ENV_VAR, OAUTH_AUTHORIZE, OAUTH_CLIENT_ID, OAUTH_SCOPE, OAUTH_TOKEN, OAUTH_TOKEN_UA}; - -#[derive(Debug, thiserror::Error)] -pub enum AnthropicAuthError { - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("url error: {0}")] - Url(String), - #[error("token error: {0}")] - Token(String), - #[error("auth error: {0}")] - Auth(#[from] goat_auth::AuthError), -} - -pub(crate) enum Auth { - ApiKey(String), - OAuth(String), -} - -pub(crate) fn authorize_url( - challenge: &str, - state: &str, - redirect_uri: &str, -) -> Result { - reqwest::Url::parse_with_params( - OAUTH_AUTHORIZE, - &[ - ("code", "true"), - ("client_id", OAUTH_CLIENT_ID), - ("response_type", "code"), - ("redirect_uri", redirect_uri), - ("scope", OAUTH_SCOPE), - ("code_challenge", challenge), - ("code_challenge_method", "S256"), - ("state", state), - ], - ) - .map(|url| url.to_string()) - .map_err(|err| AnthropicAuthError::Url(err.to_string())) -} - -pub(crate) async fn do_login( - status: &mpsc::Sender, -) -> Result { - let pkce = Pkce::generate(); - let state = random_state(); - let (listener, port) = goat_auth::bind_loopback().await?; - let redirect = format!("http://localhost:{port}/callback"); - let url = authorize_url(&pkce.challenge, &state, &redirect)?; - let _ = status - .send(format!( - "opening browser to sign in\u{2026} if it does not open, visit:\n{url}" - )) - .await; - let _ = open::that(&url); - let code = goat_auth::capture_on(listener, &state).await?; - exchange_code(&code, &pkce.verifier, &state, &redirect).await -} - -fn auth_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client") -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, -} - -async fn exchange_code( - code: &str, - verifier: &str, - state: &str, - redirect_uri: &str, -) -> Result { - let response = auth_client() - .post(OAUTH_TOKEN) - .header("Accept", "application/json, text/plain, */*") - .header("User-Agent", OAUTH_TOKEN_UA) - .json(&json!({ - "grant_type": "authorization_code", - "code": code, - "state": state, - "client_id": OAUTH_CLIENT_ID, - "redirect_uri": redirect_uri, - "code_verifier": verifier, - })) - .send() - .await?; - parse_token_response(response) - .await - .map(|t| TokenSet::from_parts(t.access_token, t.refresh_token, t.expires_in, None)) -} - -async fn do_refresh(refresh_token: String) -> Result { - let response = auth_client() - .post(OAUTH_TOKEN) - .header("Accept", "application/json, text/plain, */*") - .header("User-Agent", OAUTH_TOKEN_UA) - .json(&json!({ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": OAUTH_CLIENT_ID, - })) - .send() - .await - .map_err(|e| e.to_string())?; - parse_token_response(response) - .await - .map(|t| { - TokenSet::from_parts( - t.access_token, - t.refresh_token, - t.expires_in, - Some(&refresh_token), - ) - }) - .map_err(|e| e.to_string()) -} - -async fn parse_token_response( - response: reqwest::Response, -) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(AnthropicAuthError::Token(format!("{status}: {body}"))); - } - response.json().await.map_err(AnthropicAuthError::Http) -} - -pub(crate) async fn current_auth(store: &CredentialStore, key: &CredentialKey) -> Option { - match store.resolve(key, Some(ENV_VAR))? { - Credential::ApiKey(secret) | Credential::ApiKeyWithEndpoint { secret, .. } => { - Some(Auth::ApiKey(secret.expose().to_owned())) - } - Credential::OAuth(tokens) => { - let tokens = ensure_valid(tokens, store, key, do_refresh).await?; - Some(Auth::OAuth(tokens.access_token.expose().to_owned())) - } - } -} diff --git a/crates/goat-provider-anthropic/src/error.rs b/crates/goat-provider-anthropic/src/error.rs deleted file mode 100644 index 1c2a8a3..0000000 --- a/crates/goat-provider-anthropic/src/error.rs +++ /dev/null @@ -1,319 +0,0 @@ -use std::time::Duration; - -use goat_provider::StreamError; -use serde::Deserialize; - -#[derive(Deserialize)] -struct ErrorEnvelope { - error: Option, -} - -#[derive(Default, Deserialize)] -struct ErrorBody { - #[serde(rename = "type", default)] - kind: String, - #[serde(default)] - message: String, -} - -fn parse_body(body: &str) -> ErrorBody { - serde_json::from_str::(body) - .ok() - .and_then(|envelope| envelope.error) - .unwrap_or_default() -} - -fn overflow_message(message: &str) -> bool { - message.starts_with("prompt is too long") || message.contains("exceed context limit") -} - -pub(crate) fn classify_http( - status: reqwest::StatusCode, - headers: &reqwest::header::HeaderMap, - body: &str, -) -> StreamError { - let parsed = parse_body(body); - let message = if parsed.message.is_empty() { - format!("{status}: {body}") - } else { - parsed.message - }; - match (status.as_u16(), parsed.kind.as_str()) { - (429, _) | (_, "rate_limit_error") => { - StreamError::rate_limited(message, parse_retry_after(headers)) - } - (529 | 408 | 504, _) | (_, "overloaded_error" | "api_error" | "timeout_error") => { - StreamError::overloaded(message) - } - (401 | 403, _) | (_, "authentication_error" | "permission_error") => { - StreamError::auth(message) - } - (413, _) => StreamError::context_overflow(message), - (400, _) | (_, "invalid_request_error") => { - if overflow_message(&message) { - StreamError::context_overflow(message) - } else { - StreamError::invalid_request(message) - } - } - (code, _) if (500..600).contains(&code) => StreamError::overloaded(message), - (code, _) if (400..500).contains(&code) => StreamError::invalid_request(message), - _ => StreamError::other(message), - } -} - -pub(crate) fn classify_sse_error(data: &str) -> StreamError { - let parsed = parse_body(data); - let message = if parsed.message.is_empty() { - data.to_owned() - } else { - parsed.message - }; - match parsed.kind.as_str() { - "rate_limit_error" => StreamError::rate_limited(message, None), - "overloaded_error" | "api_error" | "timeout_error" => StreamError::overloaded(message), - "authentication_error" | "permission_error" => StreamError::auth(message), - "invalid_request_error" => { - if overflow_message(&message) { - StreamError::context_overflow(message) - } else { - StreamError::invalid_request(message) - } - } - _ => StreamError::other(message), - } -} - -pub(crate) fn transport(err: &reqwest::Error) -> StreamError { - StreamError::transport(err.to_string()) -} - -fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option { - let raw = headers - .get(reqwest::header::RETRY_AFTER)? - .to_str() - .ok()? - .trim(); - if let Ok(secs) = raw.parse::() { - return Some(Duration::from_secs(secs)); - } - let target = parse_http_date_unix(raw)?; - let delta = target - goat_provider::now_secs(); - Some(Duration::from_secs(u64::try_from(delta).unwrap_or(0))) -} - -fn month_number(name: &str) -> Option { - match name { - "Jan" => Some(1), - "Feb" => Some(2), - "Mar" => Some(3), - "Apr" => Some(4), - "May" => Some(5), - "Jun" => Some(6), - "Jul" => Some(7), - "Aug" => Some(8), - "Sep" => Some(9), - "Oct" => Some(10), - "Nov" => Some(11), - "Dec" => Some(12), - _ => None, - } -} - -fn parse_http_date_unix(s: &str) -> Option { - let rest = s.split_once(',').map_or(s, |(_, tail)| tail).trim(); - let mut parts = rest.split_whitespace(); - let day: i64 = parts.next()?.parse().ok()?; - let month = month_number(parts.next()?)?; - let year: i64 = parts.next()?.parse().ok()?; - let mut clock = parts.next()?.splitn(3, ':'); - let hour: i64 = clock.next()?.parse().ok()?; - let minute: i64 = clock.next()?.parse().ok()?; - let second: i64 = clock.next()?.parse().ok()?; - Some(crate::gregorian_to_unix( - year, month, day, hour, minute, second, - )) -} - -#[cfg(test)] -mod tests { - use goat_provider::StreamError; - - fn http(status: u16, body: &str) -> StreamError { - super::classify_http( - reqwest::StatusCode::from_u16(status).unwrap(), - &reqwest::header::HeaderMap::new(), - body, - ) - } - - #[test] - fn rate_limit_with_retry_after_seconds() { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::RETRY_AFTER, "30".parse().unwrap()); - let error = super::classify_http( - reqwest::StatusCode::TOO_MANY_REQUESTS, - &headers, - r#"{"type":"error","error":{"type":"rate_limit_error","message":"Number of requests has exceeded your rate limit"}}"#, - ); - assert_eq!( - error, - StreamError::rate_limited( - "Number of requests has exceeded your rate limit", - Some(std::time::Duration::from_secs(30)), - ) - ); - } - - #[test] - fn overloaded_529() { - let error = http( - 529, - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ); - assert!(matches!(error, StreamError::Overloaded { .. })); - } - - #[test] - fn api_error_500_is_overloaded() { - let error = http( - 500, - r#"{"type":"error","error":{"type":"api_error","message":"Internal server error"}}"#, - ); - assert!(matches!(error, StreamError::Overloaded { .. })); - } - - #[test] - fn auth_errors() { - let error = http( - 401, - r#"{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}"#, - ); - assert!(matches!(error, StreamError::Auth { .. })); - } - - #[test] - fn prompt_too_long_is_overflow() { - let error = http( - 400, - r#"{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 213413 tokens > 200000 maximum"}}"#, - ); - assert!(matches!(error, StreamError::ContextOverflow { .. })); - } - - #[test] - fn input_plus_max_tokens_is_overflow() { - let error = http( - 400, - r#"{"type":"error","error":{"type":"invalid_request_error","message":"input length and `max_tokens` exceed context limit: 195000 + 16384 > 200000, decrease input length or `max_tokens` and try again"}}"#, - ); - assert!(matches!(error, StreamError::ContextOverflow { .. })); - } - - #[test] - fn request_too_large_is_overflow() { - let error = http( - 413, - r#"{"type":"error","error":{"type":"request_too_large","message":"Request body too large"}}"#, - ); - assert!(matches!(error, StreamError::ContextOverflow { .. })); - } - - #[test] - fn plain_400_is_invalid_request() { - let error = http( - 400, - r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages: roles must alternate"}}"#, - ); - assert!(matches!(error, StreamError::InvalidRequest { .. })); - } - - #[test] - fn unparseable_body_keeps_status_context() { - let error = http(503, "bad gateway"); - assert_eq!( - error, - StreamError::overloaded("503 Service Unavailable: bad gateway") - ); - } - - #[test] - fn sse_overloaded_mid_stream() { - let error = super::classify_sse_error( - r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#, - ); - assert_eq!(error, StreamError::overloaded("Overloaded")); - } - - #[test] - fn sse_timeout_is_retryable() { - let error = super::classify_sse_error( - r#"{"type":"error","error":{"type":"timeout_error","message":"timed out"}}"#, - ); - assert!(matches!(error, StreamError::Overloaded { .. })); - } - - #[test] - fn retry_after_http_date() { - let future = goat_provider::now_secs() + 90; - let days = future / 86_400; - let secs = future % 86_400; - let mut year = 1970; - let mut remaining = days; - let leap = |y: i64| (y % 4 == 0 && y % 100 != 0) || y % 400 == 0; - loop { - let len = if leap(year) { 366 } else { 365 }; - if remaining < len { - break; - } - remaining -= len; - year += 1; - } - let month_lengths = [ - 31, - if leap(year) { 29 } else { 28 }, - 31, - 30, - 31, - 30, - 31, - 31, - 30, - 31, - 30, - 31, - ]; - let names = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - let mut month = 0; - while remaining >= month_lengths[month] { - remaining -= month_lengths[month]; - month += 1; - } - let header = format!( - "Wed, {:02} {} {} {:02}:{:02}:{:02} GMT", - remaining + 1, - names[month], - year, - secs / 3600, - (secs % 3600) / 60, - secs % 60, - ); - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::RETRY_AFTER, header.parse().unwrap()); - let error = super::classify_http( - reqwest::StatusCode::TOO_MANY_REQUESTS, - &headers, - r#"{"type":"error","error":{"type":"rate_limit_error","message":"limited"}}"#, - ); - let StreamError::RateLimited { - retry_after: Some(delay), - .. - } = error - else { - panic!("expected rate limited with retry_after"); - }; - assert!((85..=95).contains(&delay.as_secs()), "got {delay:?}"); - } -} diff --git a/crates/goat-provider-anthropic/src/lib.rs b/crates/goat-provider-anthropic/src/lib.rs deleted file mode 100644 index 10b9a11..0000000 --- a/crates/goat-provider-anthropic/src/lib.rs +++ /dev/null @@ -1,1274 +0,0 @@ -use std::collections::HashMap; - -use eventsource_stream::Eventsource; -mod auth; -mod error; -use futures::StreamExt; -use goat_auth::{CredentialKey, CredentialStore, TokenSet}; -use goat_provider::{ - AuthMethod, Capabilities, ContentBlock, Effort, Message, MessageRole, Model, Provider, - ProviderId, ProviderMetadata, RateLimitSnapshot, RateWindow, Request, SearchResult, - StreamError, StreamEvent, Usage, WebSearchOutput, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub use auth::AnthropicAuthError; -use auth::{Auth, current_auth, do_login}; - -pub const PROVIDER_ID: &str = "anthropic"; -const BASE_URL: &str = "https://api.anthropic.com/v1"; -pub(crate) const ENV_VAR: &str = "ANTHROPIC_API_KEY"; -const VERSION: &str = "2023-06-01"; -const WEB_SEARCH_MODEL: &str = "claude-haiku-4-5-20251001"; -const MAX_TOKENS: u32 = 16384; - -pub(crate) const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -pub(crate) const OAUTH_AUTHORIZE: &str = "https://claude.ai/oauth/authorize"; -pub(crate) const OAUTH_TOKEN: &str = "https://platform.claude.com/v1/oauth/token"; -pub(crate) const OAUTH_SCOPE: &str = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"; -const OAUTH_BETA: &str = "oauth-2025-04-20,claude-code-20250219"; -const OAUTH_USER_AGENT: &str = "claude-cli/2.1.119 (external, cli)"; -pub(crate) const OAUTH_TOKEN_UA: &str = "axios/1.13.6"; -const CLAUDE_CODE_SYSTEM: &str = "You are Claude Code, Anthropic's official CLI for Claude."; - -fn anthropic_context_window(model: &str) -> u32 { - let id = model.to_ascii_lowercase(); - if id.contains("fable") - || id.contains("opus-4-8") - || id.contains("opus-4-7") - || id.contains("opus-4-6") - || id.contains("sonnet-4-6") - { - 1_000_000 - } else { - 200_000 - } -} - -fn anthropic_supports_images(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - id.starts_with("claude-") - && !id.contains("text") - && !id.contains("embedding") - && !id.contains("search") -} - -const CATALOG: &[&str] = &[ - "claude-fable-5", - "claude-opus-4-8", - "claude-sonnet-4-6", - "claude-haiku-4-5-20251001", - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5-20251101", - "claude-sonnet-4-5-20250929", - "claude-opus-4-1-20250805", -]; - -pub struct AnthropicProvider { - base_url: String, - store: CredentialStore, - key: CredentialKey, - client: reqwest::Client, -} - -impl AnthropicProvider { - pub fn new(store: CredentialStore, key: CredentialKey) -> Self { - Self { - base_url: BASE_URL.to_owned(), - store, - key, - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client"), - } - } -} - -pub fn build(store: &CredentialStore, account: &str) -> AnthropicProvider { - let key = CredentialKey::model(PROVIDER_ID, account); - AnthropicProvider::new(store.clone(), key) -} - -fn build_system(system: &str, oauth: bool) -> Option { - if oauth { - let mut blocks = vec![json!({ "type": "text", "text": CLAUDE_CODE_SYSTEM })]; - if !system.is_empty() { - blocks.push(json!({ "type": "text", "text": system })); - } - Some(serde_json::Value::Array(blocks)) - } else if system.is_empty() { - None - } else { - Some(serde_json::Value::Array(vec![json!({ - "type": "text", - "text": system, - })])) - } -} - -fn cache_marker() -> serde_json::Value { - json!({ "type": "ephemeral" }) -} - -fn mark_last_cacheable_block(message: &mut serde_json::Value) -> bool { - let Some(blocks) = message - .get_mut("content") - .and_then(serde_json::Value::as_array_mut) - else { - return false; - }; - for block in blocks.iter_mut().rev() { - let kind = block - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if kind == "thinking" || kind == "redacted_thinking" { - continue; - } - if let Some(object) = block.as_object_mut() { - object.insert("cache_control".to_owned(), cache_marker()); - return true; - } - } - false -} - -fn apply_cache_control( - system: &mut Option, - messages: &mut [serde_json::Value], - tools: &mut [serde_json::Value], -) { - if let Some(tool) = tools.last_mut() - && let Some(object) = tool.as_object_mut() - { - object.insert("cache_control".to_owned(), cache_marker()); - } - if let Some(serde_json::Value::Array(blocks)) = system - && let Some(last) = blocks.last_mut() - && let Some(object) = last.as_object_mut() - { - object.insert("cache_control".to_owned(), cache_marker()); - } - let mut marked = 0; - for message in messages.iter_mut().rev() { - if marked == 2 { - break; - } - if mark_last_cacheable_block(message) { - marked += 1; - } - } -} - -#[derive(Serialize)] -struct MessagesRequest<'a> { - model: &'a str, - max_tokens: u32, - stream: bool, - #[serde(skip_serializing_if = "Option::is_none")] - system: Option, - messages: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - tools: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option, - #[serde(skip_serializing_if = "Option::is_none")] - thinking: Option, - #[serde(skip_serializing_if = "Option::is_none")] - output_config: Option, -} - -struct ThinkingConfig { - thinking: Option, - output_config: Option, - max_tokens: u32, -} - -fn uses_effort_param(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - id.contains("fable") - || id.contains("opus-4-8") - || id.contains("opus-4-7") - || id.contains("opus-4-6") - || id.contains("sonnet-4-6") -} - -fn budget_tokens(effort: Effort) -> Option { - match effort { - Effort::Off => None, - Effort::Low => Some(2048), - Effort::Medium => Some(8192), - Effort::High | Effort::Xhigh | Effort::Max => Some(24576), - } -} - -fn thinking_config(model: &str, effort: Option) -> ThinkingConfig { - let none = ThinkingConfig { - thinking: None, - output_config: None, - max_tokens: MAX_TOKENS, - }; - let Some(effort) = effort else { - return none; - }; - if uses_effort_param(model) { - if matches!(effort, Effort::Off) { - return none; - } - ThinkingConfig { - thinking: Some(json!({ "type": "adaptive" })), - output_config: Some(json!({ "effort": effort.as_str() })), - max_tokens: MAX_TOKENS, - } - } else { - match budget_tokens(effort) { - None => none, - Some(budget) => ThinkingConfig { - thinking: Some(json!({ "type": "enabled", "budget_tokens": budget })), - output_config: None, - max_tokens: budget + MAX_TOKENS, - }, - } - } -} - -const UNIFIED_PREFIX: &str = "anthropic-ratelimit-unified-"; -const UNIFIED_SUFFIX: &str = "-utilization"; - -fn unified_label(period: &str) -> String { - match period { - "5h" => "5h".to_owned(), - "7d" => "weekly".to_owned(), - other => other.to_owned(), - } -} - -fn representative_period(claim: &str) -> String { - match claim { - "five_hour" => "5h".to_owned(), - "seven_day" => "7d".to_owned(), - other => other.replace('_', ""), - } -} - -fn parse_anthropic_unified_ratelimits( - headers: &reqwest::header::HeaderMap, -) -> Option { - let mut periods: Vec = Vec::new(); - for name in headers.keys() { - let key = name.as_str(); - if let Some(rest) = key.strip_prefix(UNIFIED_PREFIX) - && let Some(period) = rest.strip_suffix(UNIFIED_SUFFIX) - && !period.is_empty() - && !periods.iter().any(|p| p == period) - { - periods.push(period.to_owned()); - } - } - periods.sort_by_key(|p| match p.as_str() { - "5h" => 0, - "7d" => 1, - _ => 2, - }); - - let mut windows = Vec::new(); - for period in &periods { - if let Some(window) = parse_unified_window(headers, period, &unified_label(period)) { - windows.push(window); - } - } - - if windows.is_empty() { - return None; - } - - let representative = headers - .get("anthropic-ratelimit-unified-representative-claim") - .and_then(|v| v.to_str().ok()) - .map(|claim| unified_label(&representative_period(claim))); - - Some(RateLimitSnapshot { - windows, - representative, - }) -} - -fn parse_unified_window( - headers: &reqwest::header::HeaderMap, - period: &str, - label: &str, -) -> Option { - let util_key = format!("anthropic-ratelimit-unified-{period}-utilization"); - let reset_key = format!("anthropic-ratelimit-unified-{period}-reset"); - - let raw = headers.get(&util_key).and_then(|v| v.to_str().ok())?; - let fraction: f32 = raw.trim_end_matches('%').parse().ok()?; - #[allow(clippy::cast_possible_truncation)] - let used_percent = if raw.ends_with('%') { - fraction - } else { - fraction * 100.0 - }; - - let resets_at = headers - .get(&reset_key) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok().or_else(|| parse_rfc3339_unix(s))); - - Some(RateWindow { - label: label.to_owned(), - used_percent, - resets_at, - }) -} - -fn parse_rfc3339_unix(s: &str) -> Option { - let s = if let Some(pos) = s.find('+') { - &s[..pos] - } else { - s.trim_end_matches('Z') - }; - let (date, time) = s.split_once('T')?; - let mut dp = date.splitn(3, '-'); - let year: i64 = dp.next()?.parse().ok()?; - let month: i64 = dp.next()?.parse().ok()?; - let day: i64 = dp.next()?.parse().ok()?; - let mut tp = time.splitn(3, ':'); - let hour: i64 = tp.next()?.parse().ok()?; - let min: i64 = tp.next()?.parse().ok()?; - #[allow(clippy::cast_possible_truncation)] - let sec: i64 = tp.next()?.trim_end_matches('Z').parse::().ok()? as i64; - Some(gregorian_to_unix(year, month, day, hour, min, sec)) -} - -fn gregorian_to_unix(year: i64, month: i64, day: i64, h: i64, m: i64, s: i64) -> i64 { - let (y, mo) = if month <= 2 { - (year - 1, month + 9) - } else { - (year, month - 3) - }; - let era = y.div_euclid(400); - let yoe = y.rem_euclid(400); - let doy = (153 * mo + 2) / 5 + day - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; - days * 86_400 + h * 3_600 + m * 60 + s -} - -fn anthropic_efforts(model: &str) -> Vec { - let id = model.to_ascii_lowercase(); - if id.contains("fable") || id.contains("opus-4-8") || id.contains("opus-4-7") { - vec![ - Effort::Low, - Effort::Medium, - Effort::High, - Effort::Xhigh, - Effort::Max, - ] - } else if id.contains("opus-4-6") || id.contains("sonnet-4-6") { - vec![Effort::Low, Effort::Medium, Effort::High, Effort::Max] - } else if id.contains("opus-4-5") - || id.contains("sonnet-4-5") - || id.contains("opus-4-1") - || id.contains("haiku-4-5") - { - vec![Effort::Off, Effort::Low, Effort::Medium, Effort::High] - } else { - Vec::new() - } -} - -fn content_block_json(block: &ContentBlock) -> serde_json::Value { - match block { - ContentBlock::Text { text } => json!({ "type": "text", "text": text }), - ContentBlock::Thinking { text, signature } => json!({ - "type": "thinking", - "thinking": text, - "signature": signature, - }), - ContentBlock::RedactedThinking { data } => json!({ - "type": "redacted_thinking", - "data": data, - }), - ContentBlock::ToolUse { id, name, input } => json!({ - "type": "tool_use", - "id": id, - "name": name, - "input": if input.is_object() { input.clone() } else { json!({}) }, - }), - ContentBlock::ToolResult { - tool_use_id, - content, - is_error, - } => { - let content_json: Vec = - content.iter().map(content_block_json).collect(); - json!({ - "type": "tool_result", - "tool_use_id": tool_use_id, - "content": content_json, - "is_error": is_error, - }) - } - ContentBlock::Image { media_type, data } => json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": media_type, - "data": data, - }, - }), - } -} - -fn message_json(role: &str, message: &Message) -> serde_json::Value { - let blocks: Vec = message.content.iter().map(content_block_json).collect(); - json!({ "role": role, "content": blocks }) -} - -#[derive(Deserialize)] -struct DeltaEvent { - delta: Option, -} - -#[derive(Deserialize)] -struct DeltaBody { - text: Option, - partial_json: Option, - thinking: Option, - signature: Option, -} - -#[allow(clippy::struct_field_names)] -#[derive(Default, Deserialize)] -struct MessageStartUsage { - #[serde(default)] - input_tokens: u32, - #[serde(default)] - cache_creation_input_tokens: u32, - #[serde(default)] - cache_read_input_tokens: u32, -} - -#[derive(Deserialize)] -struct MessageStart { - message: MessageStartMessage, -} - -#[derive(Deserialize)] -struct MessageStartMessage { - #[serde(default)] - usage: MessageStartUsage, -} - -#[derive(Default, Deserialize)] -struct MessageDeltaUsage { - #[serde(default)] - output_tokens: u32, -} - -#[derive(Deserialize)] -struct MessageDelta { - usage: Option, -} - -#[derive(Deserialize)] -struct ContentBlockStart { - index: u32, - content_block: ContentBlockInfo, -} - -#[derive(Deserialize)] -struct ContentBlockInfo { - #[serde(rename = "type")] - kind: String, - id: Option, - name: Option, - data: Option, -} - -#[derive(Deserialize)] -struct ContentBlockStop { - index: u32, -} - -#[derive(Deserialize)] -struct IndexedEvent { - index: u32, -} - -#[derive(Deserialize)] -struct ModelsResponse { - #[serde(default)] - data: Vec, -} - -#[derive(Deserialize)] -struct ModelDto { - id: String, -} - -fn parse_text_delta(data: &str) -> Option { - serde_json::from_str::(data).ok()?.delta?.text -} - -fn parse_input_json_delta(data: &str) -> Option { - serde_json::from_str::(data) - .ok()? - .delta? - .partial_json -} - -fn parse_thinking_delta(data: &str) -> Option { - serde_json::from_str::(data) - .ok()? - .delta? - .thinking -} - -fn parse_signature_delta(data: &str) -> Option { - serde_json::from_str::(data) - .ok()? - .delta? - .signature -} - -fn event_index(data: &str) -> Result { - serde_json::from_str::(data).map(|event| event.index) -} - -fn split_request(req: &Request) -> (String, Vec, Vec) { - let mut system = String::new(); - let mut messages = Vec::new(); - for message in &req.messages { - match message.role { - MessageRole::System => { - if !system.is_empty() { - system.push('\n'); - } - system.push_str(&message.text_content()); - } - MessageRole::User => messages.push(message_json("user", message)), - MessageRole::Assistant => messages.push(message_json("assistant", message)), - } - } - let tools = req - .tools - .iter() - .map(|tool| { - json!({ - "name": tool.name, - "description": tool.description, - "input_schema": tool.input_schema, - }) - }) - .collect(); - (system, messages, tools) -} - -#[allow(clippy::too_many_lines)] -async fn stream_messages(response: reqwest::Response, events: &mpsc::Sender) { - let mut stream = response.bytes_stream().eventsource(); - let mut tool_calls: HashMap = HashMap::new(); - let mut usage = Usage::default(); - let mut stopped = false; - while let Some(event) = stream.next().await { - match event { - Ok(event) => match event.event.as_str() { - "message_start" => { - if let Ok(start) = serde_json::from_str::(&event.data) { - let u = &start.message.usage; - usage.input_tokens = u.input_tokens - + u.cache_creation_input_tokens - + u.cache_read_input_tokens; - usage.cache_read_tokens = u.cache_read_input_tokens; - usage.cache_write_tokens = u.cache_creation_input_tokens; - } - } - "message_delta" => { - if let Ok(delta) = serde_json::from_str::(&event.data) - && let Some(u) = delta.usage - { - usage.output_tokens = u.output_tokens; - } - } - "content_block_start" => { - if let Ok(start) = serde_json::from_str::(&event.data) { - match start.content_block.kind.as_str() { - "tool_use" => { - if let (Some(id), Some(name)) = - (start.content_block.id, start.content_block.name) - { - tool_calls.insert(start.index, (id, name, String::new())); - } - } - "redacted_thinking" => { - if let Some(data) = start.content_block.data - && events - .send(StreamEvent::RedactedThinking { data }) - .await - .is_err() - { - return; - } - } - _ => {} - } - } - } - "content_block_delta" => { - if let Some(text) = parse_text_delta(&event.data) { - if events.send(StreamEvent::TextDelta { text }).await.is_err() { - return; - } - } else if let Some(text) = parse_thinking_delta(&event.data) { - if events - .send(StreamEvent::ThinkingDelta { text }) - .await - .is_err() - { - return; - } - } else if let Some(signature) = parse_signature_delta(&event.data) { - if events - .send(StreamEvent::ThinkingSignature { signature }) - .await - .is_err() - { - return; - } - } else if let Some(partial) = parse_input_json_delta(&event.data) - && let Ok(index) = event_index(&event.data) - && let Some(entry) = tool_calls.get_mut(&index) - { - entry.2.push_str(&partial); - } - } - "content_block_stop" => { - if let Ok(stop) = serde_json::from_str::(&event.data) - && let Some((id, name, input)) = tool_calls.remove(&stop.index) - && events - .send(StreamEvent::ToolCall { id, name, input }) - .await - .is_err() - { - return; - } - } - "message_stop" => { - let _ = events.send(StreamEvent::Usage { usage }).await; - stopped = true; - break; - } - "error" => { - let _ = events - .send(StreamEvent::Failed { - error: error::classify_sse_error(&event.data), - }) - .await; - return; - } - _ => {} - }, - Err(err) => { - let _ = events - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport(err.to_string()), - }) - .await; - return; - } - } - } - if stopped { - let _ = events.send(StreamEvent::Completed).await; - } else { - let _ = events - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport( - "stream ended before message_stop".to_owned(), - ), - }) - .await; - } -} - -fn parse_web_search_results(value: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - let Some(content) = value.get("content").and_then(|content| content.as_array()) else { - return out; - }; - for block in content { - if block.get("type").and_then(|kind| kind.as_str()) != Some("web_search_tool_result") { - continue; - } - let Some(results) = block.get("content").and_then(|content| content.as_array()) else { - continue; - }; - for result in results { - if result.get("type").and_then(|kind| kind.as_str()) != Some("web_search_result") { - continue; - } - let url = result - .get("url") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if url.is_empty() { - continue; - } - out.push(SearchResult { - title: result - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_owned(), - url: url.to_owned(), - snippet: result - .get("page_age") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_owned(), - }); - } - } - out -} - -impl Provider for AnthropicProvider { - fn id(&self) -> ProviderId { - ProviderId::from(PROVIDER_ID) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: AuthMethod::ApiKeyOrOAuth, - images: true, - } - } - - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "network", - endpoint: None, - oauth: Some("browser"), - login_endpoint: None, - setup: &[], - } - } - - fn supports_images(&self, model: &str) -> bool { - anthropic_supports_images(model) - } - - fn supports_web_search(&self) -> bool { - true - } - - fn web_search(&self, query: String) -> JoinHandle> { - let client = self.client.clone(); - let url = format!("{}/messages", self.base_url); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(auth) = current_auth(&store, &key).await else { - return Err(StreamError::auth("not logged in to anthropic")); - }; - let body = json!({ - "model": WEB_SEARCH_MODEL, - "max_tokens": 1024, - "messages": [{ "role": "user", "content": query }], - "tools": [{ "type": "web_search_20250305", "name": "web_search", "max_uses": 5 }], - }); - let builder = client - .post(&url) - .header("anthropic-version", VERSION) - .json(&body); - let builder = match &auth { - Auth::ApiKey(api_key) => builder.header("x-api-key", api_key), - Auth::OAuth(access) => builder - .bearer_auth(access) - .header("anthropic-beta", OAUTH_BETA) - .header("user-agent", OAUTH_USER_AGENT) - .header("x-app", "cli"), - }; - let resp = builder.send().await.map_err(|err| error::transport(&err))?; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - return Err(error::classify_http(status, &headers, &detail)); - } - let value: serde_json::Value = resp - .json() - .await - .map_err(|err| StreamError::other(format!("invalid search response: {err}")))?; - Ok(WebSearchOutput::from_results(parse_web_search_results( - &value, - ))) - }) - } - - fn stream(&self, req: Request, tx: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let url = format!("{}/messages", self.base_url); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(auth) = current_auth(&store, &key).await else { - let _ = tx - .send(StreamEvent::Failed { - error: goat_provider::StreamError::auth("not logged in to anthropic"), - }) - .await; - return; - }; - let (system, mut messages, mut tools) = split_request(&req); - let cfg = thinking_config(&req.model, req.effort); - let oauth = matches!(auth, Auth::OAuth(_)); - let mut system_value = build_system(&system, oauth); - apply_cache_control(&mut system_value, &mut messages, &mut tools); - let body = MessagesRequest { - model: &req.model, - max_tokens: cfg.max_tokens, - stream: true, - system: system_value, - messages, - tools, - tool_choice: matches!(req.tool_choice, goat_provider::ToolChoice::None) - .then(|| json!({ "type": "none" })), - thinking: cfg.thinking, - output_config: cfg.output_config, - }; - let builder = client - .post(&url) - .header("anthropic-version", VERSION) - .json(&body); - let builder = match &auth { - Auth::ApiKey(api_key) => builder.header("x-api-key", api_key), - Auth::OAuth(access) => builder - .bearer_auth(access) - .header("anthropic-beta", OAUTH_BETA) - .header("user-agent", OAUTH_USER_AGENT) - .header("x-app", "cli"), - }; - let resp = match builder.send().await { - Ok(resp) => resp, - Err(err) => { - let _ = tx - .send(StreamEvent::Failed { - error: error::transport(&err), - }) - .await; - return; - } - }; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - let _ = tx - .send(StreamEvent::Failed { - error: error::classify_http(status, &headers, &detail), - }) - .await; - return; - } - if matches!(auth, Auth::OAuth(_)) - && let Some(snapshot) = parse_anthropic_unified_ratelimits(resp.headers()) - { - let _ = tx.send(StreamEvent::RateLimits { snapshot }).await; - } - stream_messages(resp, &tx).await; - }) - } - - fn authenticated(&self) -> bool { - self.store.resolve(&self.key, Some(ENV_VAR)).is_some() - } - - fn validate(&self) -> JoinHandle> { - let client = self.client.clone(); - let url = format!("{}/models", self.base_url); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let auth = current_auth(&store, &key) - .await - .ok_or_else(|| "no credentials".to_owned())?; - let api_key = match auth { - Auth::OAuth(_) => return Ok(()), - Auth::ApiKey(api_key) => api_key, - }; - let resp = client - .get(&url) - .header("anthropic-version", VERSION) - .header("x-api-key", api_key) - .send() - .await - .map_err(|_| "could not reach provider".to_owned())?; - let status = resp.status(); - if status.is_success() { - Ok(()) - } else if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - Err("invalid credentials".to_owned()) - } else { - Err(format!("could not reach provider: {status}")) - } - }) - } - - fn context_window(&self, model: &str) -> Option { - Some(anthropic_context_window(model)) - } - - fn catalog(&self) -> &'static [&'static str] { - CATALOG - } - - fn efforts(&self, model: &str) -> Vec { - anthropic_efforts(model) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let url = format!("{}/models", self.base_url); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(auth) = current_auth(&store, &key).await else { - return; - }; - let api_key = match auth { - Auth::OAuth(_) => { - for &id in CATALOG { - if out - .send(Model { - id: id.to_owned(), - supports_images: anthropic_supports_images(id), - }) - .await - .is_err() - { - return; - } - } - return; - } - Auth::ApiKey(api_key) => api_key, - }; - let Ok(resp) = client - .get(&url) - .header("anthropic-version", VERSION) - .header("x-api-key", api_key) - .send() - .await - else { - return; - }; - let Ok(models) = resp.json::().await else { - return; - }; - for model in models.data { - let supports_images = anthropic_supports_images(&model.id); - if out - .send(Model { - id: model.id, - supports_images, - }) - .await - .is_err() - { - return; - } - } - }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - tokio::spawn(async move { do_login(&status).await.map_err(|e| e.to_string()) }) - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; - - use super::{ - event_index, parse_anthropic_unified_ratelimits, parse_input_json_delta, parse_text_delta, - parse_web_search_results, - }; - - fn headers_from(pairs: &[(&str, &str)]) -> HeaderMap { - let mut map = HeaderMap::new(); - for (k, v) in pairs { - map.insert( - HeaderName::from_bytes(k.as_bytes()).unwrap(), - HeaderValue::from_str(v).unwrap(), - ); - } - map - } - - #[test] - fn unified_scan_reads_5h_and_weekly_decimal() { - let h = headers_from(&[ - ("anthropic-ratelimit-unified-5h-utilization", "0.018"), - ("anthropic-ratelimit-unified-5h-reset", "1764554400"), - ("anthropic-ratelimit-unified-7d-utilization", "0.737"), - ("anthropic-ratelimit-unified-7d-reset", "1764615600"), - ( - "anthropic-ratelimit-unified-representative-claim", - "five_hour", - ), - ]); - let snap = parse_anthropic_unified_ratelimits(&h).expect("snapshot"); - assert_eq!(snap.windows.len(), 2); - assert_eq!(snap.windows[0].label, "5h"); - assert_eq!(snap.windows[1].label, "weekly"); - assert!((snap.windows[1].used_percent - 73.7).abs() < 0.1); - assert_eq!(snap.representative.as_deref(), Some("5h")); - } - - #[test] - fn unified_scan_accepts_percent_encoding() { - let h = headers_from(&[("anthropic-ratelimit-unified-5h-utilization", "42%")]); - let snap = parse_anthropic_unified_ratelimits(&h).expect("snapshot"); - assert!((snap.windows[0].used_percent - 42.0).abs() < 0.1); - } - - #[test] - fn unified_scan_surfaces_unknown_period() { - let h = headers_from(&[("anthropic-ratelimit-unified-30d-utilization", "0.5")]); - let snap = parse_anthropic_unified_ratelimits(&h).expect("snapshot"); - assert_eq!(snap.windows.len(), 1); - assert_eq!(snap.windows[0].label, "30d"); - } - - #[test] - fn unified_scan_ignores_non_period_headers() { - let h = headers_from(&[ - ("anthropic-ratelimit-unified-status", "allowed"), - ("anthropic-ratelimit-unified-fallback-percentage", "0.2"), - ]); - assert!(parse_anthropic_unified_ratelimits(&h).is_none()); - } - - #[test] - fn extracts_web_search_results() { - let value = serde_json::json!({ - "content": [ - { "type": "text", "text": "here are results" }, - { "type": "web_search_tool_result", "content": [ - { "type": "web_search_result", "url": "https://a.example", "title": "A", "page_age": "today" }, - { "type": "web_search_result", "url": "https://b.example", "title": "B" } - ]} - ] - }); - let results = parse_web_search_results(&value); - assert_eq!(results.len(), 2); - assert_eq!(results[0].url, "https://a.example"); - assert_eq!(results[0].title, "A"); - assert_eq!(results[0].snippet, "today"); - assert_eq!(results[1].url, "https://b.example"); - } - - #[test] - fn ignores_search_errors() { - let value = serde_json::json!({ - "content": [ - { "type": "web_search_tool_result", "content": { - "type": "web_search_tool_result_error", "error_code": "max_uses_exceeded" - }} - ] - }); - assert!(parse_web_search_results(&value).is_empty()); - } - - #[test] - fn parses_text_delta() { - let data = r#"{"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}"#; - assert_eq!(parse_text_delta(data).as_deref(), Some("Hi")); - } - - #[test] - fn authorize_url_carries_pkce_and_scope() { - let url = - crate::auth::authorize_url("CHAL", "STATE", "http://localhost:1234/callback").unwrap(); - assert!(url.contains("client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e")); - assert!(url.contains("code_challenge=CHAL")); - assert!(url.contains("code_challenge_method=S256")); - assert!(url.contains("state=STATE")); - assert!(url.contains("response_type=code")); - assert!(url.contains("user%3Ainference")); - assert!(url.contains("org%3Acreate_api_key")); - assert!(url.contains("user%3Afile_upload")); - assert!(url.contains("localhost%3A1234%2Fcallback")); - } - - #[test] - fn oauth_system_prepends_claude_code_identity() { - let blocks = super::build_system("be helpful", true).unwrap(); - assert_eq!(blocks[0]["text"], super::CLAUDE_CODE_SYSTEM); - assert_eq!(blocks[1]["text"], "be helpful"); - let plain = super::build_system("be helpful", false).unwrap(); - assert_eq!(plain[0]["text"], "be helpful"); - assert!(super::build_system("", false).is_none()); - } - - #[test] - fn cache_control_marks_tools_system_and_last_two_messages() { - let mut system = super::build_system("be helpful", false); - let mut tools = vec![ - serde_json::json!({ "name": "Read" }), - serde_json::json!({ "name": "Bash" }), - ]; - let mut messages = vec![ - serde_json::json!({ "role": "user", "content": [{ "type": "text", "text": "one" }] }), - serde_json::json!({ "role": "assistant", "content": [ - { "type": "text", "text": "two" }, - { "type": "thinking", "thinking": "t", "signature": "s" }, - ] }), - serde_json::json!({ "role": "user", "content": [{ "type": "text", "text": "three" }] }), - ]; - super::apply_cache_control(&mut system, &mut messages, &mut tools); - assert!(tools[0].get("cache_control").is_none()); - assert_eq!(tools[1]["cache_control"]["type"], "ephemeral"); - let system = system.unwrap(); - assert_eq!(system[0]["cache_control"]["type"], "ephemeral"); - assert!(messages[0]["content"][0].get("cache_control").is_none()); - assert_eq!( - messages[1]["content"][0]["cache_control"]["type"], "ephemeral", - "thinking blocks must be skipped when marking" - ); - assert!(messages[1]["content"][1].get("cache_control").is_none()); - assert_eq!( - messages[2]["content"][0]["cache_control"]["type"], - "ephemeral" - ); - } - - #[test] - fn text_delta_helper_skips_input_json() { - let data = r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"a\""}}"#; - assert_eq!(parse_text_delta(data), None); - } - - #[test] - fn parses_input_json_delta() { - let data = r#"{"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"a\""}}"#; - assert_eq!(parse_input_json_delta(data).as_deref(), Some("{\"a\"")); - assert_eq!(event_index(data).unwrap(), 1); - } - - #[test] - fn accumulates_tool_call_across_events() { - let mut tool_calls: HashMap = HashMap::new(); - let start = r#"{"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"read_file"}}"#; - let start: super::ContentBlockStart = serde_json::from_str(start).unwrap(); - assert_eq!(start.content_block.kind, "tool_use"); - tool_calls.insert( - start.index, - ( - start.content_block.id.unwrap(), - start.content_block.name.unwrap(), - String::new(), - ), - ); - - let first = r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}"#; - let second = r#"{"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"a.txt\"}"}}"#; - for chunk in [first, second] { - let partial = parse_input_json_delta(chunk).unwrap(); - let index = event_index(chunk).unwrap(); - tool_calls.get_mut(&index).unwrap().2.push_str(&partial); - } - - let stop = r#"{"type":"content_block_stop","index":0}"#; - let stop: super::ContentBlockStop = serde_json::from_str(stop).unwrap(); - let (id, name, input) = tool_calls.remove(&stop.index).unwrap(); - assert_eq!(id, "toolu_1"); - assert_eq!(name, "read_file"); - assert_eq!(input, r#"{"path":"a.txt"}"#); - } - - #[test] - fn handles_malformed_json() { - assert_eq!(parse_text_delta("not json"), None); - assert_eq!(parse_input_json_delta("not json"), None); - } - - #[test] - fn serializes_thinking_blocks() { - use goat_provider::ContentBlock; - let thinking = super::content_block_json(&ContentBlock::Thinking { - text: "ponder".to_owned(), - signature: "sig".to_owned(), - }); - assert_eq!(thinking["type"], "thinking"); - assert_eq!(thinking["thinking"], "ponder"); - assert_eq!(thinking["signature"], "sig"); - let redacted = super::content_block_json(&ContentBlock::RedactedThinking { - data: "blob".to_owned(), - }); - assert_eq!(redacted["type"], "redacted_thinking"); - assert_eq!(redacted["data"], "blob"); - } - - #[test] - fn effort_model_uses_output_config() { - use goat_provider::Effort; - let cfg = super::thinking_config("claude-opus-4-8", Some(Effort::High)); - assert_eq!(cfg.thinking.unwrap()["type"], "adaptive"); - assert_eq!(cfg.output_config.unwrap()["effort"], "high"); - } - - #[test] - fn fable_uses_output_config_with_xhigh() { - use goat_provider::Effort; - let cfg = super::thinking_config("claude-fable-5", Some(Effort::Xhigh)); - assert_eq!(cfg.thinking.unwrap()["type"], "adaptive"); - assert_eq!(cfg.output_config.unwrap()["effort"], "xhigh"); - assert_eq!(super::anthropic_context_window("claude-fable-5"), 1_000_000); - assert!(super::anthropic_efforts("claude-fable-5").contains(&Effort::Xhigh)); - let off = super::thinking_config("claude-fable-5", Some(Effort::Off)); - assert!(off.thinking.is_none()); - assert!(off.output_config.is_none()); - } - - #[test] - fn budget_model_uses_budget_tokens() { - use goat_provider::Effort; - let cfg = super::thinking_config("claude-sonnet-4-5-20250929", Some(Effort::Medium)); - assert_eq!(cfg.thinking.as_ref().unwrap()["type"], "enabled"); - assert_eq!(cfg.thinking.unwrap()["budget_tokens"], 8192); - assert!(cfg.max_tokens > 8192); - assert!(cfg.output_config.is_none()); - } - - #[test] - fn off_disables_thinking() { - use goat_provider::Effort; - let cfg = super::thinking_config("claude-sonnet-4-5-20250929", Some(Effort::Off)); - assert!(cfg.thinking.is_none()); - let none = super::thinking_config("claude-opus-4-8", None); - assert!(none.thinking.is_none()); - } - - #[test] - fn no_effort_disables_thinking() { - let none = super::thinking_config("claude-opus-4-8", None); - assert!(none.thinking.is_none()); - assert!(none.output_config.is_none()); - } -} diff --git a/crates/goat-provider-deepseek/Cargo.toml b/crates/goat-provider-deepseek/Cargo.toml deleted file mode 100644 index 2643ece..0000000 --- a/crates/goat-provider-deepseek/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-deepseek" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-deepseek/src/lib.rs b/crates/goat-provider-deepseek/src/lib.rs deleted file mode 100644 index d126cd3..0000000 --- a/crates/goat-provider-deepseek/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider_openai_compat::{OpenAiCompatProvider, api_key}; - -pub const PROVIDER_ID: &str = "deepseek"; -const BASE_URL: &str = "https://api.deepseek.com"; -const HOST: &str = "api.deepseek.com"; -const ENV_VAR: &str = "DEEPSEEK_API_KEY"; - -const CATALOG: &[&str] = &["deepseek-chat", "deepseek-reasoner"]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = - &[("deepseek-chat", 128_000), ("deepseek-reasoner", 128_000)]; - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_images(false) - .with_reasoning_effort(false) -} diff --git a/crates/goat-provider-gemini/Cargo.toml b/crates/goat-provider-gemini/Cargo.toml deleted file mode 100644 index e7a726c..0000000 --- a/crates/goat-provider-gemini/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "goat-provider-gemini" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-auth = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -futures = { workspace = true } -eventsource-stream = { workspace = true } -tokio = { workspace = true } -thiserror = { workspace = true } -tracing = { workspace = true } -open = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-gemini/src/codeassist.rs b/crates/goat-provider-gemini/src/codeassist.rs deleted file mode 100644 index 273149f..0000000 --- a/crates/goat-provider-gemini/src/codeassist.rs +++ /dev/null @@ -1,223 +0,0 @@ -use std::sync::Arc; - -use goat_auth::random_state; -use goat_provider::StreamError; -use serde_json::{Value, json}; - -use crate::error; -use tokio::sync::Mutex; - -pub const CA_BASE: &str = "https://cloudcode-pa.googleapis.com/v1internal"; - -fn ca_url(method: &str) -> String { - format!("{CA_BASE}:{method}") -} - -fn metadata() -> Value { - json!({ - "ideType": "IDE_UNSPECIFIED", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI", - }) -} - -fn env_project() -> Option { - std::env::var("GOOGLE_CLOUD_PROJECT") - .or_else(|_| std::env::var("GOOGLE_CLOUD_PROJECT_ID")) - .ok() - .filter(|s| !s.is_empty()) -} - -fn extract_project_str(v: &Value) -> Option { - let field = v.get("cloudaicompanionProject")?; - if let Some(s) = field.as_str() - && !s.is_empty() - { - return Some(s.to_owned()); - } - if let Some(id) = field.get("id").and_then(Value::as_str) - && !id.is_empty() - { - return Some(id.to_owned()); - } - None -} - -async fn load_code_assist( - client: &reqwest::Client, - access: &str, -) -> Result<(Option, Option), StreamError> { - let body = if let Some(proj) = env_project() { - json!({ "cloudaicompanionProject": proj, "metadata": metadata() }) - } else { - json!({ "metadata": metadata() }) - }; - let resp = client - .post(ca_url("loadCodeAssist")) - .bearer_auth(access) - .json(&body) - .send() - .await - .map_err(|e| StreamError::transport(e.to_string()))?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - tracing::debug!(%status, body = %text, "loadCodeAssist response"); - if !status.is_success() { - return Err(error::classify_http(status, &text)); - } - let v: Value = serde_json::from_str(&text).map_err(|e| StreamError::other(e.to_string()))?; - let existing_project = extract_project_str(&v); - let default_tier = v - .get("allowedTiers") - .and_then(Value::as_array) - .and_then(|tiers| { - tiers - .iter() - .find(|t| t.get("isDefault").and_then(Value::as_bool).unwrap_or(false)) - .cloned() - }); - let tier_id = default_tier.as_ref().and_then(|t| { - t.get("id") - .or_else(|| t.get("tierId")) - .and_then(Value::as_str) - .map(str::to_owned) - }); - let needs_user_project = default_tier - .as_ref() - .and_then(|t| t.get("userDefinedCloudaicompanionProject")) - .and_then(Value::as_bool) - .unwrap_or(false); - tracing::debug!( - ?existing_project, - ?tier_id, - needs_user_project, - "loadCodeAssist parsed" - ); - if needs_user_project && env_project().is_none() && existing_project.is_none() { - return Err(StreamError::other( - "Gemini Code Assist standard tier requires a GCP project. \ - Set the GOOGLE_CLOUD_PROJECT environment variable to your project ID.", - )); - } - Ok((existing_project, tier_id)) -} - -async fn onboard_user( - client: &reqwest::Client, - access: &str, - tier_id: &str, -) -> Result { - let is_free = tier_id.to_uppercase() == "FREE"; - let body = if is_free { - json!({ "tierId": tier_id, "metadata": metadata() }) - } else if let Some(proj) = env_project() { - json!({ "tierId": tier_id, "cloudaicompanionProject": proj, "metadata": metadata() }) - } else { - json!({ "tierId": tier_id, "metadata": metadata() }) - }; - tracing::debug!(tier = %tier_id, "onboardUser request"); - let resp = client - .post(ca_url("onboardUser")) - .bearer_auth(access) - .json(&body) - .send() - .await - .map_err(|e| StreamError::transport(e.to_string()))?; - let status = resp.status(); - let text = resp.text().await.unwrap_or_default(); - tracing::debug!(%status, body = %text, "onboardUser response"); - if !status.is_success() { - return Err(error::classify_http(status, &text)); - } - let lro: Value = serde_json::from_str(&text).map_err(|e| StreamError::other(e.to_string()))?; - let op_name = lro - .get("name") - .and_then(Value::as_str) - .ok_or_else(|| StreamError::other("onboardUser: missing operation name"))? - .to_owned(); - tracing::debug!(op = %op_name, "polling LRO"); - - let mut current = lro; - for i in 0..60u32 { - if current - .get("done") - .and_then(Value::as_bool) - .unwrap_or(false) - { - break; - } - if i > 0 { - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } - let poll = client - .post(ca_url("getOperation")) - .bearer_auth(access) - .json(&json!({ "name": op_name })) - .send() - .await - .map_err(|e| StreamError::transport(e.to_string()))?; - let poll_text = poll.text().await.unwrap_or_default(); - tracing::debug!(body = %poll_text, "getOperation poll"); - current = - serde_json::from_str(&poll_text).map_err(|e| StreamError::other(e.to_string()))?; - } - let project = current - .get("response") - .and_then(extract_project_str) - .or_else(|| extract_project_str(¤t)); - tracing::debug!(?project, "onboardUser resolved project"); - project.ok_or_else(|| StreamError::other("onboardUser: could not resolve project id")) -} - -pub async fn resolve_project( - client: &reqwest::Client, - access: &str, - cache: &Arc>>, -) -> Result, StreamError> { - { - let guard = cache.lock().await; - if let Some(p) = guard.as_ref() { - tracing::debug!(project = %p, "project from cache"); - return Ok(Some(p.clone())); - } - } - let result: Result, StreamError> = async { - if let Some(env_proj) = env_project() { - tracing::debug!(project = %env_proj, "project from env"); - return Ok(Some(env_proj)); - } - let (existing, tier_id) = load_code_assist(client, access).await?; - if let Some(p) = existing { - tracing::debug!(project = %p, "project from loadCodeAssist"); - return Ok(Some(p)); - } - let Some(tier) = tier_id else { - return Ok(None); - }; - tracing::debug!(%tier, "no existing project, onboarding"); - Ok(Some(onboard_user(client, access, &tier).await?)) - } - .await; - - match &result { - Ok(Some(p)) => { - *cache.lock().await = Some(p.clone()); - tracing::debug!(project = %p, "project cached"); - } - Ok(None) => tracing::warn!("no project resolved, sending without project"), - Err(e) => tracing::warn!(error = %e, "project resolution error"), - } - result -} - -pub fn wrap_request(model: &str, project: Option<&str>, inner: Value) -> Value { - let mut obj = serde_json::Map::new(); - obj.insert("model".to_owned(), Value::String(model.to_owned())); - if let Some(p) = project { - obj.insert("project".to_owned(), Value::String(p.to_owned())); - } - obj.insert("user_prompt_id".to_owned(), Value::String(random_state())); - obj.insert("request".to_owned(), inner); - tracing::debug!(model, ?project, "Code Assist wrap_request"); - Value::Object(obj) -} diff --git a/crates/goat-provider-gemini/src/error.rs b/crates/goat-provider-gemini/src/error.rs deleted file mode 100644 index d8e8023..0000000 --- a/crates/goat-provider-gemini/src/error.rs +++ /dev/null @@ -1,178 +0,0 @@ -use std::time::Duration; - -use goat_provider::StreamError; -use serde::Deserialize; -use serde_json::Value; - -#[derive(Deserialize)] -struct ErrorEnvelope { - error: Option, -} - -#[derive(Default, Deserialize)] -struct ErrorBody { - #[serde(default)] - code: Option, - #[serde(default)] - message: String, - #[serde(default)] - status: String, - #[serde(default)] - details: Vec, -} - -fn parse_body(body: &str) -> ErrorBody { - serde_json::from_str::(body) - .ok() - .and_then(|envelope| envelope.error) - .unwrap_or_default() -} - -fn retry_delay(details: &[Value]) -> Option { - let raw = details.iter().find_map(|detail| { - detail - .get("@type") - .and_then(Value::as_str) - .filter(|kind| kind.ends_with("google.rpc.RetryInfo"))?; - detail.get("retryDelay").and_then(Value::as_str) - })?; - let seconds: f64 = raw.trim_end_matches('s').parse().ok()?; - Some(Duration::from_secs_f64(seconds.max(0.0))) -} - -fn overflow_message(message: &str) -> bool { - message.contains("token count") && message.contains("exceeds") -} - -pub(crate) fn classify_http(status: reqwest::StatusCode, body: &str) -> StreamError { - let parsed = parse_body(body); - let message = if parsed.message.is_empty() { - format!("{status}: {body}") - } else { - parsed.message - }; - let code = parsed.code.unwrap_or(status.as_u16()); - classify_parsed(code, &parsed.status, message, &parsed.details) -} - -pub(crate) fn stream_error(data: &str) -> Option { - let value: Value = serde_json::from_str(data).ok()?; - let root = value.get("response").unwrap_or(&value); - root.get("error").filter(|error| !error.is_null())?; - let body = serde_json::to_string(root).unwrap_or_else(|_| data.to_owned()); - let parsed = parse_body(&body); - let message = if parsed.message.is_empty() { - format!("stream error: {data}") - } else { - parsed.message - }; - let code = parsed.code.unwrap_or(0); - Some(classify_parsed( - code, - &parsed.status, - message, - &parsed.details, - )) -} - -fn classify_parsed(code: u16, status: &str, message: String, details: &[Value]) -> StreamError { - match (code, status) { - (429, _) | (_, "RESOURCE_EXHAUSTED") => { - StreamError::rate_limited(message, retry_delay(details)) - } - (401 | 403, _) | (_, "UNAUTHENTICATED" | "PERMISSION_DENIED") => StreamError::auth(message), - (400, _) | (_, "INVALID_ARGUMENT") => { - if overflow_message(&message) { - StreamError::context_overflow(message) - } else { - StreamError::invalid_request(message) - } - } - (code, _) if (500..600).contains(&code) => StreamError::overloaded(message), - (_, "UNAVAILABLE" | "INTERNAL" | "DEADLINE_EXCEEDED") => StreamError::overloaded(message), - (code, _) if (400..500).contains(&code) => StreamError::invalid_request(message), - _ => StreamError::other(message), - } -} - -#[cfg(test)] -mod tests { - use goat_provider::StreamError; - - fn http(status: u16, body: &str) -> StreamError { - super::classify_http(reqwest::StatusCode::from_u16(status).unwrap(), body) - } - - #[test] - fn resource_exhausted_with_retry_info() { - let error = http( - 429, - r#"{"error":{"code":429,"message":"Quota exceeded","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.RetryInfo","retryDelay":"58s"}]}}"#, - ); - assert_eq!( - error, - StreamError::rate_limited("Quota exceeded", Some(std::time::Duration::from_secs(58))) - ); - } - - #[test] - fn unauthenticated_is_auth() { - let error = http( - 401, - r#"{"error":{"code":401,"message":"Request had invalid authentication credentials","status":"UNAUTHENTICATED"}}"#, - ); - assert!(matches!(error, StreamError::Auth { .. })); - } - - #[test] - fn token_overflow_is_context_overflow() { - let error = http( - 400, - r#"{"error":{"code":400,"message":"The input token count (1300000) exceeds the maximum number of tokens allowed (1048576).","status":"INVALID_ARGUMENT"}}"#, - ); - assert!(matches!(error, StreamError::ContextOverflow { .. })); - } - - #[test] - fn unavailable_is_overloaded() { - let error = http( - 503, - r#"{"error":{"code":503,"message":"The service is currently unavailable.","status":"UNAVAILABLE"}}"#, - ); - assert!(matches!(error, StreamError::Overloaded { .. })); - } - - #[test] - fn plain_400_is_invalid_request() { - let error = http( - 400, - r#"{"error":{"code":400,"message":"Invalid JSON payload","status":"INVALID_ARGUMENT"}}"#, - ); - assert!(matches!(error, StreamError::InvalidRequest { .. })); - } - - #[test] - fn stream_error_detected_and_classified() { - let error = super::stream_error( - r#"{"error":{"code":429,"message":"Quota exceeded","status":"RESOURCE_EXHAUSTED"}}"#, - ); - assert!(matches!(error, Some(StreamError::RateLimited { .. }))); - } - - #[test] - fn stream_error_wrapped_in_response_envelope() { - let error = super::stream_error( - r#"{"response":{"error":{"code":401,"message":"nope","status":"UNAUTHENTICATED"}}}"#, - ); - assert!(matches!(error, Some(StreamError::Auth { .. }))); - } - - #[test] - fn stream_error_none_for_normal_chunk() { - assert!( - super::stream_error(r#"{"candidates":[{"content":{"parts":[{"text":"hi"}]}}]}"#) - .is_none() - ); - assert!(super::stream_error("not json").is_none()); - } -} diff --git a/crates/goat-provider-gemini/src/lib.rs b/crates/goat-provider-gemini/src/lib.rs deleted file mode 100644 index b79ec0a..0000000 --- a/crates/goat-provider-gemini/src/lib.rs +++ /dev/null @@ -1,486 +0,0 @@ -mod codeassist; -mod error; -mod oauth; -mod wire; - -use std::sync::Arc; - -use eventsource_stream::Eventsource; -use futures::StreamExt; -use goat_auth::{CredentialKey, CredentialStore, TokenSet}; -use goat_provider::{ - AuthMethod, Capabilities, Effort, Model, Provider, ProviderId, ProviderMetadata, Request, - SearchResult, StreamError, StreamEvent, WebSearchOutput, -}; -use serde_json::json; -use tokio::{sync::Mutex, sync::mpsc, task::JoinHandle}; - -pub const PROVIDER_ID: &str = "gemini"; -pub const ENV_VAR: &str = "GEMINI_API_KEY"; -const GL_BASE: &str = "https://generativelanguage.googleapis.com/v1beta"; -const SEARCH_MODEL: &str = "gemini-2.5-flash"; - -fn gemini_supports_images(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - id.starts_with("gemini-") && !id.contains("embedding") -} - -const CATALOG: &[&str] = &[ - "gemini-3.5-flash", - "gemini-3.1-pro-preview", - "gemini-3.1-flash-lite", - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", -]; - -pub struct GeminiProvider { - store: CredentialStore, - key: CredentialKey, - client: reqwest::Client, - project: Arc>>, -} - -impl GeminiProvider { - fn new(store: CredentialStore, key: CredentialKey) -> Self { - Self { - store, - key, - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client"), - project: Arc::new(Mutex::new(None)), - } - } -} - -pub fn build(store: &CredentialStore, account: &str) -> GeminiProvider { - let key = CredentialKey::model(PROVIDER_ID, account); - GeminiProvider::new(store.clone(), key) -} - -#[derive(serde::Deserialize)] -struct GlModelsResponse { - #[serde(default)] - models: Vec, -} - -#[derive(serde::Deserialize)] -struct GlModel { - name: String, - #[serde(default, rename = "supportedGenerationMethods")] - supported_generation_methods: Vec, -} - -async fn fetch_gl_models(client: &reqwest::Client, api_key: &str) -> Vec { - let url = format!("{GL_BASE}/models"); - let Ok(resp) = client - .get(&url) - .header("x-goog-api-key", api_key) - .send() - .await - else { - return Vec::new(); - }; - if !resp.status().is_success() { - return Vec::new(); - } - let Ok(body) = resp.json::().await else { - return Vec::new(); - }; - body.models - .into_iter() - .filter(|m| { - m.supported_generation_methods - .iter() - .any(|method| method == "generateContent") - }) - .map(|m| { - let id = m.name.strip_prefix("models/").unwrap_or(&m.name).to_owned(); - Model { - supports_images: gemini_supports_images(&id), - id, - } - }) - .collect() -} - -async fn stream_response(resp: reqwest::Response, tx: &mpsc::Sender, oauth: bool) { - let mut stream = resp.bytes_stream().eventsource(); - let mut last_usage: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(event) => { - if event.data == "[DONE]" { - break; - } - let Ok(value) = serde_json::from_str::(&event.data) else { - continue; - }; - if let Some(error) = error::stream_error(&event.data) { - let _ = tx.send(StreamEvent::Failed { error }).await; - return; - } - for ev in wire::parse_chunk(&value, oauth) { - if tx.send(ev).await.is_err() { - return; - } - } - if let Some(usage) = wire::parse_usage(&value, oauth) { - last_usage = Some(usage); - } - match wire::extract_finish_reason(&value, oauth) { - None | Some("") => {} - Some("STOP" | "MAX_TOKENS") => break, - Some(reason) => { - if let Some(usage) = last_usage.take() { - let _ = tx.send(StreamEvent::Usage { usage }).await; - } - let _ = tx - .send(StreamEvent::Failed { - error: goat_provider::StreamError::other(format!( - "generation stopped: {reason}" - )), - }) - .await; - return; - } - } - } - Err(err) => { - let _ = tx - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport(err.to_string()), - }) - .await; - return; - } - } - } - if let Some(usage) = last_usage.take() { - let _ = tx.send(StreamEvent::Usage { usage }).await; - } - let _ = tx.send(StreamEvent::Completed).await; -} - -fn parse_grounding_results(value: &serde_json::Value) -> Vec { - let root = value.get("response").unwrap_or(value); - let mut out = Vec::new(); - let Some(candidate) = root - .get("candidates") - .and_then(|candidates| candidates.as_array()) - .and_then(|candidates| candidates.first()) - else { - return out; - }; - let Some(chunks) = candidate - .get("groundingMetadata") - .and_then(|meta| meta.get("groundingChunks")) - .and_then(|chunks| chunks.as_array()) - else { - return out; - }; - for chunk in chunks { - let Some(web) = chunk.get("web") else { - continue; - }; - let url = web - .get("uri") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if url.is_empty() { - continue; - } - out.push(SearchResult { - title: web - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_owned(), - url: url.to_owned(), - snippet: String::new(), - }); - } - out -} - -impl Provider for GeminiProvider { - fn id(&self) -> ProviderId { - ProviderId::from(PROVIDER_ID) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: AuthMethod::ApiKeyOrOAuth, - images: true, - } - } - - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "network", - endpoint: None, - oauth: Some("browser"), - login_endpoint: None, - setup: &[], - } - } - - fn supports_images(&self, model: &str) -> bool { - gemini_supports_images(model) - } - - fn authenticated(&self) -> bool { - self.store.resolve(&self.key, Some(ENV_VAR)).is_some() - } - - fn catalog(&self) -> &'static [&'static str] { - CATALOG - } - - fn efforts(&self, model: &str) -> Vec { - wire::gemini_efforts(model) - } - - fn validate(&self) -> JoinHandle> { - let store = self.store.clone(); - let key = self.key.clone(); - let client = self.client.clone(); - tokio::spawn(async move { - let auth = oauth::current_auth(&store, &key) - .await - .ok_or_else(|| "no credentials".to_owned())?; - let api_key = match auth { - oauth::Auth::OAuth(_) => return Ok(()), - oauth::Auth::ApiKey(k) => k, - }; - let url = format!("{GL_BASE}/models"); - let resp = client - .get(&url) - .header("x-goog-api-key", &api_key) - .send() - .await - .map_err(|_| "could not reach Gemini API".to_owned())?; - let status = resp.status(); - if status.is_success() { - Ok(()) - } else if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - Err("invalid API key".to_owned()) - } else { - Err(format!("could not reach Gemini API: {status}")) - } - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(auth) = oauth::current_auth(&store, &key).await else { - return; - }; - match auth { - oauth::Auth::OAuth(_) => { - for &id in CATALOG { - if out - .send(Model { - id: id.to_owned(), - supports_images: gemini_supports_images(id), - }) - .await - .is_err() - { - return; - } - } - } - oauth::Auth::ApiKey(api_key) => { - for model in fetch_gl_models(&client, &api_key).await { - if out.send(model).await.is_err() { - return; - } - } - } - } - }) - } - - fn supports_web_search(&self) -> bool { - true - } - - fn web_search(&self, query: String) -> JoinHandle> { - let client = self.client.clone(); - let store = self.store.clone(); - let key = self.key.clone(); - let project_cache = Arc::clone(&self.project); - tokio::spawn(async move { - let Some(auth) = oauth::current_auth(&store, &key).await else { - return Err(StreamError::auth("not logged in to gemini")); - }; - let inner = json!({ - "contents": [{ "role": "user", "parts": [{ "text": query }] }], - "tools": [{ "google_search": {} }], - }); - let builder = match &auth { - oauth::Auth::ApiKey(api_key) => { - let url = format!("{GL_BASE}/models/{SEARCH_MODEL}:generateContent"); - client - .post(&url) - .header("x-goog-api-key", api_key) - .json(&inner) - } - oauth::Auth::OAuth(access) => { - let project = - codeassist::resolve_project(&client, access, &project_cache).await?; - let body = codeassist::wrap_request(SEARCH_MODEL, project.as_deref(), inner); - let url = format!("{}:generateContent", codeassist::CA_BASE); - client.post(&url).bearer_auth(access).json(&body) - } - }; - let resp = builder - .send() - .await - .map_err(|err| StreamError::transport(err.to_string()))?; - if !resp.status().is_success() { - let status = resp.status(); - let detail = resp.text().await.unwrap_or_default(); - return Err(error::classify_http(status, &detail)); - } - let value: serde_json::Value = resp - .json() - .await - .map_err(|err| StreamError::other(format!("invalid search response: {err}")))?; - Ok(WebSearchOutput::from_results(parse_grounding_results( - &value, - ))) - }) - } - - fn stream(&self, req: Request, tx: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let store = self.store.clone(); - let key = self.key.clone(); - let project_cache = Arc::clone(&self.project); - tokio::spawn(async move { - let Some(auth) = oauth::current_auth(&store, &key).await else { - let _ = tx - .send(StreamEvent::Failed { - error: goat_provider::StreamError::auth("not logged in to gemini"), - }) - .await; - return; - }; - - let inner = wire::build_request(&req); - let inner_value = wire::inner_request_to_value(inner); - - tracing::debug!(model = %req.model, body = %inner_value, "gemini request"); - - let (builder, oauth) = match &auth { - oauth::Auth::ApiKey(api_key) => { - let url = format!( - "{GL_BASE}/models/{}:streamGenerateContent?alt=sse", - req.model - ); - tracing::debug!(%url, "gemini api-key stream"); - let b = client - .post(&url) - .header("x-goog-api-key", api_key) - .json(&inner_value); - (b, false) - } - oauth::Auth::OAuth(access) => { - let project = - match codeassist::resolve_project(&client, access, &project_cache).await { - Ok(p) => p, - Err(e) => { - let _ = tx.send(StreamEvent::Failed { error: e }).await; - return; - } - }; - let body = - codeassist::wrap_request(&req.model, project.as_deref(), inner_value); - let url = format!("{}:streamGenerateContent?alt=sse", codeassist::CA_BASE); - let b = client.post(&url).bearer_auth(access).json(&body); - (b, true) - } - }; - - let resp = match builder.send().await { - Ok(r) => r, - Err(err) => { - let _ = tx - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport(err.to_string()), - }) - .await; - return; - } - }; - - if !resp.status().is_success() { - let status = resp.status(); - let detail = resp.text().await.unwrap_or_default(); - let _ = tx - .send(StreamEvent::Failed { - error: error::classify_http(status, &detail), - }) - .await; - return; - } - - stream_response(resp, &tx, oauth).await; - }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - tokio::spawn(async move { oauth::do_login(&status).await.map_err(|e| e.to_string()) }) - } -} - -#[cfg(test)] -mod search_tests { - use super::parse_grounding_results; - - #[test] - fn extracts_grounding_chunks() { - let value = serde_json::json!({ - "candidates": [{ - "groundingMetadata": { - "groundingChunks": [ - { "web": { "uri": "https://a.example", "title": "A" } }, - { "web": { "uri": "https://b.example", "title": "B" } } - ] - } - }] - }); - let results = parse_grounding_results(&value); - assert_eq!(results.len(), 2); - assert_eq!(results[0].url, "https://a.example"); - assert_eq!(results[1].title, "B"); - } - - #[test] - fn unwraps_codeassist_envelope() { - let value = serde_json::json!({ - "response": { - "candidates": [{ - "groundingMetadata": { - "groundingChunks": [{ "web": { "uri": "https://x.example", "title": "X" } }] - } - }] - } - }); - let results = parse_grounding_results(&value); - assert_eq!(results.len(), 1); - assert_eq!(results[0].url, "https://x.example"); - } -} diff --git a/crates/goat-provider-gemini/src/oauth.rs b/crates/goat-provider-gemini/src/oauth.rs deleted file mode 100644 index 569b844..0000000 --- a/crates/goat-provider-gemini/src/oauth.rs +++ /dev/null @@ -1,181 +0,0 @@ -use goat_auth::{ - AuthError, Credential, CredentialKey, CredentialStore, Pkce, TokenSet, capture_on, - ensure_valid, random_state, -}; -use serde::Deserialize; -use tokio::sync::mpsc; - -const CLIENT_ID: &str = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; -const CLIENT_SECRET: &str = "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"; -const AUTHORIZE: &str = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN: &str = "https://oauth2.googleapis.com/token"; -const SCOPE: &str = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"; - -#[derive(Debug, thiserror::Error)] -pub enum GeminiAuthError { - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("url error: {0}")] - Url(String), - #[error("token error: {0}")] - Token(String), - #[error("auth error: {0}")] - Auth(#[from] AuthError), -} - -pub enum Auth { - ApiKey(String), - OAuth(String), -} - -fn authorize_url( - challenge: &str, - state: &str, - redirect_uri: &str, -) -> Result { - reqwest::Url::parse_with_params( - AUTHORIZE, - &[ - ("client_id", CLIENT_ID), - ("response_type", "code"), - ("redirect_uri", redirect_uri), - ("scope", SCOPE), - ("code_challenge", challenge), - ("code_challenge_method", "S256"), - ("state", state), - ("access_type", "offline"), - ("prompt", "consent"), - ], - ) - .map(|url| url.to_string()) - .map_err(|err| GeminiAuthError::Url(err.to_string())) -} - -fn auth_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client") -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, -} - -async fn parse_token_response( - response: reqwest::Response, -) -> Result { - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(GeminiAuthError::Token(format!("{status}: {body}"))); - } - response.json().await.map_err(GeminiAuthError::Http) -} - -async fn exchange_code( - code: &str, - verifier: &str, - redirect_uri: &str, -) -> Result { - let response = auth_client() - .post(TOKEN) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", redirect_uri), - ("client_id", CLIENT_ID), - ("client_secret", CLIENT_SECRET), - ("code_verifier", verifier), - ]) - .send() - .await?; - parse_token_response(response) - .await - .map(|t| TokenSet::from_parts(t.access_token, t.refresh_token, t.expires_in, None)) -} - -pub async fn do_refresh(refresh_token: String) -> Result { - let response = auth_client() - .post(TOKEN) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token.as_str()), - ("client_id", CLIENT_ID), - ("client_secret", CLIENT_SECRET), - ]) - .send() - .await - .map_err(|e| e.to_string())?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(format!("{status}: {body}")); - } - response - .json::() - .await - .map_err(|e| e.to_string()) - .map(|t| { - TokenSet::from_parts( - t.access_token, - t.refresh_token, - t.expires_in, - Some(&refresh_token), - ) - }) -} - -pub async fn do_login(status: &mpsc::Sender) -> Result { - let pkce = Pkce::generate(); - let state = random_state(); - let (listener, port) = goat_auth::bind_loopback().await?; - let redirect = format!("http://127.0.0.1:{port}/oauth2callback"); - let url = authorize_url(&pkce.challenge, &state, &redirect)?; - let _ = status - .send(format!( - "opening browser to sign in to Google\u{2026} if it does not open, visit:\n{url}" - )) - .await; - let _ = open::that(&url); - let code = capture_on(listener, &state).await?; - exchange_code(&code, &pkce.verifier, &redirect).await -} - -pub async fn current_auth(store: &CredentialStore, key: &CredentialKey) -> Option { - match store.resolve(key, Some(super::ENV_VAR))? { - Credential::ApiKey(secret) | Credential::ApiKeyWithEndpoint { secret, .. } => { - Some(Auth::ApiKey(secret.expose().to_owned())) - } - Credential::OAuth(tokens) => { - let tokens = ensure_valid(tokens, store, key, do_refresh).await?; - Some(Auth::OAuth(tokens.access_token.expose().to_owned())) - } - } -} - -#[cfg(test)] -mod tests { - use super::authorize_url; - - #[test] - fn authorize_url_contains_required_params() { - let url = authorize_url("CHAL", "STATE", "http://127.0.0.1:9999/oauth2callback").unwrap(); - assert!( - url.contains( - "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com" - ) - ); - assert!(url.contains("code_challenge=CHAL")); - assert!(url.contains("code_challenge_method=S256")); - assert!(url.contains("state=STATE")); - assert!(url.contains("access_type=offline")); - assert!(url.contains("prompt=consent")); - assert!(url.contains("127.0.0.1")); - assert!(url.contains("oauth2callback")); - } -} diff --git a/crates/goat-provider-gemini/src/wire.rs b/crates/goat-provider-gemini/src/wire.rs deleted file mode 100644 index da826dd..0000000 --- a/crates/goat-provider-gemini/src/wire.rs +++ /dev/null @@ -1,809 +0,0 @@ -use std::collections::HashMap; - -use goat_provider::{ - ContentBlock, Effort, MessageRole, Request, StreamEvent, ToolDefinition, Usage, -}; -use serde_json::{Value, json}; - -pub fn gemini_efforts(model: &str) -> Vec { - let id = model.to_ascii_lowercase(); - if id.contains("3.1-pro") || id.contains("2.5-pro") { - vec![Effort::Low, Effort::Medium, Effort::High, Effort::Max] - } else { - vec![Effort::Off, Effort::Low, Effort::Medium, Effort::High] - } -} - -fn is_25(model: &str) -> bool { - model.to_ascii_lowercase().contains("2.5") -} - -fn is_25_pro(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - id.contains("2.5") && id.contains("pro") -} - -pub fn generation_config(model: &str, effort: Option) -> Option { - let effort = effort?; - if is_25(model) { - let budget: Option = if is_25_pro(model) { - match effort { - Effort::Off => return None, - Effort::Low => Some(512), - Effort::Medium => Some(4096), - Effort::High => Some(16384), - Effort::Xhigh | Effort::Max => Some(-1), - } - } else { - match effort { - Effort::Off => Some(0), - Effort::Low => Some(512), - Effort::Medium => Some(4096), - Effort::High => Some(16384), - Effort::Xhigh | Effort::Max => Some(-1), - } - }; - let budget = budget?; - Some(json!({ - "thinkingConfig": { - "thinkingBudget": budget, - "includeThoughts": true, - } - })) - } else { - let level = match effort { - Effort::Off => "MINIMAL", - Effort::Low => "LOW", - Effort::Medium => "MEDIUM", - Effort::High | Effort::Xhigh | Effort::Max => "HIGH", - }; - Some(json!({ "thinkingConfig": { "thinkingLevel": level } })) - } -} - -fn sanitize_schema(schema: &Value) -> Value { - match schema { - Value::Object(map) => { - let cleaned: serde_json::Map = map - .iter() - .filter(|(k, _)| { - !matches!( - k.as_str(), - "$schema" | "additionalProperties" | "$defs" | "definitions" - ) - }) - .map(|(k, v)| (k.clone(), sanitize_schema(v))) - .collect(); - Value::Object(cleaned) - } - Value::Array(arr) => Value::Array(arr.iter().map(sanitize_schema).collect()), - other => other.clone(), - } -} - -fn tool_declarations(tools: &[ToolDefinition]) -> Value { - let decls: Vec = tools - .iter() - .map(|t| { - json!({ - "name": t.name, - "description": t.description, - "parameters": sanitize_schema(&t.input_schema), - }) - }) - .collect(); - json!([{ "functionDeclarations": decls }]) -} - -fn is_synthetic_id(id: &str) -> bool { - id.starts_with("goat-") -} - -fn content_block_to_part( - block: &ContentBlock, - id_to_name: &HashMap, - synthetic_counter: &mut u32, -) -> (Option, Value) { - match block { - ContentBlock::Text { text } => (None, json!({ "text": text })), - ContentBlock::Thinking { text, signature } => { - if signature.is_empty() { - (None, json!({ "text": text, "thought": true })) - } else { - ( - None, - json!({ "text": text, "thought": true, "thoughtSignature": signature }), - ) - } - } - ContentBlock::RedactedThinking { data } => { - (None, json!({ "thought": true, "thoughtSignature": data })) - } - ContentBlock::ToolUse { id, name, input } => { - let args = if input.is_object() { - input.clone() - } else { - json!({}) - }; - let fc = if is_synthetic_id(id) { - *synthetic_counter += 1; - json!({ "functionCall": { "name": name, "args": args } }) - } else { - json!({ "functionCall": { "name": name, "args": args, "id": id } }) - }; - (None, fc) - } - ContentBlock::ToolResult { - tool_use_id, - content, - is_error, - } => { - let func_name = id_to_name - .get(tool_use_id.as_str()) - .cloned() - .unwrap_or_else(|| tool_use_id.clone()); - let output_text = ContentBlock::tool_result_text(content); - let response_body = if *is_error { - json!({ "error": output_text }) - } else { - json!({ "output": output_text }) - }; - let fr = if is_synthetic_id(tool_use_id) { - json!({ "functionResponse": { "name": func_name, "response": response_body } }) - } else { - json!({ - "functionResponse": { - "name": func_name, - "id": tool_use_id, - "response": response_body, - } - }) - }; - (Some("user".to_owned()), fr) - } - ContentBlock::Image { media_type, data } => ( - None, - json!({ "inlineData": { "mimeType": media_type, "data": data } }), - ), - } -} - -pub struct InnerRequest { - pub contents: Vec, - pub system_instruction: Option, - pub tools: Option, - pub tool_config: Option, - pub generation_config: Option, -} - -pub fn build_request(req: &Request) -> InnerRequest { - let mut id_to_name: HashMap = HashMap::new(); - for msg in &req.messages { - for block in &msg.content { - if let ContentBlock::ToolUse { id, name, .. } = block { - id_to_name.insert(id.clone(), name.clone()); - } - } - } - - let mut system_parts: Vec = Vec::new(); - let mut contents: Vec = Vec::new(); - let mut synthetic_counter: u32 = 0; - - for msg in &req.messages { - match msg.role { - MessageRole::System => { - for block in &msg.content { - if let ContentBlock::Text { text } = block { - system_parts.push(json!({ "text": text })); - } - } - } - MessageRole::User => { - let mut parts: Vec = Vec::new(); - let mut pending_fr: Vec = Vec::new(); - for block in &msg.content { - let (override_role, part) = - content_block_to_part(block, &id_to_name, &mut synthetic_counter); - if override_role.is_some() { - pending_fr.push(part); - } else { - parts.push(part); - } - } - if !parts.is_empty() { - contents.push(json!({ "role": "user", "parts": parts })); - } - if !pending_fr.is_empty() { - contents.push(json!({ "role": "user", "parts": pending_fr })); - } - } - MessageRole::Assistant => { - let parts: Vec = msg - .content - .iter() - .map(|b| content_block_to_part(b, &id_to_name, &mut synthetic_counter).1) - .collect(); - if !parts.is_empty() { - contents.push(json!({ "role": "model", "parts": parts })); - } - } - } - } - - let contents = coalesce_user_text_contents(contents); - - let system_instruction = if system_parts.is_empty() { - None - } else { - Some(json!({ "parts": system_parts })) - }; - - let tools = if req.tools.is_empty() { - None - } else { - Some(tool_declarations(&req.tools)) - }; - - let tool_config = (tools.is_some() - && matches!(req.tool_choice, goat_provider::ToolChoice::None)) - .then(|| json!({ "functionCallingConfig": { "mode": "NONE" } })); - - let gen_cfg = generation_config(&req.model, req.effort); - - InnerRequest { - contents, - system_instruction, - tools, - tool_config, - generation_config: gen_cfg, - } -} - -fn is_plain_user_content(content: &Value) -> bool { - content.get("role").and_then(Value::as_str) == Some("user") - && content - .get("parts") - .and_then(Value::as_array) - .is_some_and(|parts| { - parts - .iter() - .all(|part| part.get("functionResponse").is_none()) - }) -} - -fn coalesce_user_text_contents(contents: Vec) -> Vec { - let mut out: Vec = Vec::new(); - for mut content in contents { - if let Some(last) = out.last_mut() - && is_plain_user_content(last) - && is_plain_user_content(&content) - && let (Some(Value::Array(dst)), Some(Value::Array(src))) = - (last.get_mut("parts"), content.get_mut("parts")) - { - dst.append(src); - continue; - } - out.push(content); - } - out -} - -pub fn inner_request_to_value(inner: InnerRequest) -> Value { - let mut obj = serde_json::Map::new(); - obj.insert("contents".to_owned(), Value::Array(inner.contents)); - if let Some(si) = inner.system_instruction { - obj.insert("systemInstruction".to_owned(), si); - } - if let Some(tools) = inner.tools { - obj.insert("tools".to_owned(), tools); - } - if let Some(tool_config) = inner.tool_config { - obj.insert("toolConfig".to_owned(), tool_config); - } - if let Some(gc) = inner.generation_config { - obj.insert("generationConfig".to_owned(), gc); - } - Value::Object(obj) -} - -static SYNTHETIC_TOOL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); - -pub fn parse_chunk(value: &Value, oauth: bool) -> Vec { - let payload = if oauth { - value.get("response").unwrap_or(value) - } else { - value - }; - - let mut events = Vec::new(); - let Some(parts) = payload - .get("candidates") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("content")) - .and_then(|c| c.get("parts")) - .and_then(Value::as_array) - else { - return events; - }; - - for part in parts { - if let Some(fc) = part.get("functionCall") { - let name = fc - .get("name") - .and_then(Value::as_str) - .unwrap_or("") - .to_owned(); - let id = fc.get("id").and_then(Value::as_str).map_or_else( - || { - let n = SYNTHETIC_TOOL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - format!("goat-{n}") - }, - str::to_owned, - ); - let input = fc - .get("args") - .map_or_else(|| "{}".to_owned(), Value::to_string); - events.push(StreamEvent::ToolCall { id, name, input }); - continue; - } - - let is_thought = part - .get("thought") - .and_then(Value::as_bool) - .unwrap_or(false); - let text = part.get("text").and_then(Value::as_str).unwrap_or(""); - - if is_thought { - if !text.is_empty() { - events.push(StreamEvent::ThinkingDelta { - text: text.to_owned(), - }); - } - if let Some(sig) = part.get("thoughtSignature").and_then(Value::as_str) - && !sig.is_empty() - { - events.push(StreamEvent::ThinkingSignature { - signature: sig.to_owned(), - }); - } - } else if !text.is_empty() { - events.push(StreamEvent::TextDelta { - text: text.to_owned(), - }); - } - } - - events -} - -pub fn extract_finish_reason(value: &Value, oauth: bool) -> Option<&str> { - let payload = if oauth { - value.get("response").unwrap_or(value) - } else { - value - }; - payload - .get("candidates") - .and_then(|c| c.get(0)) - .and_then(|c| c.get("finishReason")) - .and_then(Value::as_str) -} - -pub fn parse_usage(value: &Value, oauth: bool) -> Option { - let payload = if oauth { - value.get("response").unwrap_or(value) - } else { - value - }; - let meta = payload.get("usageMetadata")?; - let count = |key: &str| -> u32 { - meta.get(key) - .and_then(Value::as_u64) - .and_then(|n| u32::try_from(n).ok()) - .unwrap_or(0) - }; - Some(Usage { - input_tokens: count("promptTokenCount"), - output_tokens: count("candidatesTokenCount") + count("thoughtsTokenCount"), - cache_read_tokens: count("cachedContentTokenCount"), - cache_write_tokens: 0, - }) -} - -#[cfg(test)] -mod tests { - use goat_provider::{ContentBlock, Effort, Message, MessageRole, Request, ToolDefinition}; - use serde_json::json; - - use super::{ - build_request, gemini_efforts, generation_config, inner_request_to_value, parse_chunk, - parse_usage, - }; - - fn make_request(messages: Vec) -> Request { - Request { - model: "gemini-2.5-flash".to_owned(), - messages, - tool_choice: goat_provider::ToolChoice::Auto, - tools: vec![], - effort: None, - } - } - - #[test] - fn text_message_maps_to_user_part() { - let req = make_request(vec![Message { - role: MessageRole::User, - content: vec![ContentBlock::Text { - text: "hello".to_owned(), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - assert_eq!(v["contents"][0]["role"], "user"); - assert_eq!(v["contents"][0]["parts"][0]["text"], "hello"); - } - - #[test] - fn system_message_maps_to_system_instruction() { - let req = make_request(vec![Message { - role: MessageRole::System, - content: vec![ContentBlock::Text { - text: "be helpful".to_owned(), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - assert!(v.get("systemInstruction").is_some()); - assert_eq!(v["systemInstruction"]["parts"][0]["text"], "be helpful"); - assert!(v["contents"].as_array().is_none_or(Vec::is_empty)); - } - - #[test] - fn assistant_message_maps_to_model_role() { - let req = make_request(vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::Text { - text: "hi".to_owned(), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - assert_eq!(v["contents"][0]["role"], "model"); - } - - #[test] - fn thinking_block_with_signature() { - let req = make_request(vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::Thinking { - text: "ponder".to_owned(), - signature: "sig123".to_owned(), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let part = &v["contents"][0]["parts"][0]; - assert_eq!(part["thought"], true); - assert_eq!(part["text"], "ponder"); - assert_eq!(part["thoughtSignature"], "sig123"); - } - - #[test] - fn thinking_block_empty_signature_omits_field() { - let req = make_request(vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::Thinking { - text: "think".to_owned(), - signature: String::new(), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let part = &v["contents"][0]["parts"][0]; - assert!(part.get("thoughtSignature").is_none()); - } - - #[test] - fn tool_use_real_id_included() { - let req = make_request(vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "real-id-123".to_owned(), - name: "my_tool".to_owned(), - input: json!({ "x": 1 }), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let fc = &v["contents"][0]["parts"][0]["functionCall"]; - assert_eq!(fc["name"], "my_tool"); - assert_eq!(fc["id"], "real-id-123"); - } - - #[test] - fn tool_use_synthetic_id_omitted() { - let req = make_request(vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "goat-1".to_owned(), - name: "my_tool".to_owned(), - input: json!({}), - }], - }]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let fc = &v["contents"][0]["parts"][0]["functionCall"]; - assert!(fc.get("id").is_none()); - assert_eq!(fc["name"], "my_tool"); - } - - #[test] - fn consecutive_user_text_contents_merge() { - let req = make_request(vec![ - Message { - role: MessageRole::User, - content: vec![ContentBlock::Text { - text: "first".to_owned(), - }], - }, - Message { - role: MessageRole::User, - content: vec![ContentBlock::Text { - text: "second".to_owned(), - }], - }, - ]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - assert_eq!(v["contents"].as_array().unwrap().len(), 1); - assert_eq!(v["contents"][0]["role"], "user"); - assert_eq!(v["contents"][0]["parts"][0]["text"], "first"); - assert_eq!(v["contents"][0]["parts"][1]["text"], "second"); - } - - #[test] - fn function_response_content_does_not_merge() { - let req = make_request(vec![ - Message { - role: MessageRole::User, - content: vec![ContentBlock::Text { - text: "run it".to_owned(), - }], - }, - Message { - role: MessageRole::User, - content: vec![ContentBlock::text_result( - "real-id-1".to_owned(), - "done", - false, - )], - }, - ]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - assert_eq!(v["contents"].as_array().unwrap().len(), 2); - assert!( - v["contents"][1]["parts"][0] - .get("functionResponse") - .is_some() - ); - } - - #[test] - fn tool_result_uses_id_to_name_map() { - let req = make_request(vec![ - Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "real-id-1".to_owned(), - name: "read_file".to_owned(), - input: json!({}), - }], - }, - Message { - role: MessageRole::User, - content: vec![ContentBlock::text_result( - "real-id-1".to_owned(), - "file content", - false, - )], - }, - ]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let fr = &v["contents"][1]["parts"][0]["functionResponse"]; - assert_eq!(fr["name"], "read_file"); - assert_eq!(fr["id"], "real-id-1"); - assert_eq!(fr["response"]["output"], "file content"); - } - - #[test] - fn tool_result_synthetic_id_omits_id_field() { - let req = make_request(vec![ - Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "goat-1".to_owned(), - name: "write_file".to_owned(), - input: json!({}), - }], - }, - Message { - role: MessageRole::User, - content: vec![ContentBlock::text_result("goat-1".to_owned(), "ok", false)], - }, - ]); - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let fr = &v["contents"][1]["parts"][0]["functionResponse"]; - assert!(fr.get("id").is_none()); - assert_eq!(fr["name"], "write_file"); - } - - #[test] - fn generation_config_25_flash_off_returns_zero_budget() { - let cfg = generation_config("gemini-2.5-flash", Some(Effort::Off)).unwrap(); - assert_eq!(cfg["thinkingConfig"]["thinkingBudget"], 0); - assert_eq!(cfg["thinkingConfig"]["includeThoughts"], true); - } - - #[test] - fn generation_config_25_pro_off_returns_none() { - let cfg = generation_config("gemini-2.5-pro", Some(Effort::Off)); - assert!(cfg.is_none()); - } - - #[test] - fn generation_config_25_flash_medium() { - let cfg = generation_config("gemini-2.5-flash", Some(Effort::Medium)).unwrap(); - assert_eq!(cfg["thinkingConfig"]["thinkingBudget"], 4096); - } - - #[test] - fn generation_config_25_max_dynamic() { - let cfg = generation_config("gemini-2.5-flash", Some(Effort::Max)).unwrap(); - assert_eq!(cfg["thinkingConfig"]["thinkingBudget"], -1); - } - - #[test] - fn generation_config_3x_flash_uses_level() { - let cfg = generation_config("gemini-3.5-flash", Some(Effort::Medium)).unwrap(); - assert_eq!(cfg["thinkingConfig"]["thinkingLevel"], "MEDIUM"); - assert!(cfg["thinkingConfig"].get("thinkingBudget").is_none()); - } - - #[test] - fn generation_config_3x_off_maps_to_minimal() { - let cfg = generation_config("gemini-3.5-flash", Some(Effort::Off)).unwrap(); - assert_eq!(cfg["thinkingConfig"]["thinkingLevel"], "MINIMAL"); - } - - #[test] - fn generation_config_none_effort_returns_none() { - assert!(generation_config("gemini-2.5-flash", None).is_none()); - } - - #[test] - fn gemini_efforts_pro_no_off() { - let e = gemini_efforts("gemini-2.5-pro"); - assert!(!e.contains(&Effort::Off)); - assert!(e.contains(&Effort::High)); - } - - #[test] - fn gemini_efforts_flash_has_off() { - let e = gemini_efforts("gemini-2.5-flash"); - assert!(e.contains(&Effort::Off)); - } - - #[test] - fn parse_chunk_text_delta() { - let chunk = json!({ - "candidates": [{ - "content": { "parts": [{ "text": "hello" }] } - }] - }); - let events = parse_chunk(&chunk, false); - assert_eq!(events.len(), 1); - assert!( - matches!(&events[0], goat_provider::StreamEvent::TextDelta { text } if text == "hello") - ); - } - - #[test] - fn parse_chunk_thought() { - let chunk = json!({ - "candidates": [{ - "content": { "parts": [{ "thought": true, "text": "thinking..." }] } - }] - }); - let events = parse_chunk(&chunk, false); - assert!(matches!( - &events[0], - goat_provider::StreamEvent::ThinkingDelta { .. } - )); - } - - #[test] - fn parse_chunk_function_call_no_id_uses_synthetic() { - let chunk = json!({ - "candidates": [{ - "content": { "parts": [{ "functionCall": { "name": "foo", "args": {} } }] } - }] - }); - let events = parse_chunk(&chunk, false); - assert!( - matches!(&events[0], goat_provider::StreamEvent::ToolCall { id, .. } if id.starts_with("goat-")) - ); - } - - #[test] - fn parse_chunk_oauth_unwraps_response() { - let chunk = json!({ - "response": { - "candidates": [{ - "content": { "parts": [{ "text": "wrapped" }] } - }] - } - }); - let events = parse_chunk(&chunk, true); - assert!( - matches!(&events[0], goat_provider::StreamEvent::TextDelta { text } if text == "wrapped") - ); - } - - #[test] - fn tool_request_serialized() { - let req = Request { - model: "gemini-2.5-flash".to_owned(), - messages: vec![], - tool_choice: goat_provider::ToolChoice::Auto, - tools: vec![ToolDefinition { - name: "fn1".to_owned(), - description: "does fn1".to_owned(), - input_schema: json!({ "type": "object", "$schema": "ignored" }), - }], - effort: None, - }; - let inner = build_request(&req); - let v = inner_request_to_value(inner); - let decl = &v["tools"][0]["functionDeclarations"][0]; - assert_eq!(decl["name"], "fn1"); - assert!(decl["parameters"].get("$schema").is_none()); - } - - #[test] - fn parse_usage_sums_candidates_and_thoughts() { - let chunk = json!({ - "usageMetadata": { - "promptTokenCount": 100, - "candidatesTokenCount": 40, - "thoughtsTokenCount": 25, - "cachedContentTokenCount": 10 - } - }); - let usage = parse_usage(&chunk, false).expect("usage"); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.output_tokens, 65); - assert_eq!(usage.cache_read_tokens, 10); - assert_eq!(usage.cache_write_tokens, 0); - } - - #[test] - fn parse_usage_oauth_unwraps_response() { - let chunk = json!({ - "response": { "usageMetadata": { "promptTokenCount": 7 } } - }); - let usage = parse_usage(&chunk, true).expect("usage"); - assert_eq!(usage.input_tokens, 7); - assert_eq!(usage.output_tokens, 0); - } - - #[test] - fn parse_usage_absent_returns_none() { - let chunk = json!({ "candidates": [] }); - assert!(parse_usage(&chunk, false).is_none()); - } -} diff --git a/crates/goat-provider-groq/Cargo.toml b/crates/goat-provider-groq/Cargo.toml deleted file mode 100644 index b232cc8..0000000 --- a/crates/goat-provider-groq/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-groq" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-groq/src/lib.rs b/crates/goat-provider-groq/src/lib.rs deleted file mode 100644 index a93cfda..0000000 --- a/crates/goat-provider-groq/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider_openai_compat::{OpenAiCompatProvider, api_key}; - -pub const PROVIDER_ID: &str = "groq"; -const BASE_URL: &str = "https://api.groq.com/openai/v1"; -const HOST: &str = "api.groq.com"; -const ENV_VAR: &str = "GROQ_API_KEY"; - -const CATALOG: &[&str] = &[ - "llama-3.3-70b-versatile", - "llama-3.1-8b-instant", - "openai/gpt-oss-120b", - "openai/gpt-oss-20b", - "qwen/qwen3-32b", - "qwen/qwen3.6-27b", - "meta-llama/llama-4-scout-17b-16e-instruct", -]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[ - ("llama-3.3", 131_072), - ("llama-3.1", 131_072), - ("openai/gpt-oss", 131_072), - ("qwen/qwen3", 131_072), - ("meta-llama/llama-4-scout", 131_072), -]; - -fn is_chat_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - !(id.contains("whisper") || id.contains("tts") || id.contains("embedding")) -} - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_model_filter(is_chat_model) - .with_images(false) - .with_stream_options(false) - .with_reasoning_effort(false) -} diff --git a/crates/goat-provider-kimi-code/Cargo.toml b/crates/goat-provider-kimi-code/Cargo.toml deleted file mode 100644 index ba2ba6c..0000000 --- a/crates/goat-provider-kimi-code/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "goat-provider-kimi-code" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -tokio = { workspace = true } -thiserror = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-kimi-code/src/lib.rs b/crates/goat-provider-kimi-code/src/lib.rs deleted file mode 100644 index a23c38c..0000000 --- a/crates/goat-provider-kimi-code/src/lib.rs +++ /dev/null @@ -1,242 +0,0 @@ -mod oauth; - -use goat_auth::{Credential, CredentialKey, CredentialStore, TokenSet}; -use goat_provider::{ - AuthMethod, Capabilities, Effort, Model, Provider, ProviderId, ProviderMetadata, Request, - StreamError, StreamEvent, WebSearchOutput, -}; -use goat_provider_openai_compat::{ - ChatDiscovery, OpenAiCompatProvider, enforce_https_host, no_efforts, no_vision, -}; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub const PROVIDER_ID: &str = "kimi-code"; - -const BASE_URL: &str = "https://api.kimi.com/coding/v1"; -const ALLOWED_HOST: &str = "api.kimi.com"; - -const SETUP: &[&str] = &[ - "Kimi Code OAuth device-code login.", - "Run `goat-code provider login kimi-code`, open the URL, and enter the code.", -]; - -const CATALOG: &[&str] = &[ - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", - "kimi-k2.5", -]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[ - ("kimi-k2.7", 256_000), - ("kimi-k2.6", 256_000), - ("kimi-k2.5", 256_000), -]; - -pub fn build(store: &CredentialStore, account: &str) -> KimiCodeProvider { - enforce_https_host(BASE_URL, ALLOWED_HOST).expect("kimi-code provider base URL"); - KimiCodeProvider::new(store.clone(), CredentialKey::model(PROVIDER_ID, account)) -} - -pub struct KimiCodeProvider { - store: CredentialStore, - key: CredentialKey, - client: reqwest::Client, -} - -impl KimiCodeProvider { - pub fn new(store: CredentialStore, key: CredentialKey) -> Self { - Self { - store, - key, - client: oauth::oauth_client(), - } - } -} - -impl Provider for KimiCodeProvider { - fn id(&self) -> ProviderId { - ProviderId::from(PROVIDER_ID) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: AuthMethod::OAuth, - images: false, - } - } - - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata { - env_var: None, - validation: "network", - endpoint: Some(BASE_URL), - oauth: Some("device code"), - login_endpoint: None, - setup: SETUP, - } - } - - fn authenticated(&self) -> bool { - self.store - .get(&self.key) - .is_some_and(|cred| matches!(cred, Credential::OAuth(_))) - } - - fn catalog(&self) -> &'static [&'static str] { - CATALOG - } - - fn efforts(&self, _model: &str) -> Vec { - Vec::new() - } - - fn context_window(&self, model: &str) -> Option { - CONTEXT_WINDOWS - .iter() - .find_map(|(prefix, window)| model.starts_with(prefix).then_some(*window)) - } - - fn supports_images(&self, _model: &str) -> bool { - false - } - - fn verifies_credentials(&self) -> bool { - true - } - - fn validate(&self) -> JoinHandle> { - let store = self.store.clone(); - let key = self.key.clone(); - let client = self.client.clone(); - tokio::spawn(async move { - let Some(token) = oauth::current_token(&store, &key).await else { - return Err("no credentials".to_owned()); - }; - let response = client - .get(format!("{BASE_URL}/models")) - .bearer_auth(token) - .send() - .await - .map_err(|_| "could not reach provider".to_owned())?; - let status = response.status(); - if status.is_success() { - Ok(()) - } else if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - Err("invalid credentials".to_owned()) - } else { - Err(format!("could not reach provider: {status}")) - } - }) - } - - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(token) = oauth::current_token(&store, &key).await else { - let _ = events - .send(StreamEvent::Failed { - error: StreamError::auth("no credentials"), - }) - .await; - return; - }; - let provider = OpenAiCompatProvider::new( - ProviderId::from(PROVIDER_ID), - BASE_URL, - Some(token), - AuthMethod::OAuth, - ) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_vision_filter(no_vision) - .with_efforts(no_efforts) - .with_reasoning_effort(false); - let handle = provider.stream(req, events); - let _ = handle.await; - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some(token) = oauth::current_token(&store, &key).await else { - for id in CATALOG { - if out - .send(Model { - id: (*id).to_owned(), - supports_images: false, - }) - .await - .is_err() - { - return; - } - } - return; - }; - let provider = OpenAiCompatProvider::new( - ProviderId::from(PROVIDER_ID), - BASE_URL, - Some(token), - AuthMethod::OAuth, - ) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_model_filter(chat_model) - .with_vision_filter(no_vision) - .with_discovery(ChatDiscovery::CatalogOnly); - let handle = provider.discover(out); - let _ = handle.await; - }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - tokio::spawn(async move { oauth::login(&status).await.map_err(|err| err.to_string()) }) - } - - fn web_search(&self, query: String) -> JoinHandle> { - let _ = query; - tokio::spawn(async { Err(StreamError::other("web search is not supported")) }) - } -} - -fn chat_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - !id.contains("embedding") && !id.contains("image") && !id.contains("video") -} - -#[cfg(test)] -mod tests { - use goat_auth::CredentialStore; - use goat_provider::{AuthMethod, Provider}; - - use super::*; - use crate::oauth::valid_kimi_verification_url; - - fn store(name: &str) -> CredentialStore { - let _ = std::fs::remove_file(std::env::temp_dir().join(name)); - CredentialStore::new(std::env::temp_dir().join(name)) - } - - #[test] - fn kimi_code_is_oauth_provider() { - let store = store("goat-provider-kimi-code.json"); - let provider = build(&store, "default"); - assert_eq!(provider.capabilities().auth, AuthMethod::OAuth); - assert_eq!(provider.metadata().oauth, Some("device code")); - assert!(!provider.authenticated()); - assert_eq!(provider.catalog(), CATALOG); - assert!(valid_kimi_verification_url( - "https://auth.kimi.com/device?code=abc" - )); - assert!(!valid_kimi_verification_url( - "https://example.com/device?code=abc" - )); - } -} diff --git a/crates/goat-provider-kimi-code/src/oauth.rs b/crates/goat-provider-kimi-code/src/oauth.rs deleted file mode 100644 index 41fbfbf..0000000 --- a/crates/goat-provider-kimi-code/src/oauth.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::path::PathBuf; - -use goat_auth::{Credential, CredentialKey, CredentialStore, TokenSet, ensure_valid, now_secs}; -use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, USER_AGENT}; -use serde::Deserialize; -use tokio::sync::mpsc; - -const OAUTH_HOST: &str = "https://auth.kimi.com"; -const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; - -#[derive(Debug, thiserror::Error)] -pub enum KimiCodeOAuthError { - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("oauth error: {0}")] - OAuth(String), - #[error("io error: {0}")] - Io(#[from] std::io::Error), -} - -#[derive(Deserialize)] -struct DeviceAuthorizationResponse { - user_code: String, - device_code: String, - verification_uri: Option, - verification_uri_complete: String, - expires_in: Option, - interval: Option, -} - -#[derive(Deserialize)] -pub(crate) struct TokenResponse { - access_token: String, - refresh_token: String, - expires_in: i64, - scope: Option, - token_type: Option, -} - -#[derive(Deserialize)] -struct OAuthErrorResponse { - error: Option, - #[serde(rename = "error_description")] - _error_description: Option, -} - -pub fn oauth_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("reqwest client") -} - -pub async fn login(status: &mpsc::Sender) -> Result { - let client = oauth_client(); - let device = request_device_authorization(&client).await?; - let url = if device.verification_uri_complete.is_empty() { - device.verification_uri.as_deref().unwrap_or("") - } else { - &device.verification_uri_complete - }; - if !valid_kimi_verification_url(url) { - return Err(KimiCodeOAuthError::OAuth( - "device authorization returned an invalid verification URL".to_owned(), - )); - } - let _ = status - .send(format!("open {url} and enter code: {}", device.user_code)) - .await; - poll_device_token(&client, &device).await -} - -pub async fn current_token(store: &CredentialStore, key: &CredentialKey) -> Option { - let Credential::OAuth(tokens) = store.get(key)? else { - return None; - }; - let tokens = ensure_valid(tokens, store, key, refresh).await?; - Some(tokens.access_token.expose().to_owned()) -} - -async fn request_device_authorization( - client: &reqwest::Client, -) -> Result { - let response = client - .post(format!("{OAUTH_HOST}/api/oauth/device_authorization")) - .headers(kimi_headers()) - .form(&[("client_id", CLIENT_ID)]) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(KimiCodeOAuthError::OAuth(format!( - "device authorization failed: {status}" - ))); - } - let device: DeviceAuthorizationResponse = response.json().await?; - if device.user_code.is_empty() - || device.device_code.is_empty() - || device.verification_uri_complete.is_empty() - { - return Err(KimiCodeOAuthError::OAuth( - "device authorization response is missing required fields".to_owned(), - )); - } - Ok(device) -} - -async fn poll_device_token( - client: &reqwest::Client, - device: &DeviceAuthorizationResponse, -) -> Result { - let mut interval = device.interval.unwrap_or(5).max(1); - let deadline = now_secs() + i64::try_from(device.expires_in.unwrap_or(900)).unwrap_or(900); - loop { - if now_secs() > deadline { - return Err(KimiCodeOAuthError::OAuth( - "device login timed out".to_owned(), - )); - } - tokio::time::sleep(std::time::Duration::from_secs(interval)).await; - let response = client - .post(format!("{OAUTH_HOST}/api/oauth/token")) - .headers(kimi_headers()) - .form(&[ - ("client_id", CLIENT_ID), - ("device_code", device.device_code.as_str()), - ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), - ]) - .send() - .await?; - let status = response.status(); - if status.is_success() { - let tokens: TokenResponse = response.json().await?; - return parse_token_response(tokens); - } - let error = response.json::().await.ok(); - match error.as_ref().and_then(|err| err.error.as_deref()) { - Some("authorization_pending") => {} - Some("slow_down") => interval = interval.saturating_add(5).min(30), - Some("expired_token") => { - return Err(KimiCodeOAuthError::OAuth( - "device login code expired".to_owned(), - )); - } - Some("access_denied") => { - return Err(KimiCodeOAuthError::OAuth( - "device login access denied".to_owned(), - )); - } - Some(code) => { - return Err(KimiCodeOAuthError::OAuth(format!( - "device token polling failed: {code}" - ))); - } - None => { - return Err(KimiCodeOAuthError::OAuth(format!( - "device token polling failed: {status}" - ))); - } - } - } -} - -async fn refresh(refresh_token: String) -> Result { - let client = oauth_client(); - let response = client - .post(format!("{OAUTH_HOST}/api/oauth/token")) - .headers(kimi_headers()) - .form(&[ - ("client_id", CLIENT_ID), - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token.as_str()), - ]) - .send() - .await - .map_err(|_| "token refresh request failed".to_owned())?; - let status = response.status(); - if !status.is_success() { - return Err(format!("token refresh failed: {status}")); - } - response - .json::() - .await - .map_err(|_| "token refresh returned invalid JSON".to_owned()) - .and_then(|tokens| parse_token_response(tokens).map_err(|err| err.to_string())) -} - -pub(crate) fn parse_token_response(tokens: TokenResponse) -> Result { - let _ = (&tokens.scope, &tokens.token_type); - if tokens.access_token.is_empty() || tokens.refresh_token.is_empty() || tokens.expires_in <= 0 { - return Err(KimiCodeOAuthError::OAuth( - "token response is missing required fields".to_owned(), - )); - } - Ok(TokenSet::from_parts( - tokens.access_token, - Some(tokens.refresh_token), - Some(tokens.expires_in), - None, - )) -} - -fn kimi_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("application/x-www-form-urlencoded"), - ); - headers.insert(USER_AGENT, HeaderValue::from_static("goat-code/0.1.14")); - insert_header(&mut headers, "X-Msh-Platform", "kimi_code_cli"); - insert_header(&mut headers, "X-Msh-Version", env!("CARGO_PKG_VERSION")); - insert_header(&mut headers, "X-Msh-Device-Name", &device_name()); - insert_header(&mut headers, "X-Msh-Device-Model", &device_model()); - insert_header(&mut headers, "X-Msh-Os-Version", std::env::consts::OS); - insert_header(&mut headers, "X-Msh-Device-Id", &device_id()); - headers -} - -fn insert_header(headers: &mut HeaderMap, name: &'static str, value: &str) { - if let Ok(value) = HeaderValue::from_str(&ascii_header(value)) { - headers.insert( - HeaderName::from_static(name.to_ascii_lowercase().leak()), - value, - ); - } -} - -fn ascii_header(value: &str) -> String { - let cleaned: String = value - .chars() - .filter(|ch| matches!(*ch as u32, 0x20..=0x7e)) - .collect::() - .trim() - .to_owned(); - if cleaned.is_empty() { - "unknown".to_owned() - } else { - cleaned - } -} - -fn device_name() -> String { - std::env::var("HOSTNAME") - .or_else(|_| std::env::var("COMPUTERNAME")) - .unwrap_or_else(|_| "unknown".to_owned()) -} - -fn device_model() -> String { - format!("{} {}", std::env::consts::OS, std::env::consts::ARCH) -} - -fn device_id() -> String { - let path = device_id_path(); - if let Ok(value) = std::fs::read_to_string(&path) { - let value = value.trim(); - if !value.is_empty() { - return value.to_owned(); - } - } - let id = goat_auth::random_state(); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - set_private_dir(parent); - } - if std::fs::write(&path, &id).is_ok() { - set_private_file(&path); - } - id -} - -fn device_id_path() -> PathBuf { - std::env::home_dir().map_or_else( - || PathBuf::from(".goat-code-kimi-code-device-id"), - |home| home.join(".goat-code").join("kimi-code-device-id"), - ) -} - -#[cfg(unix)] -fn set_private_dir(path: &std::path::Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)); -} - -#[cfg(not(unix))] -fn set_private_dir(_path: &std::path::Path) {} - -#[cfg(unix)] -fn set_private_file(path: &std::path::Path) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); -} - -#[cfg(not(unix))] -fn set_private_file(_path: &std::path::Path) {} - -pub fn valid_kimi_verification_url(url: &str) -> bool { - reqwest::Url::parse(url) - .ok() - .and_then(|url| { - (url.scheme() == "https") - .then(|| url.host_str().is_some_and(|host| host == "auth.kimi.com")) - }) - .unwrap_or(false) -} - -#[cfg(test)] -mod tests { - use super::{TokenResponse, parse_token_response}; - - #[test] - fn parses_kimi_token_response_without_leaking_secrets() { - let token = parse_token_response(TokenResponse { - access_token: "access-secret".to_owned(), - refresh_token: "refresh-secret".to_owned(), - expires_in: 3600, - scope: Some("scope".to_owned()), - token_type: Some("Bearer".to_owned()), - }) - .unwrap(); - assert_eq!(token.access_token.expose(), "access-secret"); - assert_eq!(token.refresh_token.unwrap().expose(), "refresh-secret"); - let error = parse_token_response(TokenResponse { - access_token: String::new(), - refresh_token: "refresh-secret".to_owned(), - expires_in: 3600, - scope: None, - token_type: None, - }) - .unwrap_err() - .to_string(); - assert!(!error.contains("refresh-secret")); - assert!(!error.contains("access-secret")); - } -} diff --git a/crates/goat-provider-kimi/Cargo.toml b/crates/goat-provider-kimi/Cargo.toml deleted file mode 100644 index 4f24fb0..0000000 --- a/crates/goat-provider-kimi/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-kimi" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-kimi/src/lib.rs b/crates/goat-provider-kimi/src/lib.rs deleted file mode 100644 index 6d65ab5..0000000 --- a/crates/goat-provider-kimi/src/lib.rs +++ /dev/null @@ -1,61 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider::ProviderMetadata; -use goat_provider_openai_compat::{ - ChatDiscovery, ChatValidation, OpenAiCompatProvider, api_key, no_efforts, -}; - -pub const PROVIDER_ID: &str = "kimi"; - -const BASE_URL: &str = "https://api.moonshot.ai/v1"; -const HOST: &str = "api.moonshot.ai"; -const ENV_VAR: &str = "MOONSHOT_API_KEY"; - -const KIMI_SETUP: &[&str] = &[ - "Kimi Platform API key provider.", - "For Kimi Code OAuth, use `goat-code provider login kimi-code`.", - "API-key setup: `goat-code provider login kimi --key sk-...`.", -]; - -const CATALOG: &[&str] = &[ - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", - "kimi-k2.5", - "moonshot-v1-128k", - "moonshot-v1-32k", - "moonshot-v1-8k", - "moonshot-v1-auto", -]; - -const CONTEXT: &[(&str, u32)] = &[ - ("kimi-k2.7", 256_000), - ("kimi-k2.6", 256_000), - ("kimi-k2.5", 256_000), - ("moonshot-v1-128k", 128_000), - ("moonshot-v1-32k", 32_000), - ("moonshot-v1-8k", 8_000), -]; - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT) - .with_vision_filter(kimi_vision_model) - .with_efforts(no_efforts) - .with_reasoning_effort(false) - .with_validation(ChatValidation::CatalogOnly) - .with_discovery(ChatDiscovery::CatalogOnly) - .with_metadata(ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "catalog-only", - endpoint: None, - oauth: Some("Kimi Code OAuth is provider id kimi-code"), - login_endpoint: None, - setup: KIMI_SETUP, - }) -} - -fn kimi_vision_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - id.starts_with("kimi-k2.6") || id.contains("vision-preview") -} diff --git a/crates/goat-provider-local/Cargo.toml b/crates/goat-provider-local/Cargo.toml deleted file mode 100644 index 43d8384..0000000 --- a/crates/goat-provider-local/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "goat-provider-local" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -publish = false -license.workspace = true -repository.workspace = true - -[dependencies] -goat-provider-openai-compat = { workspace = true } - -[dev-dependencies] -goat-provider = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-local/src/lib.rs b/crates/goat-provider-local/src/lib.rs deleted file mode 100644 index dda622b..0000000 --- a/crates/goat-provider-local/src/lib.rs +++ /dev/null @@ -1,35 +0,0 @@ -use goat_provider_openai_compat::OpenAiCompatProvider; - -pub fn ollama() -> OpenAiCompatProvider { - OpenAiCompatProvider::local("ollama", "http://localhost:11434/v1") -} - -pub fn lmstudio() -> OpenAiCompatProvider { - OpenAiCompatProvider::local("lmstudio", "http://localhost:1234/v1") -} - -pub fn llama_cpp() -> OpenAiCompatProvider { - OpenAiCompatProvider::local("llama-cpp", "http://localhost:8080/v1") -} - -#[cfg(test)] -mod tests { - use goat_provider::{Provider, ProviderId}; - - use super::{llama_cpp, lmstudio, ollama}; - - #[test] - fn ollama_has_correct_id() { - assert_eq!(ollama().id(), ProviderId::from("ollama")); - } - - #[test] - fn lmstudio_has_correct_id() { - assert_eq!(lmstudio().id(), ProviderId::from("lmstudio")); - } - - #[test] - fn llama_cpp_has_correct_id() { - assert_eq!(llama_cpp().id(), ProviderId::from("llama-cpp")); - } -} diff --git a/crates/goat-provider-mistral/Cargo.toml b/crates/goat-provider-mistral/Cargo.toml deleted file mode 100644 index ad0d343..0000000 --- a/crates/goat-provider-mistral/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-mistral" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-mistral/src/lib.rs b/crates/goat-provider-mistral/src/lib.rs deleted file mode 100644 index 155289b..0000000 --- a/crates/goat-provider-mistral/src/lib.rs +++ /dev/null @@ -1,41 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider_openai_compat::{OpenAiCompatProvider, api_key, no_efforts}; - -pub const PROVIDER_ID: &str = "mistral"; -const BASE_URL: &str = "https://api.mistral.ai/v1"; -const HOST: &str = "api.mistral.ai"; -const ENV_VAR: &str = "MISTRAL_API_KEY"; - -const CATALOG: &[&str] = &[ - "mistral-large-latest", - "magistral-medium-2506", - "magistral-small-2506", - "devstral-2512", - "devstral-small-2505", - "codestral-latest", - "mistral-small-latest", - "pixtral-large-latest", -]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[ - ("mistral-large", 131_072), - ("magistral", 131_072), - ("mistral-small", 131_072), - ("devstral-2512", 262_144), - ("devstral-small", 131_072), - ("codestral", 256_000), - ("pixtral", 131_072), -]; - -fn is_vision_model(id: &str) -> bool { - id.to_ascii_lowercase().contains("pixtral") -} - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_vision_filter(is_vision_model) - .with_efforts(no_efforts) - .with_reasoning_effort(false) -} diff --git a/crates/goat-provider-openai-codex/Cargo.toml b/crates/goat-provider-openai-codex/Cargo.toml deleted file mode 100644 index 6ed3d28..0000000 --- a/crates/goat-provider-openai-codex/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "goat-provider-openai-codex" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } -thiserror = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -base64 = { workspace = true } -open = { workspace = true } -tracing = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-openai-codex/src/lib.rs b/crates/goat-provider-openai-codex/src/lib.rs deleted file mode 100644 index 048d815..0000000 --- a/crates/goat-provider-openai-codex/src/lib.rs +++ /dev/null @@ -1,602 +0,0 @@ -use std::time::Duration; - -use base64::Engine; -use goat_auth::{ - BASE64URL, Credential, CredentialKey, CredentialStore, Pkce, TokenSet, capture_loopback_code, - ensure_valid, random_state, -}; -use goat_provider::{ - AuthMethod, Capabilities, Model, Provider, ProviderId, ProviderMetadata, Request, StreamError, - StreamEvent, WebSearchOutput, now_secs, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub const PROVIDER_ID: &str = "openai-codex"; -const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; -const AUTHORIZE: &str = "https://auth.openai.com/oauth/authorize"; -const TOKEN: &str = "https://auth.openai.com/oauth/token"; -const REDIRECT_URI: &str = "http://localhost:1455/auth/callback"; -const CALLBACK_PORT: u16 = 1455; -const SCOPES: &str = - "openid profile email offline_access api.connectors.read api.connectors.invoke"; -const ORIGINATOR: &str = "codex_cli_rs"; -const BASE: &str = "https://chatgpt.com/backend-api/codex"; -const DEFAULT_INSTRUCTIONS: &str = "You are goat, a coding assistant running in a terminal."; -const DEVICE_USERCODE: &str = "https://auth.openai.com/deviceauth/usercode"; - -const CATALOG: &[&str] = &["gpt-5.5", "gpt-5.4", "gpt-5.4-mini"]; -const SEARCH_MODEL: &str = "gpt-5.4-mini"; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[("gpt-5", 272_000)]; -const DEVICE_TOKEN: &str = "https://auth.openai.com/deviceauth/token"; -const DEVICE_VERIFY_URL: &str = "https://auth.openai.com/codex/device"; - -#[derive(Debug, thiserror::Error)] -pub enum CodexError { - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("auth error: {0}")] - Auth(#[from] goat_auth::AuthError), - #[error("url error: {0}")] - Url(String), - #[error("token error: {0}")] - Token(String), - #[error("no browser available")] - NoBrowser, -} - -fn authorize_url(challenge: &str, state: &str) -> Result { - reqwest::Url::parse_with_params( - AUTHORIZE, - &[ - ("response_type", "code"), - ("client_id", CLIENT_ID), - ("redirect_uri", REDIRECT_URI), - ("scope", SCOPES), - ("code_challenge", challenge), - ("code_challenge_method", "S256"), - ("id_token_add_organizations", "true"), - ("codex_cli_simplified_flow", "true"), - ("originator", ORIGINATOR), - ("state", state), - ], - ) - .map(|url| url.to_string()) - .map_err(|err| CodexError::Url(err.to_string())) -} - -fn account_id(access_token: &str) -> Option { - let payload = access_token.split('.').nth(1)?; - let bytes = BASE64URL.decode(payload.trim_end_matches('=')).ok()?; - let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?; - claims - .get("https://api.openai.com/auth")? - .get("chatgpt_account_id")? - .as_str() - .map(str::to_owned) -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, -} - -fn auth_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client") -} - -async fn exchange_code(code: &str, verifier: &str) -> Result { - let response = auth_client() - .post(TOKEN) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", REDIRECT_URI), - ("client_id", CLIENT_ID), - ("code_verifier", verifier), - ]) - .send() - .await?; - if let Err(err) = response.error_for_status_ref() { - return Err(CodexError::Token(err.to_string())); - } - let tokens: TokenResponse = response.json().await?; - Ok(TokenSet::from_parts( - tokens.access_token, - tokens.refresh_token, - tokens.expires_in, - None, - )) -} - -fn browser_available() -> bool { - if cfg!(any(target_os = "macos", target_os = "windows")) { - return true; - } - std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some() -} - -pub async fn login(status: &mpsc::Sender) -> Result { - if browser_available() { - match login_browser().await { - Err(CodexError::NoBrowser) => login_device(status).await, - other => other, - } - } else { - login_device(status).await - } -} - -async fn login_browser() -> Result { - let pkce = Pkce::generate(); - let state = random_state(); - let url = authorize_url(&pkce.challenge, &state)?; - if open::that(&url).is_err() { - return Err(CodexError::NoBrowser); - } - let code = capture_loopback_code(CALLBACK_PORT, &state).await?; - exchange_code(&code, &pkce.verifier).await -} - -#[derive(Deserialize)] -struct DeviceCodeResponse { - device_auth_id: String, - user_code: String, - interval: Option, -} - -#[derive(Deserialize)] -struct DevicePollResponse { - authorization_code: Option, - code_verifier: Option, -} - -#[derive(Deserialize)] -struct DevicePollError { - error: Option, -} - -async fn login_device(status: &mpsc::Sender) -> Result { - let client = auth_client(); - let response = client - .post(DEVICE_USERCODE) - .json(&serde_json::json!({ "client_id": CLIENT_ID })) - .send() - .await?; - if let Err(err) = response.error_for_status_ref() { - return Err(CodexError::Token(err.to_string())); - } - let device: DeviceCodeResponse = response.json().await?; - let _ = open::that(DEVICE_VERIFY_URL); - let _ = status - .send(format!( - "open {DEVICE_VERIFY_URL} and enter code: {}", - device.user_code - )) - .await; - - let interval = device.interval.unwrap_or(5).max(1); - let deadline = now_secs() + 900; - loop { - if now_secs() > deadline { - return Err(CodexError::Token("device login timed out".to_owned())); - } - tokio::time::sleep(Duration::from_secs(interval)).await; - let poll = client - .post(DEVICE_TOKEN) - .json(&serde_json::json!({ - "device_auth_id": device.device_auth_id, - "user_code": device.user_code, - })) - .send() - .await?; - if !poll.status().is_success() { - let bytes = poll.bytes().await.unwrap_or_default(); - if let Ok(err_body) = serde_json::from_slice::(&bytes) { - match err_body.error.as_deref() { - Some("access_denied") => { - return Err(CodexError::Token("device login access denied".to_owned())); - } - Some("expired_token") => { - return Err(CodexError::Token("device login code expired".to_owned())); - } - _ => {} - } - } - continue; - } - let Ok(body) = poll.json::().await else { - continue; - }; - if let (Some(code), Some(verifier)) = (body.authorization_code, body.code_verifier) { - return exchange_code(&code, &verifier).await; - } - } -} - -async fn do_refresh(refresh_token: String) -> Result { - let response = auth_client() - .post(TOKEN) - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token.as_str()), - ("client_id", CLIENT_ID), - ]) - .send() - .await - .map_err(|e| e.to_string())?; - if let Err(err) = response.error_for_status_ref() { - return Err(err.to_string()); - } - let tokens: TokenResponse = response.json().await.map_err(|e| e.to_string())?; - Ok(TokenSet::from_parts( - tokens.access_token, - tokens.refresh_token, - tokens.expires_in, - Some(refresh_token.as_str()), - )) -} - -async fn current_access( - store: &CredentialStore, - key: &CredentialKey, -) -> Option<(String, Option)> { - let tokens = match store.resolve(key, None)? { - Credential::OAuth(tokens) => tokens, - Credential::ApiKey(secret) | Credential::ApiKeyWithEndpoint { secret, .. } => { - let access = secret.expose().to_owned(); - let account = account_id(&access); - return Some((access, account)); - } - }; - let tokens = ensure_valid(tokens, store, key, do_refresh).await?; - let access = tokens.access_token.expose().to_owned(); - let account = account_id(&access); - Some((access, account)) -} - -pub fn build(store: &CredentialStore, account: &str) -> CodexProvider { - let key = CredentialKey::model(PROVIDER_ID, account); - CodexProvider::new(store.clone(), key) -} - -pub struct CodexProvider { - store: CredentialStore, - key: CredentialKey, - client: reqwest::Client, -} - -impl CodexProvider { - pub fn new(store: CredentialStore, key: CredentialKey) -> Self { - Self { - store, - key, - client: reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client"), - } - } -} - -async fn fetch_models(client: &reqwest::Client, access: &str, account: Option<&str>) -> Vec { - let mut builder = client - .get(format!("{BASE}/models?client_version=0.0.0")) - .bearer_auth(access) - .header("Accept", "application/json"); - if let Some(account) = account { - builder = builder.header("chatgpt-account-id", account); - } - let Ok(response) = builder.send().await else { - return Vec::new(); - }; - if !response.status().is_success() { - return Vec::new(); - } - let Ok(value) = response.json::().await else { - return Vec::new(); - }; - let Some(items) = value.get("models").and_then(serde_json::Value::as_array) else { - return Vec::new(); - }; - items - .iter() - .filter(|model| model.get("visibility").and_then(serde_json::Value::as_str) == Some("list")) - .filter_map(|model| { - let id = model.get("slug").and_then(serde_json::Value::as_str)?; - Some(Model { - id: id.to_owned(), - supports_images: goat_provider_openai_compat::known_openai_vision_model(id), - }) - }) - .collect() -} - -#[derive(Serialize)] -struct CodexSearchRequest<'a> { - id: &'a str, - model: &'a str, - input: Vec, - commands: CodexSearchCommands<'a>, - settings: CodexSearchSettings, - max_output_tokens: u64, -} - -#[derive(Serialize)] -struct CodexSearchCommands<'a> { - search_query: Vec>, - response_length: CodexSearchResponseLength, -} - -#[derive(Serialize)] -struct CodexSearchQuery<'a> { - q: &'a str, -} - -#[derive(Serialize)] -#[serde(rename_all = "lowercase")] -enum CodexSearchResponseLength { - Short, -} - -#[derive(Serialize)] -struct CodexSearchSettings { - allowed_callers: Vec, - external_web_access: bool, -} - -#[derive(Serialize)] -#[serde(rename_all = "snake_case")] -enum CodexSearchAllowedCaller { - Direct, -} - -#[derive(Deserialize)] -struct CodexSearchResponse { - output: String, -} - -fn codex_search_body(id: &str, model: &str, query: &str) -> serde_json::Value { - serde_json::to_value(CodexSearchRequest { - id, - model, - input: vec![json!({ - "type": "message", - "role": "user", - "content": [{ "type": "input_text", "text": query }], - })], - commands: CodexSearchCommands { - search_query: vec![CodexSearchQuery { q: query }], - response_length: CodexSearchResponseLength::Short, - }, - settings: CodexSearchSettings { - allowed_callers: vec![CodexSearchAllowedCaller::Direct], - external_web_access: true, - }, - max_output_tokens: 2500, - }) - .expect("CodexSearchRequest is always serializable") -} - -async fn run_codex_search( - client: &reqwest::Client, - access: &str, - account: Option<&str>, - query: &str, -) -> Result { - let url = format!("{BASE}/alpha/search"); - let body = codex_search_body("goat-web-search", SEARCH_MODEL, query); - let mut builder = client.post(&url).bearer_auth(access).json(&body); - if let Some(account) = account { - builder = builder.header("chatgpt-account-id", account); - } - let resp = builder - .send() - .await - .map_err(|err| goat_provider_openai_compat::common::transport(&err))?; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - return Err(goat_provider_openai_compat::common::classify_http( - status, &headers, &detail, - )); - } - let response: CodexSearchResponse = resp - .json() - .await - .map_err(|err| StreamError::other(format!("invalid search response: {err}")))?; - let content = if response.output.trim().is_empty() { - "No results found.".to_owned() - } else { - response.output - }; - Ok(WebSearchOutput { - content, - results: Vec::new(), - }) -} - -impl Provider for CodexProvider { - fn id(&self) -> ProviderId { - ProviderId::from(PROVIDER_ID) - } - - fn authenticated(&self) -> bool { - self.store.resolve(&self.key, None).is_some() - } - - fn validate(&self) -> JoinHandle> { - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - if current_access(&store, &key).await.is_some() { - Ok(()) - } else { - Err("not logged in".to_owned()) - } - }) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: AuthMethod::OAuth, - images: true, - } - } - - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata { - env_var: None, - validation: "oauth", - endpoint: None, - oauth: Some("browser or device"), - login_endpoint: None, - setup: &[], - } - } - - fn supports_images(&self, model: &str) -> bool { - goat_provider_openai_compat::known_openai_vision_model(model) - } - - fn supports_web_search(&self) -> bool { - true - } - - fn web_search(&self, query: String) -> JoinHandle> { - let client = self.client.clone(); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some((access, account)) = current_access(&store, &key).await else { - return Err(StreamError::auth("not logged in to codex")); - }; - run_codex_search(&client, &access, account.as_deref(), &query).await - }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - tokio::spawn(async move { login(&status).await.map_err(|e| e.to_string()) }) - } - - fn context_window(&self, model: &str) -> Option { - CONTEXT_WINDOWS - .iter() - .find(|(prefix, _)| model.starts_with(prefix)) - .map(|(_, w)| *w) - } - - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let url = format!("{BASE}/responses"); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some((access, account)) = current_access(&store, &key).await else { - let _ = events - .send(StreamEvent::Failed { - error: goat_provider::StreamError::auth("not logged in to codex"), - }) - .await; - return; - }; - let body = goat_provider_openai_compat::build_body( - &req.model, - &req.messages, - &req.tools, - Some(DEFAULT_INSTRUCTIONS), - false, - req.effort, - req.tool_choice, - ); - goat_provider_openai_compat::run_request( - &client, - &url, - Some(&access), - account.as_deref(), - &body, - &events, - Some(goat_provider_openai_compat::parse_codex_ratelimits), - ) - .await; - }) - } - - fn catalog(&self) -> &'static [&'static str] { - CATALOG - } - - fn efforts(&self, model: &str) -> Vec { - goat_provider_openai_compat::responses_efforts(model) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let Some((access, account)) = current_access(&store, &key).await else { - return; - }; - for model in fetch_models(&client, &access, account.as_deref()).await { - if out.send(model).await.is_err() { - return; - } - } - }) - } -} - -#[cfg(test)] -mod tests { - use super::{account_id, authorize_url, codex_search_body}; - - #[test] - fn authorize_url_carries_pkce_and_client() { - let url = authorize_url("CHAL", "STATE").unwrap(); - assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann")); - assert!(url.contains("code_challenge=CHAL")); - assert!(url.contains("code_challenge_method=S256")); - assert!(url.contains("state=STATE")); - assert!(url.contains("redirect_uri=http")); - assert!(url.contains("codex_cli_simplified_flow=true")); - assert!(url.contains("id_token_add_organizations=true")); - } - - #[test] - fn codex_search_body_matches_upstream_shape() { - let body = codex_search_body("search-session", "gpt-test", "find this"); - assert_eq!(body["id"], "search-session"); - assert_eq!(body["model"], "gpt-test"); - assert!(body["input"].is_array()); - assert_eq!(body["input"][0]["type"], "message"); - assert_eq!(body["input"][0]["role"], "user"); - assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][0]["content"][0]["text"], "find this"); - assert_eq!(body["commands"]["search_query"][0]["q"], "find this"); - assert_eq!(body["commands"]["response_length"], "short"); - assert_eq!(body["settings"]["allowed_callers"][0], "direct"); - assert_eq!(body["settings"]["external_web_access"], true); - assert_eq!(body["max_output_tokens"], 2500); - } - - #[test] - fn decodes_account_id_from_jwt() { - use base64::Engine; - let payload = r#"{"https://api.openai.com/auth":{"chatgpt_account_id":"acct-99"}}"#; - let encoded = goat_auth::BASE64URL.encode(payload.as_bytes()); - let jwt = format!("header.{encoded}.sig"); - assert_eq!(account_id(&jwt).as_deref(), Some("acct-99")); - } -} diff --git a/crates/goat-provider-openai-compat/Cargo.toml b/crates/goat-provider-openai-compat/Cargo.toml deleted file mode 100644 index 6a50dfb..0000000 --- a/crates/goat-provider-openai-compat/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "goat-provider-openai-compat" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-auth = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -futures = { workspace = true } -eventsource-stream = { workspace = true } -tokio = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-openai-compat/src/chat.rs b/crates/goat-provider-openai-compat/src/chat.rs deleted file mode 100644 index 54673c9..0000000 --- a/crates/goat-provider-openai-compat/src/chat.rs +++ /dev/null @@ -1,1024 +0,0 @@ -use std::collections::HashMap; - -use eventsource_stream::Eventsource; -use futures::StreamExt; -use goat_provider::{ - AuthMethod, Capabilities, ContentBlock, Effort, Message, MessageRole, Model, ModelListSource, - Provider, ProviderId, ProviderMetadata, Request, StreamError, StreamEvent, Usage, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::{sync::mpsc, task::JoinHandle}; - -use crate::common; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChatValidation { - ModelsEndpoint, - CatalogOnly, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChatDiscovery { - ModelsEndpoint, - CatalogOnly, -} - -#[derive(Clone)] -struct ChatOptions { - tools: bool, - images: bool, - stream_options: bool, - reasoning_effort: bool, - model_filter: Option bool>, - vision_filter: fn(&str) -> bool, - effort_options: fn(&str) -> Vec, - effort_wire: fn(Effort) -> Option<&'static str>, - catalog: &'static [&'static str], - context_windows: &'static [(&'static str, u32)], - validation: ChatValidation, - discovery: ChatDiscovery, - model_list_source: Option, - metadata: ProviderMetadata, - extra_headers: &'static [(&'static str, &'static str)], -} - -impl Default for ChatOptions { - fn default() -> Self { - Self { - tools: true, - images: true, - stream_options: true, - reasoning_effort: true, - model_filter: None, - vision_filter: crate::vision::known_openai_compatible_vision_model, - effort_options: default_efforts, - effort_wire: chat_effort_wire, - catalog: &[], - context_windows: &[], - validation: ChatValidation::ModelsEndpoint, - discovery: ChatDiscovery::ModelsEndpoint, - model_list_source: None, - metadata: ProviderMetadata::default(), - extra_headers: &[], - } - } -} - -pub struct OpenAiCompatProvider { - id: ProviderId, - base_url: String, - bearer: Option, - auth: AuthMethod, - client: reqwest::Client, - options: ChatOptions, -} - -impl OpenAiCompatProvider { - pub fn new( - id: ProviderId, - base_url: impl Into, - bearer: Option, - auth: AuthMethod, - ) -> Self { - Self { - id, - base_url: normalize_base_url(&base_url.into()), - bearer, - auth, - client: common::http_client(), - options: ChatOptions::default(), - } - } - - pub fn local(provider_id: &'static str, base_url: &'static str) -> Self { - Self::new( - ProviderId::from(provider_id), - base_url, - None, - AuthMethod::None, - ) - .with_metadata(ProviderMetadata { - env_var: None, - validation: "local", - endpoint: Some(base_url), - oauth: None, - login_endpoint: None, - setup: &[], - }) - } - - pub fn base_url(&self) -> &str { - &self.base_url - } - - #[must_use] - pub fn with_tools(mut self, enabled: bool) -> Self { - self.options.tools = enabled; - self - } - - #[must_use] - pub fn with_images(mut self, enabled: bool) -> Self { - self.options.images = enabled; - self - } - - #[must_use] - pub fn with_stream_options(mut self, enabled: bool) -> Self { - self.options.stream_options = enabled; - self - } - - #[must_use] - pub fn with_reasoning_effort(mut self, enabled: bool) -> Self { - self.options.reasoning_effort = enabled; - self - } - - #[must_use] - pub fn with_model_filter(mut self, filter: fn(&str) -> bool) -> Self { - self.options.model_filter = Some(filter); - self - } - - #[must_use] - pub fn with_vision_filter(mut self, filter: fn(&str) -> bool) -> Self { - self.options.vision_filter = filter; - self - } - - #[must_use] - pub fn with_efforts(mut self, efforts: fn(&str) -> Vec) -> Self { - self.options.effort_options = efforts; - self - } - - #[must_use] - pub fn with_effort_wire(mut self, effort_wire: fn(Effort) -> Option<&'static str>) -> Self { - self.options.effort_wire = effort_wire; - self - } - - #[must_use] - pub fn with_catalog(mut self, catalog: &'static [&'static str]) -> Self { - self.options.catalog = catalog; - self - } - - #[must_use] - pub fn with_context_windows(mut self, windows: &'static [(&'static str, u32)]) -> Self { - self.options.context_windows = windows; - self - } - - #[must_use] - pub fn with_validation(mut self, validation: ChatValidation) -> Self { - self.options.validation = validation; - self - } - - #[must_use] - pub fn with_discovery(mut self, discovery: ChatDiscovery) -> Self { - self.options.discovery = discovery; - self - } - - #[must_use] - pub fn with_model_list_source(mut self, source: ModelListSource) -> Self { - self.options.model_list_source = Some(source); - self - } - - #[must_use] - pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self { - self.options.metadata = metadata; - self - } - - #[must_use] - pub fn with_extra_headers(mut self, headers: &'static [(&'static str, &'static str)]) -> Self { - self.options.extra_headers = headers; - self - } -} - -fn normalize_base_url(base_url: &str) -> String { - base_url.trim_end_matches('/').to_owned() -} - -#[derive(Serialize)] -struct ChatRequest<'a> { - model: &'a str, - messages: Vec, - stream: bool, - #[serde(skip_serializing_if = "Option::is_none")] - stream_options: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - tools: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning_effort: Option<&'a str>, -} - -#[derive(Serialize)] -struct StreamOptions { - include_usage: bool, -} - -fn default_efforts(_model: &str) -> Vec { - vec![Effort::Low, Effort::Medium, Effort::High] -} - -fn chat_effort_wire(effort: Effort) -> Option<&'static str> { - match effort { - Effort::Off => None, - Effort::Low => Some("low"), - Effort::Medium => Some("medium"), - Effort::High | Effort::Xhigh | Effort::Max => Some("high"), - } -} - -fn role_label(role: MessageRole) -> &'static str { - match role { - MessageRole::System => "system", - MessageRole::User => "user", - MessageRole::Assistant => "assistant", - } -} - -fn text_and_images_content(message: &Message) -> serde_json::Value { - let images: Vec<(&String, &String)> = message - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Image { media_type, data } => Some((media_type, data)), - _ => None, - }) - .collect(); - let text = message.text_content(); - if images.is_empty() { - return serde_json::Value::String(text); - } - let mut content = Vec::new(); - if !text.is_empty() { - content.push(json!({ "type": "text", "text": text })); - } - for (media_type, data) in images { - content.push(json!({ - "type": "image_url", - "image_url": { "url": format!("data:{media_type};base64,{data}") }, - })); - } - serde_json::Value::Array(content) -} - -fn to_chat_messages(messages: &[Message]) -> Vec { - let mut out = Vec::new(); - for message in messages { - let has_tool_use = message - .content - .iter() - .any(|block| matches!(block, ContentBlock::ToolUse { .. })); - let has_tool_result = message - .content - .iter() - .any(|block| matches!(block, ContentBlock::ToolResult { .. })); - if has_tool_use { - let tool_calls: Vec = message - .content - .iter() - .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ - "id": id, - "type": "function", - "function": { "name": name, "arguments": common::tool_arguments(input) }, - })), - _ => None, - }) - .collect(); - let text = message.text_content(); - out.push(json!({ - "role": "assistant", - "content": if text.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text) }, - "tool_calls": tool_calls, - })); - } else if has_tool_result { - for block in &message.content { - if let ContentBlock::ToolResult { - tool_use_id, - content, - .. - } = block - { - let output_text = ContentBlock::tool_result_text(content); - out.push(json!({ - "role": "tool", - "tool_call_id": tool_use_id, - "content": output_text, - })); - } - } - } else { - out.push(json!({ - "role": role_label(message.role), - "content": text_and_images_content(message), - })); - } - } - out -} - -fn to_chat_tools(req: &Request) -> Vec { - req.tools - .iter() - .map(|tool| { - json!({ - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.input_schema, - }, - }) - }) - .collect() -} - -fn request_has_images(req: &Request) -> bool { - req.messages.iter().any(|message| { - message.content.iter().any(|block| match block { - ContentBlock::Image { .. } => true, - ContentBlock::ToolResult { content, .. } => content - .iter() - .any(|item| matches!(item, ContentBlock::Image { .. })), - _ => false, - }) - }) -} - -fn build_chat_body(req: &Request, options: &ChatOptions) -> Result { - if request_has_images(req) && (!options.images || !(options.vision_filter)(&req.model)) { - return Err(StreamError::invalid_request( - "this provider or model does not support image input; switch to a vision-capable model", - )); - } - let tools = if options.tools { - to_chat_tools(req) - } else { - Vec::new() - }; - let tool_choice = (options.tools - && !req.tools.is_empty() - && matches!(req.tool_choice, goat_provider::ToolChoice::None)) - .then_some("none"); - let reasoning_effort = if options.reasoning_effort { - req.effort.and_then(options.effort_wire) - } else { - None - }; - let body = ChatRequest { - model: &req.model, - messages: to_chat_messages(&req.messages), - stream: true, - stream_options: options.stream_options.then_some(StreamOptions { - include_usage: true, - }), - tool_choice, - tools, - reasoning_effort, - }; - serde_json::to_value(body).map_err(|err| StreamError::other(err.to_string())) -} - -#[derive(Deserialize)] -struct ChatChunk { - #[serde(default)] - choices: Vec, - usage: Option, -} - -#[derive(Deserialize)] -struct ChatUsage { - prompt_tokens: Option, - completion_tokens: Option, -} - -#[derive(Deserialize)] -struct ChatChoice { - #[serde(default)] - delta: ChatDelta, - finish_reason: Option, -} - -#[derive(Default, Deserialize)] -struct ChatDelta { - content: Option, - #[serde(default)] - reasoning_content: Option, - #[serde(default)] - reasoning: Option, - #[serde(default)] - tool_calls: Vec, -} - -#[derive(Deserialize)] -struct ToolCallChunk { - index: u32, - id: Option, - function: Option, -} - -#[derive(Deserialize)] -struct ToolCallFunction { - name: Option, - arguments: Option, -} - -type ToolAccumulator = HashMap; - -fn accumulate_tool_calls(tool_calls: &mut ToolAccumulator, deltas: Vec) { - for call in deltas { - let entry = tool_calls.entry(call.index).or_default(); - if let Some(id) = call.id { - entry.0 = id; - } - if let Some(function) = call.function { - if let Some(name) = function.name { - entry.1 = name; - } - if let Some(arguments) = function.arguments { - entry.2.push_str(&arguments); - } - } - } -} - -fn drain_tool_calls(tool_calls: &mut ToolAccumulator) -> Vec { - let mut entries: Vec<(u32, (String, String, String))> = tool_calls.drain().collect(); - entries.sort_by_key(|(index, _)| *index); - entries - .into_iter() - .map(|(_, (id, name, input))| StreamEvent::ToolCall { id, name, input }) - .collect() -} - -fn data_has_error(data: &str) -> bool { - serde_json::from_str::(data) - .ok() - .and_then(|value| { - value - .get("error") - .filter(|error| !error.is_null()) - .map(|_| ()) - }) - .is_some() -} - -async fn stream_chat(response: reqwest::Response, events: &mpsc::Sender) { - let mut stream = response.bytes_stream().eventsource(); - let mut tool_calls: ToolAccumulator = HashMap::new(); - let mut last_usage: Option = None; - while let Some(event) = stream.next().await { - match event { - Ok(event) => { - if event.data == "[DONE]" { - break; - } - if event.event == "error" || data_has_error(&event.data) { - let _ = events - .send(StreamEvent::Failed { - error: common::classify_stream_error(&event.data), - }) - .await; - return; - } - let Ok(chunk) = serde_json::from_str::(&event.data) else { - continue; - }; - if chunk.usage.is_some() { - last_usage = chunk.usage; - } - let Some(choice) = chunk.choices.into_iter().next() else { - continue; - }; - let reasoning = choice.delta.reasoning_content.or(choice.delta.reasoning); - if let Some(text) = reasoning - && !text.is_empty() - && events - .send(StreamEvent::ThinkingDelta { text }) - .await - .is_err() - { - return; - } - if let Some(text) = choice.delta.content - && events.send(StreamEvent::TextDelta { text }).await.is_err() - { - return; - } - accumulate_tool_calls(&mut tool_calls, choice.delta.tool_calls); - if choice.finish_reason.is_some() { - for call in drain_tool_calls(&mut tool_calls) { - if events.send(call).await.is_err() { - return; - } - } - } - } - Err(err) => { - let _ = events - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport(err.to_string()), - }) - .await; - return; - } - } - } - for call in drain_tool_calls(&mut tool_calls) { - if events.send(call).await.is_err() { - return; - } - } - if let Some(u) = last_usage { - let usage = Usage { - input_tokens: u.prompt_tokens.unwrap_or(0), - output_tokens: u.completion_tokens.unwrap_or(0), - cache_read_tokens: 0, - cache_write_tokens: 0, - }; - let _ = events.send(StreamEvent::Usage { usage }).await; - } - let _ = events.send(StreamEvent::Completed).await; -} - -impl Provider for OpenAiCompatProvider { - fn id(&self) -> ProviderId { - self.id.clone() - } - - fn authenticated(&self) -> bool { - common::authenticated(self.auth, &self.bearer) - } - - fn verifies_credentials(&self) -> bool { - matches!(self.options.validation, ChatValidation::ModelsEndpoint) - } - - fn validate(&self) -> JoinHandle> { - match self.options.validation { - ChatValidation::ModelsEndpoint => common::validate_bearer( - self.client.clone(), - format!("{}/models", self.base_url), - self.auth, - self.bearer.clone(), - ), - ChatValidation::CatalogOnly => tokio::spawn(async move { Ok(()) }), - } - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: self.options.tools, - auth: self.auth, - images: self.options.images, - } - } - - fn metadata(&self) -> ProviderMetadata { - self.options.metadata - } - - fn catalog(&self) -> &'static [&'static str] { - self.options.catalog - } - - fn model_list_source(&self) -> ModelListSource { - self.options.model_list_source.unwrap_or({ - if self.options.catalog.is_empty() { - ModelListSource::Discover - } else { - ModelListSource::Catalog - } - }) - } - - fn supports_images(&self, model: &str) -> bool { - self.options.images && (self.options.vision_filter)(model) - } - - fn efforts(&self, model: &str) -> Vec { - if self.options.reasoning_effort { - (self.options.effort_options)(model) - } else { - Vec::new() - } - } - - fn context_window(&self, model: &str) -> Option { - self.options - .context_windows - .iter() - .find_map(|(prefix, window)| model.starts_with(prefix).then_some(*window)) - } - - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let url = format!("{}/chat/completions", self.base_url); - let bearer = self.bearer.clone(); - let options = self.options.clone(); - tokio::spawn(async move { - let body = match build_chat_body(&req, &options) { - Ok(body) => body, - Err(error) => { - let _ = events.send(StreamEvent::Failed { error }).await; - return; - } - }; - let mut builder = client.post(&url).json(&body); - if let Some(token) = &bearer { - builder = builder.bearer_auth(token); - } - for (name, value) in options.extra_headers { - builder = builder.header(*name, *value); - } - let resp = match builder.send().await { - Ok(resp) => resp, - Err(err) => { - let _ = events - .send(StreamEvent::Failed { - error: common::transport(&err), - }) - .await; - return; - } - }; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - let _ = events - .send(StreamEvent::Failed { - error: common::classify_http(status, &headers, &detail), - }) - .await; - return; - } - stream_chat(resp, &events).await; - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - match self.options.discovery { - ChatDiscovery::ModelsEndpoint => common::discover_models( - self.client.clone(), - format!("{}/models", self.base_url), - self.bearer.clone(), - self.options.model_filter, - self.options.vision_filter, - out, - ), - ChatDiscovery::CatalogOnly => { - let catalog = self.options.catalog; - let vision_filter = self.options.vision_filter; - let images = self.options.images; - tokio::spawn(async move { - for id in catalog { - if out - .send(Model { - id: (*id).to_owned(), - supports_images: images && vision_filter(id), - }) - .await - .is_err() - { - return; - } - } - }) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::{ - ChatChunk, ChatDiscovery, ChatOptions, ChatValidation, OpenAiCompatProvider, - ToolAccumulator, accumulate_tool_calls, build_chat_body, data_has_error, drain_tool_calls, - to_chat_messages, - }; - - #[test] - fn null_error_field_is_not_a_stream_error() { - assert!(!data_has_error( - r#"{"choices":[{"delta":{"content":"hi"}}],"error":null}"# - )); - assert!(!data_has_error(r#"{"choices":[]}"#)); - assert!(data_has_error(r#"{"error":{"message":"boom"}}"#)); - } - - use goat_provider::{ - AuthMethod, ContentBlock, Effort, Message, MessageRole, Provider, Request, StreamEvent, - ToolChoice, ToolDefinition, - }; - use serde_json::json; - - fn chunk_tool_calls(data: &str) -> Vec { - let chunk: ChatChunk = serde_json::from_str(data).unwrap(); - chunk.choices.into_iter().next().unwrap().delta.tool_calls - } - - #[test] - fn reasoning_delta_fields_are_parsed() { - let deepseek: ChatChunk = - serde_json::from_str(r#"{"choices":[{"delta":{"reasoning_content":"hmm"}}]}"#).unwrap(); - assert_eq!( - deepseek.choices[0].delta.reasoning_content.as_deref(), - Some("hmm") - ); - let openrouter: ChatChunk = - serde_json::from_str(r#"{"choices":[{"delta":{"reasoning":"think"}}]}"#).unwrap(); - assert_eq!( - openrouter.choices[0].delta.reasoning.as_deref(), - Some("think") - ); - } - - fn request() -> Request { - Request { - model: "model".to_owned(), - messages: vec![Message::text(MessageRole::User, "hi")], - tools: vec![ToolDefinition { - name: "read_file".to_owned(), - description: "read".to_owned(), - input_schema: json!({ "type": "object" }), - }], - effort: Some(Effort::High), - tool_choice: ToolChoice::None, - } - } - - #[test] - fn normalizes_base_url() { - let provider = OpenAiCompatProvider::new( - "test".into(), - "https://api.example.com/v1/", - None, - AuthMethod::ApiKey, - ); - assert_eq!(provider.base_url(), "https://api.example.com/v1"); - } - - #[test] - fn validation_mode_controls_verification_hint() { - let provider = OpenAiCompatProvider::new( - "test".into(), - "https://api.example.com/v1", - Some("key".to_owned()), - AuthMethod::ApiKey, - ) - .with_validation(ChatValidation::CatalogOnly); - assert!(!provider.verifies_credentials()); - } - - #[test] - fn catalog_only_discovery_returns_catalog_models() { - const CATALOG: &[&str] = &["a", "b"]; - let provider = OpenAiCompatProvider::new( - "test".into(), - "https://api.example.com/v1", - None, - AuthMethod::ApiKey, - ) - .with_catalog(CATALOG) - .with_discovery(ChatDiscovery::CatalogOnly); - assert_eq!(provider.catalog(), CATALOG); - } - - #[test] - fn build_body_can_omit_provider_specific_fields() { - let options = ChatOptions { - tools: false, - stream_options: false, - reasoning_effort: false, - ..ChatOptions::default() - }; - let body = build_chat_body(&request(), &options).unwrap(); - assert!(body.get("tools").is_none()); - assert!(body.get("tool_choice").is_none()); - assert!(body.get("stream_options").is_none()); - assert!(body.get("reasoning_effort").is_none()); - } - - #[test] - fn image_content_without_vision_support_errors() { - let mut req = request(); - req.messages = vec![Message { - role: MessageRole::User, - content: vec![ContentBlock::Image { - media_type: "image/png".to_owned(), - data: "abc".to_owned(), - }], - }]; - let options = ChatOptions { - images: false, - ..ChatOptions::default() - }; - let error = build_chat_body(&req, &options).unwrap_err(); - assert!(matches!( - error, - goat_provider::StreamError::InvalidRequest { .. } - )); - } - - #[test] - fn effort_wire_is_serialized() { - let body = build_chat_body(&request(), &ChatOptions::default()).unwrap(); - assert_eq!(body["reasoning_effort"], "high"); - } - - #[test] - fn error_chunk_is_detected() { - assert!(data_has_error( - r#"{"error":{"message":"bad","type":"invalid_request_error"}}"# - )); - assert!(!data_has_error( - r#"{"choices":[{"delta":{"content":"hi"}}]}"# - )); - assert!(!data_has_error("not json")); - } - - #[test] - fn plain_text_message_uses_text_role() { - let messages = vec![Message::text(MessageRole::User, "hi")]; - let out = to_chat_messages(&messages); - assert_eq!(out[0]["role"], "user"); - assert_eq!(out[0]["content"], "hi"); - assert!(out[0].get("tool_calls").is_none()); - } - - #[test] - fn tool_use_becomes_assistant_tool_calls() { - let messages = vec![Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call_1".to_owned(), - name: "read_file".to_owned(), - input: json!({ "path": "a.txt" }), - }], - }]; - let out = to_chat_messages(&messages); - assert_eq!(out[0]["role"], "assistant"); - assert!(out[0]["content"].is_null()); - assert_eq!(out[0]["tool_calls"][0]["id"], "call_1"); - assert_eq!(out[0]["tool_calls"][0]["type"], "function"); - assert_eq!(out[0]["tool_calls"][0]["function"]["name"], "read_file"); - assert_eq!( - out[0]["tool_calls"][0]["function"]["arguments"], - r#"{"path":"a.txt"}"# - ); - } - - #[test] - fn tool_result_becomes_tool_role_message() { - let messages = vec![Message { - role: MessageRole::User, - content: vec![ContentBlock::text_result("call_1", "file body", false)], - }]; - let out = to_chat_messages(&messages); - assert_eq!(out[0]["role"], "tool"); - assert_eq!(out[0]["tool_call_id"], "call_1"); - assert_eq!(out[0]["content"], "file body"); - } - - #[test] - fn accumulates_streamed_tool_call() { - let mut tool_calls: ToolAccumulator = ToolAccumulator::new(); - let first = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}"#; - let second = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"a.txt\"}"}}]}}]}"#; - accumulate_tool_calls(&mut tool_calls, chunk_tool_calls(first)); - accumulate_tool_calls(&mut tool_calls, chunk_tool_calls(second)); - let events = drain_tool_calls(&mut tool_calls); - assert_eq!( - events, - vec![StreamEvent::ToolCall { - id: "call_1".to_owned(), - name: "read_file".to_owned(), - input: r#"{"path":"a.txt"}"#.to_owned(), - }] - ); - assert!(tool_calls.is_empty()); - } - - #[test] - fn drains_multiple_tool_calls_in_index_order() { - let mut tool_calls: ToolAccumulator = ToolAccumulator::new(); - let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"b","function":{"name":"two","arguments":"{}"}},{"index":0,"id":"a","function":{"name":"one","arguments":"{}"}}]}}]}"#; - accumulate_tool_calls(&mut tool_calls, chunk_tool_calls(data)); - let events = drain_tool_calls(&mut tool_calls); - assert_eq!( - events, - vec![ - StreamEvent::ToolCall { - id: "a".to_owned(), - name: "one".to_owned(), - input: "{}".to_owned(), - }, - StreamEvent::ToolCall { - id: "b".to_owned(), - name: "two".to_owned(), - input: "{}".to_owned(), - }, - ] - ); - } - - #[tokio::test] - async fn stream_sends_extra_headers() { - use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }; - - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - sync::mpsc, - }; - - const HEADERS: &[(&str, &str)] = &[ - ("User-Agent", "xai-grok-cli"), - ("x-grok-client-version", "0.2.82"), - ("x-grok-client-identifier", "xai-grok-cli"), - ]; - - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let saw_version = Arc::new(AtomicBool::new(false)); - let saw_identifier = Arc::new(AtomicBool::new(false)); - let saw_user_agent = Arc::new(AtomicBool::new(false)); - let saw_version_server = saw_version.clone(); - let saw_identifier_server = saw_identifier.clone(); - let saw_user_agent_server = saw_user_agent.clone(); - - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = vec![0u8; 16_384]; - let n = socket.read(&mut buf).await.unwrap(); - let request = String::from_utf8_lossy(&buf[..n]); - if request.contains("x-grok-client-version: 0.2.82") { - saw_version_server.store(true, Ordering::SeqCst); - } - if request.contains("x-grok-client-identifier: xai-grok-cli") { - saw_identifier_server.store(true, Ordering::SeqCst); - } - if request - .to_ascii_lowercase() - .contains("user-agent: xai-grok-cli") - { - saw_user_agent_server.store(true, Ordering::SeqCst); - } - let body = concat!( - "data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", - "data: [DONE]\n\n" - ); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\n\r\n{body}", - body.len() - ); - socket.write_all(response.as_bytes()).await.unwrap(); - }); - - let provider = OpenAiCompatProvider::new( - "test".into(), - format!("http://{addr}/v1"), - None, - AuthMethod::None, - ) - .with_extra_headers(HEADERS); - let (events, mut rx) = mpsc::channel(8); - let handle = provider.stream( - Request { - model: "grok-composer-2.5-fast".to_owned(), - messages: vec![Message::text(MessageRole::User, "hi")], - tools: Vec::new(), - effort: None, - tool_choice: ToolChoice::None, - }, - events, - ); - let _ = handle.await; - server.await.unwrap(); - assert!(saw_version.load(Ordering::SeqCst)); - assert!(saw_identifier.load(Ordering::SeqCst)); - assert!(saw_user_agent.load(Ordering::SeqCst)); - assert!(matches!( - rx.recv().await, - Some(StreamEvent::TextDelta { .. }) - )); - assert!(matches!(rx.recv().await, Some(StreamEvent::Completed))); - } -} diff --git a/crates/goat-provider-openai-compat/src/common.rs b/crates/goat-provider-openai-compat/src/common.rs deleted file mode 100644 index 1f7eb4a..0000000 --- a/crates/goat-provider-openai-compat/src/common.rs +++ /dev/null @@ -1,322 +0,0 @@ -use goat_provider::{AuthMethod, Model, StreamError}; -use serde::Deserialize; -use tokio::{sync::mpsc, task::JoinHandle}; - -#[derive(Deserialize)] -struct ErrorEnvelope { - error: Option, -} - -#[derive(Deserialize)] -struct ResponseFailed { - response: Option, -} - -#[derive(Default, Deserialize)] -struct ErrorBody { - #[serde(default)] - message: String, - #[serde(rename = "type", default)] - kind: String, - #[serde(default)] - code: Option, -} - -fn parse_error_body(data: &str) -> ErrorBody { - if let Ok(envelope) = serde_json::from_str::(data) - && let Some(body) = envelope.error - { - return body; - } - if let Ok(failed) = serde_json::from_str::(data) - && let Some(body) = failed.response.and_then(|envelope| envelope.error) - { - return body; - } - serde_json::from_str::(data).unwrap_or_default() -} - -fn is_overflow_code(code: &str) -> bool { - matches!( - code, - "context_length_exceeded" - | "context_window_exceeded" - | "string_above_max_length" - | "invalid_request_error_context_length" - ) -} - -fn overflow_message(message: &str) -> bool { - let m = message.to_ascii_lowercase(); - m.contains("context length") - || m.contains("context window") - || m.contains("context size") - || m.contains("maximum context") - || m.contains("reduce the length") - || m.contains("too many tokens") - || m.contains("exceeds the available context") -} - -fn classify_body( - body: ErrorBody, - status: Option, - retry_after: Option, - fallback: String, -) -> StreamError { - let message = if body.message.is_empty() { - fallback - } else { - body.message - }; - let code = body.code.as_deref().unwrap_or(""); - if is_overflow_code(code) || overflow_message(&message) { - return StreamError::context_overflow(message); - } - if code == "insufficient_quota" { - return StreamError::other(message); - } - if code == "invalid_api_key" || body.kind == "authentication_error" { - return StreamError::auth(message); - } - match (status, code) { - (Some(429), _) | (_, "rate_limit_exceeded") => { - StreamError::rate_limited(message, retry_after) - } - (Some(401 | 403), _) => StreamError::auth(message), - (Some(code), _) if (500..600).contains(&code) => StreamError::overloaded(message), - (Some(code), _) if (400..500).contains(&code) => StreamError::invalid_request(message), - (None, _) if body.kind == "server_error" => StreamError::overloaded(message), - _ => StreamError::other(message), - } -} - -pub fn classify_http( - status: reqwest::StatusCode, - headers: &reqwest::header::HeaderMap, - body: &str, -) -> StreamError { - let retry_after = headers - .get(reqwest::header::RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(|raw| raw.trim().parse::().ok()) - .map(std::time::Duration::from_secs); - classify_body( - parse_error_body(body), - Some(status.as_u16()), - retry_after, - format!("{status}: {body}"), - ) -} - -pub(crate) fn classify_stream_error(data: &str) -> StreamError { - classify_body(parse_error_body(data), None, None, data.to_owned()) -} - -pub fn transport(err: &reqwest::Error) -> StreamError { - StreamError::transport(err.to_string()) -} - -pub fn tool_arguments(input: &serde_json::Value) -> String { - if input.is_object() { - input.to_string() - } else { - "{}".to_owned() - } -} - -pub fn http_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_mins(5)) - .connect_timeout(std::time::Duration::from_secs(10)) - .build() - .expect("reqwest client") -} - -pub fn authenticated(auth: AuthMethod, bearer: &Option) -> bool { - match auth { - AuthMethod::None => true, - _ => bearer.is_some(), - } -} - -pub fn validate_bearer( - client: reqwest::Client, - url: String, - auth: AuthMethod, - bearer: Option, -) -> JoinHandle> { - tokio::spawn(async move { - if matches!(auth, AuthMethod::None) { - return Ok(()); - } - let Some(token) = bearer else { - return Err("no credentials".to_owned()); - }; - let resp = client - .get(&url) - .bearer_auth(token) - .send() - .await - .map_err(|_| "could not reach provider".to_owned())?; - let status = resp.status(); - if status.is_success() { - Ok(()) - } else if status == reqwest::StatusCode::UNAUTHORIZED - || status == reqwest::StatusCode::FORBIDDEN - { - Err("invalid credentials".to_owned()) - } else { - Err(format!("could not reach provider: {status}")) - } - }) -} - -pub fn discover_models( - client: reqwest::Client, - url: String, - bearer: Option, - filter: Option bool>, - vision_filter: fn(&str) -> bool, - tx: mpsc::Sender, -) -> JoinHandle<()> { - tokio::spawn(async move { - let mut builder = client.get(&url); - if let Some(token) = &bearer { - builder = builder.bearer_auth(token); - } - let Ok(resp) = builder.send().await else { - return; - }; - let Ok(models) = resp.json::().await else { - return; - }; - for model in models.data { - if let Some(keep) = filter - && !keep(&model.id) - { - continue; - } - let supports_images = vision_filter(&model.id); - if tx - .send(Model { - id: model.id, - supports_images, - }) - .await - .is_err() - { - return; - } - } - }) -} - -#[derive(Deserialize)] -pub(crate) struct ModelsResponse { - #[serde(default)] - pub data: Vec, -} - -#[derive(Deserialize)] -pub(crate) struct ModelDto { - pub id: String, -} - -#[cfg(test)] -mod tests { - use goat_provider::StreamError; - - fn http(status: u16, body: &str) -> StreamError { - super::classify_http( - reqwest::StatusCode::from_u16(status).unwrap(), - &reqwest::header::HeaderMap::new(), - body, - ) - } - - #[test] - fn context_length_exceeded_code() { - let error = http( - 400, - r#"{"error":{"message":"This model's maximum context length is 128000 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}"#, - ); - assert!(matches!(error, StreamError::ContextOverflow { .. })); - } - - #[test] - fn non_openai_overflow_wordings_are_context_overflow() { - for body in [ - r#"{"error":{"message":"the request exceeds the available context size","type":"invalid_request_error"}}"#, - r#"{"error":{"message":"This model's maximum context is 32768 tokens","code":"string_above_max_length"}}"#, - r#"{"error":{"message":"Please reduce the length of the messages"}}"#, - r#"{"error":{"message":"Input is too many tokens for this model"}}"#, - ] { - assert!( - matches!(http(400, body), StreamError::ContextOverflow { .. }), - "expected overflow for: {body}" - ); - } - } - - #[test] - fn rate_limit_with_retry_after() { - let mut headers = reqwest::header::HeaderMap::new(); - headers.insert(reqwest::header::RETRY_AFTER, "12".parse().unwrap()); - let error = super::classify_http( - reqwest::StatusCode::TOO_MANY_REQUESTS, - &headers, - r#"{"error":{"message":"Rate limit reached","type":"requests","code":"rate_limit_exceeded"}}"#, - ); - assert_eq!( - error, - StreamError::rate_limited( - "Rate limit reached", - Some(std::time::Duration::from_secs(12)), - ) - ); - } - - #[test] - fn insufficient_quota_is_other() { - let error = http( - 429, - r#"{"error":{"message":"You exceeded your current quota","type":"insufficient_quota","code":"insufficient_quota"}}"#, - ); - assert!(matches!(error, StreamError::Other { .. })); - } - - #[test] - fn invalid_api_key_is_auth() { - let error = http( - 401, - r#"{"error":{"message":"Incorrect API key provided","type":"invalid_request_error","code":"invalid_api_key"}}"#, - ); - assert!(matches!(error, StreamError::Auth { .. })); - } - - #[test] - fn server_errors_are_overloaded() { - let error = http( - 503, - r#"{"error":{"message":"The server is overloaded","type":"server_error"}}"#, - ); - assert!(matches!(error, StreamError::Overloaded { .. })); - } - - #[test] - fn response_failed_envelope() { - let error = super::classify_stream_error( - r#"{"response":{"error":{"code":"rate_limit_exceeded","message":"slow down"}}}"#, - ); - assert_eq!(error, StreamError::rate_limited("slow down", None)); - } - - #[test] - fn unparseable_keeps_context() { - let error = http(502, "bad gateway"); - assert_eq!( - error, - StreamError::overloaded("502 Bad Gateway: bad gateway") - ); - } -} diff --git a/crates/goat-provider-openai-compat/src/headers.rs b/crates/goat-provider-openai-compat/src/headers.rs deleted file mode 100644 index 8f72c92..0000000 --- a/crates/goat-provider-openai-compat/src/headers.rs +++ /dev/null @@ -1,44 +0,0 @@ -use goat_provider::{RateLimitSnapshot, RateWindow, now_secs}; -use reqwest::header::HeaderMap; - -pub fn parse_codex_ratelimits(headers: &HeaderMap) -> Option { - let mut windows = Vec::new(); - - if let Some(window) = parse_codex_window(headers, "primary", "5h") { - windows.push(window); - } - if let Some(window) = parse_codex_window(headers, "secondary", "weekly") { - windows.push(window); - } - - if windows.is_empty() { - None - } else { - Some(RateLimitSnapshot { - windows, - representative: None, - }) - } -} - -fn parse_codex_window(headers: &HeaderMap, prefix: &str, label: &str) -> Option { - let pct_key = format!("x-codex-{prefix}-used-percent"); - let reset_key = format!("x-codex-{prefix}-reset-after-seconds"); - - let used_percent: f32 = headers - .get(&pct_key) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse().ok())?; - - let resets_at = headers - .get(&reset_key) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .map(|secs| now_secs() + secs); - - Some(RateWindow { - label: label.to_owned(), - used_percent, - resets_at, - }) -} diff --git a/crates/goat-provider-openai-compat/src/hosted.rs b/crates/goat-provider-openai-compat/src/hosted.rs deleted file mode 100644 index f9a42c4..0000000 --- a/crates/goat-provider-openai-compat/src/hosted.rs +++ /dev/null @@ -1,67 +0,0 @@ -use goat_auth::{CredentialKey, CredentialStore}; -use goat_provider::{AuthMethod, Effort, ProviderId, ProviderMetadata}; - -use crate::OpenAiCompatProvider; - -pub fn enforce_https_host(base_url: &str, allowed_host: &str) -> Result<(), String> { - let url = base_url.trim_end_matches('/'); - let rest = url - .strip_prefix("https://") - .ok_or_else(|| "hosted providers require https".to_owned())?; - let actual = rest.split('/').next().unwrap_or_default(); - if actual == allowed_host || actual.ends_with(&format!(".{allowed_host}")) { - Ok(()) - } else { - Err(format!("invalid hosted provider host: {actual}")) - } -} - -pub fn api_key( - store: &CredentialStore, - account: &str, - provider_id: &'static str, - base_url: &'static str, - allowed_host: &'static str, - env_var: &'static str, -) -> OpenAiCompatProvider { - enforce_https_host(base_url, allowed_host).expect("hosted provider base URL"); - let key = CredentialKey::model(provider_id, account); - let bearer = store - .resolve(&key, Some(env_var)) - .map(|cred| cred.bearer().to_owned()); - OpenAiCompatProvider::new( - ProviderId::from(provider_id), - base_url, - bearer, - AuthMethod::ApiKey, - ) - .with_metadata(ProviderMetadata { - env_var: Some(env_var), - validation: "network", - endpoint: None, - oauth: Some("not supported"), - login_endpoint: None, - setup: &[], - }) -} - -pub fn no_vision(_id: &str) -> bool { - false -} - -pub fn no_efforts(_model: &str) -> Vec { - Vec::new() -} - -#[cfg(test)] -mod tests { - use super::enforce_https_host; - - #[test] - fn enforces_https_and_allowed_host() { - assert!(enforce_https_host("https://openrouter.ai/api/v1/", "openrouter.ai").is_ok()); - assert!(enforce_https_host("http://openrouter.ai/api/v1", "openrouter.ai").is_err()); - assert!(enforce_https_host("https://example.com/api/v1", "openrouter.ai").is_err()); - assert!(enforce_https_host("https://api.z.ai/api/coding/paas/v4", "api.z.ai").is_ok()); - } -} diff --git a/crates/goat-provider-openai-compat/src/lib.rs b/crates/goat-provider-openai-compat/src/lib.rs deleted file mode 100644 index d0bd271..0000000 --- a/crates/goat-provider-openai-compat/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub mod chat; -pub mod common; -pub mod headers; -pub mod hosted; -pub mod responses; -pub mod vision; - -pub use chat::{ChatDiscovery, ChatValidation, OpenAiCompatProvider}; -pub use headers::parse_codex_ratelimits; -pub use hosted::{api_key, enforce_https_host, no_efforts, no_vision}; -pub use responses::{ - ResponsesProvider, build_body, responses_efforts, run_request, run_web_search, -}; -pub use vision::{known_openai_compatible_vision_model, known_openai_vision_model}; diff --git a/crates/goat-provider-openai-compat/src/responses.rs b/crates/goat-provider-openai-compat/src/responses.rs deleted file mode 100644 index 98bf553..0000000 --- a/crates/goat-provider-openai-compat/src/responses.rs +++ /dev/null @@ -1,1056 +0,0 @@ -use std::collections::HashMap; - -use eventsource_stream::Eventsource; -use futures::StreamExt; -use goat_provider::{ - AuthMethod, Capabilities, ContentBlock, Effort, Message, MessageRole, Model, Provider, - ProviderId, ProviderMetadata, RateLimitSnapshot, Request, SearchResult, StreamError, - StreamEvent, ToolChoice, ToolDefinition, Usage, WebSearchOutput, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use tokio::{sync::mpsc, task::JoinHandle}; - -use crate::common; - -#[derive(Serialize)] -struct ResponsesRequest<'a> { - model: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - instructions: Option<&'a str>, - input: Vec, - #[serde(skip_serializing_if = "Vec::is_empty")] - tools: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - tool_choice: Option<&'a str>, - #[serde(skip_serializing_if = "std::ops::Not::not")] - parallel_tool_calls: bool, - #[serde(skip_serializing_if = "Option::is_none")] - reasoning: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - include: Vec<&'a str>, - store: bool, - stream: bool, -} - -fn effort_wire(effort: Effort) -> &'static str { - match effort { - Effort::Off => "none", - other => other.as_str(), - } -} - -#[must_use] -pub fn responses_efforts(model: &str) -> Vec { - let id = model.to_ascii_lowercase(); - if id.starts_with("gpt-5") { - vec![ - Effort::Off, - Effort::Low, - Effort::Medium, - Effort::High, - Effort::Xhigh, - ] - } else if id.starts_with("o3") || id.starts_with("o4") { - vec![Effort::Low, Effort::Medium, Effort::High] - } else { - Vec::new() - } -} - -fn text_item(role: &str, content_kind: &str, text: &str) -> serde_json::Value { - json!({ - "type": "message", - "role": role, - "content": [{ "type": content_kind, "text": text }], - }) -} - -fn reasoning_input_item(data: &str) -> Option { - let blob = serde_json::from_str::(data).ok()?; - let id = blob.get("id")?.as_str()?; - let encrypted_content = blob.get("encrypted_content")?.as_str()?; - Some(json!({ - "type": "reasoning", - "id": id, - "summary": [], - "encrypted_content": encrypted_content, - })) -} - -fn append_message_items( - message: &Message, - role: &str, - content_kind: &str, - input: &mut Vec, -) { - let mut text = String::new(); - for block in &message.content { - match block { - ContentBlock::Text { text: chunk } => { - if !text.is_empty() { - text.push('\n'); - } - text.push_str(chunk); - } - ContentBlock::ToolUse { - id, - name, - input: args, - } => { - input.push(json!({ - "type": "function_call", - "call_id": id, - "name": name, - "arguments": common::tool_arguments(args), - })); - } - ContentBlock::ToolResult { - tool_use_id, - content, - .. - } => { - let image = content.iter().find_map(|b| match b { - ContentBlock::Image { media_type, data } => Some((media_type, data)), - _ => None, - }); - let output = if let Some((media_type, data)) = image { - json!([{ - "type": "input_image", - "image_url": format!("data:{media_type};base64,{data}"), - }]) - } else { - json!(ContentBlock::tool_result_text(content)) - }; - input.push(json!({ - "type": "function_call_output", - "call_id": tool_use_id, - "output": output, - })); - } - ContentBlock::RedactedThinking { data } => { - if let Some(item) = reasoning_input_item(data) { - if !text.is_empty() { - input.push(text_item(role, content_kind, &text)); - text.clear(); - } - input.push(item); - } - } - ContentBlock::Image { media_type, data } => { - if !text.is_empty() { - input.push(text_item(role, content_kind, &text)); - text.clear(); - } - input.push(json!({ - "type": "message", - "role": role, - "content": [{ - "type": "input_image", - "image_url": format!("data:{media_type};base64,{data}"), - }], - })); - } - ContentBlock::Thinking { .. } => {} - } - } - if !text.is_empty() { - input.push(text_item(role, content_kind, &text)); - } -} - -pub fn build_body( - model: &str, - messages: &[Message], - tools: &[ToolDefinition], - default_instructions: Option<&str>, - store: bool, - effort: Option, - choice: ToolChoice, -) -> serde_json::Value { - let mut instructions = String::new(); - let mut input: Vec = Vec::new(); - for message in messages { - match message.role { - MessageRole::System => { - if !instructions.is_empty() { - instructions.push('\n'); - } - instructions.push_str(&message.text_content()); - } - MessageRole::User => append_message_items(message, "user", "input_text", &mut input), - MessageRole::Assistant => { - append_message_items(message, "assistant", "output_text", &mut input); - } - } - } - let instructions = if instructions.is_empty() { - default_instructions - } else { - Some(instructions.as_str()) - }; - let tools: Vec = tools - .iter() - .map(|tool| { - json!({ - "type": "function", - "name": tool.name, - "description": tool.description, - "parameters": tool.input_schema, - }) - }) - .collect(); - let has_tools = !tools.is_empty(); - let reasoning = - effort.map(|effort| json!({ "effort": effort_wire(effort), "summary": "auto" })); - let include = if reasoning.is_some() { - vec!["reasoning.encrypted_content"] - } else { - Vec::new() - }; - let tool_choice = match (has_tools, choice) { - (false, _) => None, - (true, ToolChoice::None) => Some("none"), - (true, ToolChoice::Auto) => Some("auto"), - }; - let request = ResponsesRequest { - model, - instructions, - input, - tools, - tool_choice, - parallel_tool_calls: has_tools, - reasoning, - include, - store, - stream: true, - }; - serde_json::to_value(request).expect("ResponsesRequest is always serializable") -} - -pub async fn run_request( - client: &reqwest::Client, - url: &str, - bearer: Option<&str>, - account_id: Option<&str>, - body: &serde_json::Value, - events: &mpsc::Sender, - parse_rate_limits: Option Option>, -) { - let mut builder = client - .post(url) - .header("Accept", "text/event-stream") - .json(body); - if let Some(token) = bearer { - builder = builder.bearer_auth(token); - } - if let Some(account) = account_id { - builder = builder.header("chatgpt-account-id", account); - } - let resp = match builder.send().await { - Ok(resp) => resp, - Err(err) => { - let _ = events - .send(StreamEvent::Failed { - error: common::transport(&err), - }) - .await; - return; - } - }; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - let _ = events - .send(StreamEvent::Failed { - error: common::classify_http(status, &headers, &detail), - }) - .await; - return; - } - if let Some(parser) = parse_rate_limits - && let Some(snapshot) = parser(resp.headers()) - { - let _ = events.send(StreamEvent::RateLimits { snapshot }).await; - } - stream_responses(resp, events).await; -} - -async fn stream_responses(response: reqwest::Response, events: &mpsc::Sender) { - let mut stream = response.bytes_stream().eventsource(); - let mut tool_calls: HashMap = HashMap::new(); - while let Some(event) = stream.next().await { - match event { - Ok(event) => match event.event.as_str() { - "response.output_text.delta" => { - if let Some(text) = parse_output_delta(&event.data) - && events.send(StreamEvent::TextDelta { text }).await.is_err() - { - return; - } - } - "response.reasoning_summary_text.delta" => { - if let Some(text) = parse_output_delta(&event.data) - && events - .send(StreamEvent::ThinkingDelta { text }) - .await - .is_err() - { - return; - } - } - "response.output_item.added" => { - if let Some(call) = parse_function_call_item(&event.data) { - tool_calls.insert(call.item_id, (call.call_id, call.name, String::new())); - } - } - "response.function_call_arguments.delta" => { - if let Some(delta) = parse_arguments_delta(&event.data) - && let Some(entry) = tool_calls.get_mut(&delta.item_id) - { - entry.2.push_str(&delta.delta); - } - } - "response.output_item.done" | "response.function_call_arguments.done" => { - if let Some(data) = parse_reasoning_item(&event.data) - && events - .send(StreamEvent::RedactedThinking { data }) - .await - .is_err() - { - return; - } - if let Some(item_id) = parse_item_id(&event.data) - && let Some((call_id, name, input)) = tool_calls.remove(&item_id) - && events - .send(StreamEvent::ToolCall { - id: call_id, - name, - input, - }) - .await - .is_err() - { - return; - } - } - "response.completed" => { - if let Some(usage) = parse_completed_usage(&event.data) { - let _ = events.send(StreamEvent::Usage { usage }).await; - } - break; - } - "response.failed" | "error" => { - let _ = events - .send(StreamEvent::Failed { - error: common::classify_stream_error(&event.data), - }) - .await; - return; - } - _ => {} - }, - Err(err) => { - let _ = events - .send(StreamEvent::Failed { - error: goat_provider::StreamError::transport(err.to_string()), - }) - .await; - return; - } - } - } - let _ = events.send(StreamEvent::Completed).await; -} - -#[derive(Deserialize)] -struct CompletedEvent { - response: Option, -} - -#[derive(Deserialize)] -struct CompletedResponse { - usage: Option, -} - -#[derive(Deserialize)] -struct ResponseUsage { - input_tokens: Option, - output_tokens: Option, - #[serde(default)] - input_tokens_details: InputTokenDetails, -} - -#[derive(Default, Deserialize)] -struct InputTokenDetails { - #[serde(default)] - cached_tokens: u32, -} - -fn parse_completed_usage(data: &str) -> Option { - let ev = serde_json::from_str::(data).ok()?; - let u = ev.response?.usage?; - Some(Usage { - input_tokens: u.input_tokens.unwrap_or(0), - output_tokens: u.output_tokens.unwrap_or(0), - cache_read_tokens: u.input_tokens_details.cached_tokens, - cache_write_tokens: 0, - }) -} - -#[derive(Deserialize)] -struct OutputTextDelta { - delta: Option, -} - -fn parse_output_delta(data: &str) -> Option { - serde_json::from_str::(data).ok()?.delta -} - -#[derive(Deserialize)] -struct OutputItemAdded { - item: OutputItem, -} - -#[derive(Deserialize)] -struct OutputItem { - #[serde(rename = "type")] - kind: String, - id: Option, - call_id: Option, - name: Option, -} - -struct FunctionCallItem { - item_id: String, - call_id: String, - name: String, -} - -fn parse_function_call_item(data: &str) -> Option { - let item = serde_json::from_str::(data).ok()?.item; - if item.kind != "function_call" { - return None; - } - Some(FunctionCallItem { - item_id: item.id?, - call_id: item.call_id?, - name: item.name?, - }) -} - -fn parse_reasoning_item(data: &str) -> Option { - let item = serde_json::from_str::(data).ok()?.item; - if item.kind != "reasoning" { - return None; - } - let encrypted_content = item.encrypted_content?; - serde_json::to_string(&json!({ - "id": item.id, - "encrypted_content": encrypted_content, - })) - .ok() -} - -#[derive(Deserialize)] -struct ReasoningItemEvent { - item: ReasoningItem, -} - -#[derive(Deserialize)] -struct ReasoningItem { - #[serde(rename = "type")] - kind: String, - id: String, - #[serde(default)] - encrypted_content: Option, -} - -#[derive(Deserialize)] -struct ArgumentsDelta { - item_id: String, - delta: String, -} - -fn parse_arguments_delta(data: &str) -> Option { - serde_json::from_str::(data).ok() -} - -#[derive(Deserialize)] -struct ItemRef { - item_id: Option, - item: Option, -} - -fn parse_item_id(data: &str) -> Option { - let parsed = serde_json::from_str::(data).ok()?; - parsed - .item_id - .or_else(|| parsed.item.and_then(|item| item.id)) -} - -pub struct ResponsesProvider { - id: ProviderId, - base_url: String, - bearer: Option, - auth: AuthMethod, - client: reqwest::Client, - model_filter: Option bool>, - vision_filter: fn(&str) -> bool, - catalog: &'static [&'static str], - rate_limits_parser: Option Option>, - context_windows: &'static [(&'static str, u32)], - search_model: Option<&'static str>, - metadata: ProviderMetadata, -} - -impl ResponsesProvider { - pub fn new( - id: ProviderId, - base_url: impl Into, - bearer: Option, - auth: AuthMethod, - ) -> Self { - Self { - id, - base_url: base_url.into(), - bearer, - auth, - client: common::http_client(), - model_filter: None, - vision_filter: crate::vision::known_openai_vision_model, - catalog: &[], - rate_limits_parser: None, - context_windows: &[], - search_model: None, - metadata: ProviderMetadata::default(), - } - } - - #[must_use] - pub fn with_search_model(mut self, model: &'static str) -> Self { - self.search_model = Some(model); - self - } - - #[must_use] - pub fn with_model_filter(mut self, filter: fn(&str) -> bool) -> Self { - self.model_filter = Some(filter); - self - } - - #[must_use] - pub fn with_vision_filter(mut self, filter: fn(&str) -> bool) -> Self { - self.vision_filter = filter; - self - } - - #[must_use] - pub fn with_catalog(mut self, catalog: &'static [&'static str]) -> Self { - self.catalog = catalog; - self - } - - #[must_use] - pub fn supports_images(&self, model: &str) -> bool { - (self.vision_filter)(model) - } - - #[must_use] - pub fn with_rate_limits_parser( - mut self, - parser: fn(&reqwest::header::HeaderMap) -> Option, - ) -> Self { - self.rate_limits_parser = Some(parser); - self - } - - #[must_use] - pub fn with_context_windows(mut self, windows: &'static [(&'static str, u32)]) -> Self { - self.context_windows = windows; - self - } - - #[must_use] - pub fn with_metadata(mut self, metadata: ProviderMetadata) -> Self { - self.metadata = metadata; - self - } -} - -fn build_web_search_body( - model: &str, - instructions: Option<&str>, - query: &str, -) -> serde_json::Value { - let mut body = json!({ - "model": model, - "input": [text_item("user", "input_text", query)], - "tools": [{ "type": "web_search" }], - "tool_choice": "auto", - }); - if let Some(instructions) = instructions { - body["instructions"] = json!(instructions); - } - body -} - -pub async fn run_web_search( - client: &reqwest::Client, - url: &str, - bearer: Option<&str>, - account_id: Option<&str>, - model: &str, - instructions: Option<&str>, - query: &str, -) -> Result { - let body = build_web_search_body(model, instructions, query); - let mut builder = client.post(url).json(&body); - if let Some(token) = bearer { - builder = builder.bearer_auth(token); - } - if let Some(account) = account_id { - builder = builder.header("chatgpt-account-id", account); - } - let resp = builder - .send() - .await - .map_err(|err| common::transport(&err))?; - if !resp.status().is_success() { - let status = resp.status(); - let headers = resp.headers().clone(); - let detail = resp.text().await.unwrap_or_default(); - return Err(common::classify_http(status, &headers, &detail)); - } - let value: serde_json::Value = resp - .json() - .await - .map_err(|err| StreamError::other(format!("invalid search response: {err}")))?; - Ok(WebSearchOutput::from_results(parse_responses_citations( - &value, - ))) -} - -fn parse_responses_citations(value: &serde_json::Value) -> Vec { - let mut out = Vec::new(); - let mut seen = std::collections::HashSet::new(); - let Some(output) = value.get("output").and_then(|output| output.as_array()) else { - return out; - }; - for item in output { - let Some(content) = item.get("content").and_then(|content| content.as_array()) else { - continue; - }; - for part in content { - let Some(annotations) = part - .get("annotations") - .and_then(|annotations| annotations.as_array()) - else { - continue; - }; - for annotation in annotations { - if annotation.get("type").and_then(|kind| kind.as_str()) != Some("url_citation") { - continue; - } - let url = annotation - .get("url") - .and_then(|value| value.as_str()) - .unwrap_or_default(); - if url.is_empty() || !seen.insert(url.to_owned()) { - continue; - } - out.push(SearchResult { - title: annotation - .get("title") - .and_then(|value| value.as_str()) - .unwrap_or_default() - .to_owned(), - url: url.to_owned(), - snippet: String::new(), - }); - } - } - } - out -} - -impl Provider for ResponsesProvider { - fn id(&self) -> ProviderId { - self.id.clone() - } - - fn authenticated(&self) -> bool { - common::authenticated(self.auth, &self.bearer) - } - - fn validate(&self) -> JoinHandle> { - common::validate_bearer( - self.client.clone(), - format!("{}/models", self.base_url), - self.auth, - self.bearer.clone(), - ) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: self.auth, - images: true, - } - } - - fn metadata(&self) -> ProviderMetadata { - self.metadata - } - - fn supports_images(&self, model: &str) -> bool { - (self.vision_filter)(model) - } - - fn supports_web_search(&self) -> bool { - self.search_model.is_some() - } - - fn web_search(&self, query: String) -> JoinHandle> { - let client = self.client.clone(); - let url = format!("{}/responses", self.base_url); - let bearer = self.bearer.clone(); - let model = self.search_model; - tokio::spawn(async move { - let Some(model) = model else { - return Err(StreamError::other("web search is not supported")); - }; - run_web_search(&client, &url, bearer.as_deref(), None, model, None, &query).await - }) - } - - fn catalog(&self) -> &'static [&'static str] { - self.catalog - } - - fn efforts(&self, model: &str) -> Vec { - responses_efforts(model) - } - - fn context_window(&self, model: &str) -> Option { - self.context_windows - .iter() - .find(|(prefix, _)| model.starts_with(prefix)) - .map(|(_, w)| *w) - } - - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { - let client = self.client.clone(); - let url = format!("{}/responses", self.base_url); - let bearer = self.bearer.clone(); - let rate_limits_parser = self.rate_limits_parser; - tokio::spawn(async move { - let body = build_body( - &req.model, - &req.messages, - &req.tools, - None, - false, - req.effort, - req.tool_choice, - ); - run_request( - &client, - &url, - bearer.as_deref(), - None, - &body, - &events, - rate_limits_parser, - ) - .await; - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - common::discover_models( - self.client.clone(), - format!("{}/models", self.base_url), - self.bearer.clone(), - self.model_filter, - self.vision_filter, - out, - ) - } -} - -#[cfg(test)] -mod tests { - use super::{ - build_body, build_web_search_body, parse_arguments_delta, parse_completed_usage, - parse_function_call_item, parse_item_id, parse_output_delta, parse_reasoning_item, - parse_responses_citations, - }; - use goat_provider::{ContentBlock, Message, MessageRole, ToolDefinition}; - - #[test] - fn web_search_body_uses_list_input() { - let body = build_web_search_body("gpt-search", Some("base"), "find this"); - assert_eq!(body["model"], "gpt-search"); - assert_eq!(body["instructions"], "base"); - assert!(body["input"].is_array()); - assert_eq!(body["input"][0]["type"], "message"); - assert_eq!(body["input"][0]["role"], "user"); - assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); - assert_eq!(body["input"][0]["content"][0]["text"], "find this"); - assert_eq!(body["tools"][0]["type"], "web_search"); - assert_eq!(body["tool_choice"], "auto"); - } - - #[test] - fn web_search_body_omits_empty_instructions() { - let body = build_web_search_body("gpt-search", None, "find this"); - assert!(body.get("instructions").is_none()); - } - - #[test] - fn extracts_url_citations() { - let value = serde_json::json!({ - "output": [ - { "type": "web_search_call", "status": "completed" }, - { "type": "message", "content": [ - { "type": "output_text", "text": "see sources", "annotations": [ - { "type": "url_citation", "url": "https://a.example", "title": "A" }, - { "type": "url_citation", "url": "https://a.example", "title": "A dup" }, - { "type": "url_citation", "url": "https://b.example", "title": "B" } - ]} - ]} - ] - }); - let results = parse_responses_citations(&value); - assert_eq!(results.len(), 2); - assert_eq!(results[0].url, "https://a.example"); - assert_eq!(results[1].url, "https://b.example"); - } - use serde_json::json; - - #[test] - fn parses_output_text_delta() { - let data = r#"{"type":"response.output_text.delta","delta":"Hi"}"#; - assert_eq!(parse_output_delta(data).as_deref(), Some("Hi")); - } - - #[test] - fn default_instructions_used_when_no_system_message() { - let messages = vec![Message::text(MessageRole::User, "hi")]; - let body = build_body( - "gpt-5.5", - &messages, - &[], - Some("base"), - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert_eq!(body["instructions"], "base"); - assert_eq!(body["input"][0]["role"], "user"); - assert_eq!(body["input"][0]["content"][0]["type"], "input_text"); - } - - #[test] - fn system_message_overrides_default_instructions() { - let messages = vec![ - Message::text(MessageRole::System, "be terse"), - Message::text(MessageRole::User, "hi"), - ]; - let body = build_body( - "gpt-5.5", - &messages, - &[], - Some("base"), - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert_eq!(body["instructions"], "be terse"); - } - - #[test] - fn instructions_omitted_when_empty_and_no_default() { - let messages = vec![Message::text(MessageRole::User, "hi")]; - let body = build_body( - "gpt-5.5", - &messages, - &[], - None, - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert!(body.get("instructions").is_none()); - } - - #[test] - fn serializes_tool_definitions() { - let tools = vec![ToolDefinition { - name: "read_file".to_owned(), - description: "reads a file".to_owned(), - input_schema: json!({ "type": "object" }), - }]; - let messages = vec![Message::text(MessageRole::User, "hi")]; - let body = build_body( - "gpt-5.5", - &messages, - &tools, - None, - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert_eq!(body["tools"][0]["type"], "function"); - assert_eq!(body["tools"][0]["name"], "read_file"); - assert_eq!(body["tools"][0]["parameters"]["type"], "object"); - } - - #[test] - fn serializes_tool_use_and_result_items() { - let assistant = Message { - role: MessageRole::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call_1".to_owned(), - name: "read_file".to_owned(), - input: json!({ "path": "a.txt" }), - }], - }; - let result = Message { - role: MessageRole::User, - content: vec![ContentBlock::text_result("call_1", "file body", false)], - }; - let body = build_body( - "gpt-5.5", - &[assistant, result], - &[], - None, - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert_eq!(body["input"][0]["type"], "function_call"); - assert_eq!(body["input"][0]["call_id"], "call_1"); - assert_eq!(body["input"][0]["name"], "read_file"); - assert_eq!(body["input"][0]["arguments"], r#"{"path":"a.txt"}"#); - assert_eq!(body["input"][1]["type"], "function_call_output"); - assert_eq!(body["input"][1]["call_id"], "call_1"); - assert_eq!(body["input"][1]["output"], "file body"); - } - - #[test] - fn reasoning_included_only_when_effort_present() { - let messages = vec![Message::text(MessageRole::User, "hi")]; - let plain = build_body( - "gpt-5.5", - &messages, - &[], - None, - false, - None, - goat_provider::ToolChoice::Auto, - ); - assert!(plain.get("reasoning").is_none()); - let high = build_body( - "gpt-5.5", - &messages, - &[], - None, - false, - Some(goat_provider::Effort::High), - goat_provider::ToolChoice::Auto, - ); - assert_eq!(high["reasoning"]["effort"], "high"); - assert_eq!(high["reasoning"]["summary"], "auto"); - let off = build_body( - "gpt-5.5", - &messages, - &[], - None, - false, - Some(goat_provider::Effort::Off), - goat_provider::ToolChoice::Auto, - ); - assert_eq!(off["reasoning"]["effort"], "none"); - assert!(plain.get("include").is_none()); - assert_eq!(high["include"][0], "reasoning.encrypted_content"); - } - - #[test] - fn reasoning_item_round_trips_through_input() { - let done = - r#"{"item":{"type":"reasoning","id":"rs_1","summary":[],"encrypted_content":"ENC"}}"#; - let data = parse_reasoning_item(done).expect("reasoning item"); - let message = Message { - role: MessageRole::Assistant, - content: vec![ - ContentBlock::RedactedThinking { data }, - ContentBlock::Text { - text: "answer".to_owned(), - }, - ], - }; - let body = build_body( - "gpt-5.5", - &[message], - &[], - None, - false, - Some(goat_provider::Effort::High), - goat_provider::ToolChoice::Auto, - ); - assert_eq!(body["input"][0]["type"], "reasoning"); - assert_eq!(body["input"][0]["id"], "rs_1"); - assert_eq!(body["input"][0]["encrypted_content"], "ENC"); - assert!(body["input"][0]["summary"].is_array()); - assert_eq!(body["input"][1]["type"], "message"); - } - - #[test] - fn reasoning_item_without_encrypted_content_is_ignored() { - let done = r#"{"item":{"type":"reasoning","id":"rs_1","summary":[]}}"#; - assert!(parse_reasoning_item(done).is_none()); - } - - #[test] - fn accumulates_function_call_from_stream() { - let added = r#"{"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file"}}"#; - let call = parse_function_call_item(added).unwrap(); - assert_eq!(call.item_id, "fc_1"); - assert_eq!(call.call_id, "call_1"); - assert_eq!(call.name, "read_file"); - - let first = r#"{"item_id":"fc_1","delta":"{\"path\":"}"#; - let second = r#"{"item_id":"fc_1","delta":"\"a.txt\"}"}"#; - let mut buf = String::new(); - for chunk in [first, second] { - let delta = parse_arguments_delta(chunk).unwrap(); - assert_eq!(delta.item_id, "fc_1"); - buf.push_str(&delta.delta); - } - assert_eq!(buf, r#"{"path":"a.txt"}"#); - - let done = r#"{"item_id":"fc_1"}"#; - assert_eq!(parse_item_id(done).as_deref(), Some("fc_1")); - let done_item = r#"{"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"read_file"}}"#; - assert_eq!(parse_item_id(done_item).as_deref(), Some("fc_1")); - } - - #[test] - fn completed_usage_does_not_map_reasoning_to_cache_write() { - let data = r#"{"response":{"usage":{ - "input_tokens":100, - "output_tokens":50, - "input_tokens_details":{"cached_tokens":20}, - "output_tokens_details":{"reasoning_tokens":30} - }}}"#; - let usage = parse_completed_usage(data).expect("usage"); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.output_tokens, 50); - assert_eq!(usage.cache_read_tokens, 20); - assert_eq!(usage.cache_write_tokens, 0); - } -} diff --git a/crates/goat-provider-openai-compat/src/vision.rs b/crates/goat-provider-openai-compat/src/vision.rs deleted file mode 100644 index 303a2a2..0000000 --- a/crates/goat-provider-openai-compat/src/vision.rs +++ /dev/null @@ -1,41 +0,0 @@ -pub fn known_openai_vision_model(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - if id.contains("image") - || id.contains("audio") - || id.contains("tts") - || id.contains("whisper") - || id.contains("transcribe") - || id.contains("realtime") - || id.contains("embedding") - || id.contains("moderation") - || id.contains("search") - || id.contains("instruct") - { - return false; - } - id.starts_with("gpt-5") - || id.starts_with("gpt-4.1") - || id.starts_with("gpt-4o") - || id.starts_with("o3") - || id.starts_with("o4") -} - -pub fn known_openai_compatible_vision_model(model: &str) -> bool { - let id = model.to_ascii_lowercase(); - known_openai_vision_model(&id) - || id.contains("vision") - || id.contains("llava") - || id.contains("bakllava") - || id.contains("moondream") - || id.contains("qwen-vl") - || id.contains("qwen2-vl") - || id.contains("qwen2.5-vl") - || id.contains("qwen3-vl") - || id.contains("minicpm-v") - || id.contains("pixtral") - || id.contains("internvl") - || id.contains("cogvlm") - || id.contains("vila") - || id.contains("granite-vision") - || id.contains("gemma3") -} diff --git a/crates/goat-provider-openai/Cargo.toml b/crates/goat-provider-openai/Cargo.toml deleted file mode 100644 index c6ec5e1..0000000 --- a/crates/goat-provider-openai/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-openai" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-openai/src/lib.rs b/crates/goat-provider-openai/src/lib.rs deleted file mode 100644 index 7099a22..0000000 --- a/crates/goat-provider-openai/src/lib.rs +++ /dev/null @@ -1,118 +0,0 @@ -use goat_auth::{CredentialKey, CredentialStore}; -use goat_provider::{AuthMethod, ProviderId, ProviderMetadata}; -use goat_provider_openai_compat::ResponsesProvider; - -pub const PROVIDER_ID: &str = "openai"; -const BASE_URL: &str = "https://api.openai.com/v1"; -const ENV_VAR: &str = "OPENAI_API_KEY"; -const SEARCH_MODEL: &str = "gpt-4.1"; - -const CATALOG: &[&str] = &[ - "gpt-5.5", - "gpt-5.4", - "gpt-5.4-mini", - "gpt-4.1", - "o3", - "o4-mini", -]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[ - ("gpt-5", 400_000), - ("gpt-4.1", 1_047_576), - ("o3", 200_000), - ("o4", 200_000), -]; - -const NON_CHAT_MARKERS: [&str; 15] = [ - "image", - "audio", - "tts", - "whisper", - "transcribe", - "realtime", - "embedding", - "moderation", - "search", - "dall-e", - "instruct", - "babbage", - "davinci", - "sora", - "computer-use", -]; - -fn is_chat_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - if NON_CHAT_MARKERS.iter().any(|marker| id.contains(marker)) { - return false; - } - let mut chars = id.chars(); - id.starts_with("gpt-") - || (chars.next() == Some('o') && chars.next().is_some_and(|c| c.is_ascii_digit())) -} - -pub fn build(store: &CredentialStore, account: &str) -> ResponsesProvider { - let key = CredentialKey::model(PROVIDER_ID, account); - let bearer = store - .resolve(&key, Some(ENV_VAR)) - .map(|cred| cred.bearer().to_owned()); - ResponsesProvider::new( - ProviderId::from(PROVIDER_ID), - BASE_URL, - bearer, - AuthMethod::ApiKey, - ) - .with_model_filter(is_chat_model) - .with_vision_filter(goat_provider_openai_compat::known_openai_vision_model) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_search_model(SEARCH_MODEL) - .with_metadata(ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "network", - endpoint: None, - oauth: Some("not supported"), - login_endpoint: None, - setup: &[], - }) -} - -#[cfg(test)] -mod tests { - use super::is_chat_model; - - #[test] - fn keeps_chat_models() { - for id in [ - "gpt-5.5", - "gpt-5-codex", - "gpt-4o", - "gpt-4.1-mini", - "o3", - "o4-mini", - "gpt-3.5-turbo", - ] { - assert!(is_chat_model(id), "expected to keep {id}"); - } - } - - #[test] - fn drops_non_chat_models() { - for id in [ - "gpt-4o-mini-tts", - "gpt-4o-transcribe", - "whisper-1", - "tts-1-hd", - "gpt-image-1", - "text-embedding-3-large", - "gpt-realtime", - "gpt-4o-search-preview", - "gpt-3.5-turbo-instruct", - "omni-moderation-latest", - "davinci-002", - "sora-2", - ] { - assert!(!is_chat_model(id), "expected to drop {id}"); - } - } -} diff --git a/crates/goat-provider-openrouter/Cargo.toml b/crates/goat-provider-openrouter/Cargo.toml deleted file mode 100644 index 2349526..0000000 --- a/crates/goat-provider-openrouter/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-openrouter" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -goat-auth = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-openrouter/src/lib.rs b/crates/goat-provider-openrouter/src/lib.rs deleted file mode 100644 index 037fc3a..0000000 --- a/crates/goat-provider-openrouter/src/lib.rs +++ /dev/null @@ -1,55 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider::ModelListSource; -use goat_provider_openai_compat::{ - OpenAiCompatProvider, api_key, known_openai_compatible_vision_model, -}; - -pub const PROVIDER_ID: &str = "openrouter"; -const BASE_URL: &str = "https://openrouter.ai/api/v1"; -const HOST: &str = "openrouter.ai"; -const ENV_VAR: &str = "OPENROUTER_API_KEY"; - -const CATALOG: &[&str] = &[ - "anthropic/claude-sonnet-4.6", - "openai/gpt-5.4", - "google/gemini-2.5-pro", - "deepseek/deepseek-chat-v3-0324", - "qwen/qwen3-coder", - "moonshotai/kimi-k2.6", -]; - -const CONTEXT_WINDOWS: &[(&str, u32)] = &[ - ("anthropic/claude-sonnet-4.6", 1_000_000), - ("openai/gpt-5.4", 1_000_000), - ("google/gemini-2.5", 1_000_000), - ("deepseek/deepseek-chat-v3", 164_000), - ("qwen/qwen3-coder", 1_000_000), - ("moonshotai/kimi-k2.6", 262_144), -]; - -fn is_chat_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - !(id.contains("embedding") - || id.contains("moderation") - || id.contains("image") - || id.contains("tts") - || id.contains("whisper")) -} - -fn is_vision_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - known_openai_compatible_vision_model(&id) - || id.contains("claude") - || id.contains("gemini") - || id.contains("grok-4") -} - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT_WINDOWS) - .with_model_filter(is_chat_model) - .with_vision_filter(is_vision_model) - .with_reasoning_effort(false) - .with_model_list_source(ModelListSource::Discover) -} diff --git a/crates/goat-provider-qwen/Cargo.toml b/crates/goat-provider-qwen/Cargo.toml deleted file mode 100644 index c06beb8..0000000 --- a/crates/goat-provider-qwen/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "goat-provider-qwen" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-qwen/src/lib.rs b/crates/goat-provider-qwen/src/lib.rs deleted file mode 100644 index aa8923b..0000000 --- a/crates/goat-provider-qwen/src/lib.rs +++ /dev/null @@ -1,207 +0,0 @@ -use goat_auth::{CredentialKey, CredentialStore}; -use goat_provider::{AuthMethod, LoginEndpointMetadata, ProviderId, ProviderMetadata}; -use goat_provider_openai_compat::{OpenAiCompatProvider, no_efforts}; - -pub const PROVIDER_ID: &str = "qwen"; - -const QWEN_DEFAULT_ENDPOINT: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1"; - -const QWEN_SETUP: &[&str] = &[ - "Qwen DashScope API-key provider.", - "Default endpoint: https://dashscope-us.aliyuncs.com/compatible-mode/v1", - "Non-US workspaces: `goat-code provider login qwen --endpoint --key sk-...`.", - "Qwen OAuth enrollment is discontinued upstream.", -]; - -const CATALOG: &[&str] = &[ - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-flash", - "qwen3-coder-plus", - "qwen3-coder-flash", -]; - -const CONTEXT: &[(&str, u32)] = &[ - ("qwen3.7", 1_000_000), - ("qwen3.6-flash", 1_000_000), - ("qwen3-coder", 1_000_000), -]; - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - let key = CredentialKey::model(PROVIDER_ID, account); - let stored_endpoint = store - .get(&key) - .and_then(|cred| cred.endpoint().map(str::to_owned)); - let endpoint = match stored_endpoint { - Some(raw) => validate_qwen_endpoint(&raw).ok(), - None => Some(QWEN_DEFAULT_ENDPOINT.to_owned()), - }; - let bearer = endpoint.as_ref().and_then(|_| { - store - .resolve(&key, Some("DASHSCOPE_API_KEY")) - .map(|cred| cred.bearer().to_owned()) - }); - OpenAiCompatProvider::new( - ProviderId::from(PROVIDER_ID), - endpoint.unwrap_or_else(|| QWEN_DEFAULT_ENDPOINT.to_owned()), - bearer, - AuthMethod::ApiKey, - ) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT) - .with_vision_filter(qwen_vision_model) - .with_efforts(no_efforts) - .with_reasoning_effort(false) - .with_metadata(ProviderMetadata { - env_var: Some("DASHSCOPE_API_KEY"), - validation: "network", - endpoint: Some("required for non-US DashScope workspaces"), - oauth: Some("Qwen OAuth enrollment discontinued"), - login_endpoint: Some(LoginEndpointMetadata { - env_var: Some("QWEN_BASE_URL"), - default: Some(QWEN_DEFAULT_ENDPOINT), - validate: Some(validate_qwen_endpoint), - }), - setup: QWEN_SETUP, - }) -} - -pub fn validate_qwen_endpoint(endpoint: &str) -> Result { - let trimmed = endpoint.trim().trim_end_matches('/'); - let url = reqwest::Url::parse(trimmed).map_err(|err| err.to_string())?; - if url.scheme() != "https" { - return Err("qwen endpoint must use https".to_owned()); - } - if !url.username().is_empty() || url.password().is_some() { - return Err("qwen endpoint must not include userinfo".to_owned()); - } - let Some(host) = url.host_str() else { - return Err("qwen endpoint must include a host".to_owned()); - }; - if host.ends_with('.') { - return Err("qwen endpoint host must not end with a dot".to_owned()); - } - let allowed_static = [ - "dashscope.aliyuncs.com", - "dashscope-intl.aliyuncs.com", - "dashscope-us.aliyuncs.com", - ]; - let allowed_regions = [ - "cn-beijing.maas.aliyuncs.com", - "ap-southeast-1.maas.aliyuncs.com", - "ap-northeast-1.maas.aliyuncs.com", - ]; - let allowed = allowed_static.contains(&host) - || allowed_regions.iter().any(|region| { - host.strip_suffix(region) - .and_then(|prefix| prefix.strip_suffix('.')) - .is_some_and(valid_workspace_id) - }); - if !allowed { - return Err("qwen endpoint host is not an allowed Alibaba Model Studio host".to_owned()); - } - if url.port().is_some() { - return Err("qwen endpoint must not include a custom port".to_owned()); - } - if url.path() != "/compatible-mode/v1" { - return Err("qwen endpoint path must be /compatible-mode/v1".to_owned()); - } - if url.query().is_some() || url.fragment().is_some() { - return Err("qwen endpoint must not include query or fragment".to_owned()); - } - Ok(trimmed.to_owned()) -} - -fn valid_workspace_id(value: &str) -> bool { - !value.is_empty() - && value - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'-') -} - -fn qwen_vision_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - id.contains("qwen3.7") - || id.contains("qwen-vl") - || id.contains("qwen2-vl") - || id.contains("qwen2.5-vl") -} - -#[cfg(test)] -mod tests { - use goat_auth::{Credential, CredentialStore, SecretString}; - use goat_provider::Provider; - - use super::*; - - fn store(name: &str) -> CredentialStore { - let _ = std::fs::remove_file(std::env::temp_dir().join(name)); - CredentialStore::new(std::env::temp_dir().join(name)) - } - - #[test] - fn validates_qwen_endpoints() { - for endpoint in [ - "https://dashscope-us.aliyuncs.com/compatible-mode/v1", - "https://dashscope.aliyuncs.com/compatible-mode/v1", - "https://workspace-1.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", - "https://abc123.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/", - ] { - assert_eq!( - validate_qwen_endpoint(endpoint).unwrap(), - endpoint.trim_end_matches('/') - ); - } - for endpoint in [ - "http://dashscope-us.aliyuncs.com/compatible-mode/v1", - "https://dashscope-us.aliyuncs.com.evil.test/compatible-mode/v1", - "https://user@dashscope-us.aliyuncs.com/compatible-mode/v1", - "https://dashscope-us.aliyuncs.com:444/compatible-mode/v1", - "https://dashscope-us.aliyuncs.com/v1", - "https://dashscope-us.aliyuncs.com/compatible-mode/v1?x=1", - "https://workspace_1.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", - "https://workspace-1.cn-hangzhou.maas.aliyuncs.com/compatible-mode/v1", - ] { - assert!( - validate_qwen_endpoint(endpoint).is_err(), - "expected rejection for {endpoint}" - ); - } - } - - #[test] - fn invalid_qwen_endpoint_does_not_authenticate() { - let store = store("goat-provider-qwen-invalid.json"); - store - .store( - &CredentialKey::model(PROVIDER_ID, "default"), - Credential::ApiKeyWithEndpoint { - secret: SecretString::from("key".to_owned()), - endpoint: "https://example.com/compatible-mode/v1".to_owned(), - }, - ) - .unwrap(); - let provider = build(&store, "default"); - assert!(!provider.authenticated()); - } - - #[test] - fn qwen_endpoint_credential_authenticates() { - let store = store("goat-provider-qwen-valid.json"); - store - .store( - &CredentialKey::model(PROVIDER_ID, "default"), - Credential::ApiKeyWithEndpoint { - secret: SecretString::from("key".to_owned()), - endpoint: "https://dashscope-us.aliyuncs.com/compatible-mode/v1".to_owned(), - }, - ) - .unwrap(); - let provider = build(&store, "default"); - assert!(provider.authenticated()); - assert_eq!( - provider.base_url(), - "https://dashscope-us.aliyuncs.com/compatible-mode/v1" - ); - } -} diff --git a/crates/goat-provider-xai/Cargo.toml b/crates/goat-provider-xai/Cargo.toml deleted file mode 100644 index 24aeeca..0000000 --- a/crates/goat-provider-xai/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "goat-provider-xai" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } -reqwest = { workspace = true } -serde = { workspace = true } -tokio = { workspace = true } -thiserror = { workspace = true } -open = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-xai/src/lib.rs b/crates/goat-provider-xai/src/lib.rs deleted file mode 100644 index 90c67ee..0000000 --- a/crates/goat-provider-xai/src/lib.rs +++ /dev/null @@ -1,440 +0,0 @@ -mod oauth; - -use goat_auth::{Credential, CredentialKey, CredentialStore, TokenSet}; -use goat_provider::{ - AuthMethod, Capabilities, Effort, Model, Provider, ProviderId, ProviderMetadata, Request, - StreamError, StreamEvent, WebSearchOutput, -}; -use goat_provider_openai_compat::{ - OpenAiCompatProvider, ResponsesProvider, enforce_https_host, no_efforts, -}; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub const PROVIDER_ID: &str = "xai"; - -const BASE_URL: &str = "https://api.x.ai/v1"; -const ALLOWED_HOST: &str = "api.x.ai"; - -const SETUP: &[&str] = &[ - "xAI Grok provider (API key or SuperGrok / X Premium+ OAuth).", - "API key: `goat-code provider login xai --key xai-...` or `XAI_API_KEY`.", - "OAuth: `goat-code provider login xai` (browser or device code; no API key).", - "OAuth coding models (Composer, Grok Build) use the same api.x.ai Responses API.", -]; - -const OAUTH_CATALOG: &[&str] = &[ - "grok-composer-2.5-fast", - "grok-4.3", - "grok-build-0.1", - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-4.20-multi-agent-0309", -]; - -const API_KEY_CATALOG: &[&str] = &[ - "grok-4", - "grok-4-fast-reasoning", - "grok-4-fast-non-reasoning", - "grok-3", - "grok-3-fast", - "grok-3-mini", -]; - -const CATALOG: &[&str] = &[ - "grok-composer-2.5-fast", - "grok-4.3", - "grok-build-0.1", - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-4.20-multi-agent-0309", - "grok-4", - "grok-4-fast-reasoning", - "grok-4-fast-non-reasoning", - "grok-3", - "grok-3-fast", - "grok-3-mini", -]; - -const OAUTH_CONTEXT: &[(&str, u32)] = &[ - ("grok-composer", 200_000), - ("grok-4.3", 1_000_000), - ("grok-build", 256_000), - ("grok-4.20", 1_000_000), -]; - -const API_KEY_CONTEXT: &[(&str, u32)] = &[("grok-4", 256_000), ("grok-3", 131_072)]; - -pub fn build(store: &CredentialStore, account: &str) -> XaiProvider { - enforce_https_host(BASE_URL, ALLOWED_HOST).expect("xai provider base URL"); - XaiProvider::new(store.clone(), CredentialKey::model(PROVIDER_ID, account)) -} - -enum XaiAuth { - ApiKey(String), - OAuth(String), -} - -pub struct XaiProvider { - store: CredentialStore, - key: CredentialKey, -} - -impl XaiProvider { - pub fn new(store: CredentialStore, key: CredentialKey) -> Self { - Self { store, key } - } - - async fn resolve_auth(&self) -> Option { - let cred = self.store.resolve(&self.key, Some("XAI_API_KEY"))?; - Self::auth_from_credential(&self.store, &self.key, cred).await - } - - async fn resolve_auth_for_model(&self, model: &str) -> Option { - if Self::is_oauth_model(model) - && let Some(cred @ Credential::OAuth(_)) = self.store.get(&self.key) - { - return Self::auth_from_credential(&self.store, &self.key, cred).await; - } - self.resolve_auth().await - } - - async fn auth_from_credential( - store: &CredentialStore, - key: &CredentialKey, - cred: Credential, - ) -> Option { - match cred { - Credential::ApiKey(secret) | Credential::ApiKeyWithEndpoint { secret, .. } => { - Some(XaiAuth::ApiKey(secret.expose().to_owned())) - } - Credential::OAuth(_) => oauth::current_oauth_token(store, key) - .await - .map(XaiAuth::OAuth), - } - } - - fn is_oauth_model(model: &str) -> bool { - OAUTH_CATALOG.contains(&model) - } - - fn chat_provider(bearer: String) -> OpenAiCompatProvider { - OpenAiCompatProvider::new( - ProviderId::from(PROVIDER_ID), - BASE_URL, - Some(bearer), - AuthMethod::ApiKeyOrOAuth, - ) - .with_catalog(API_KEY_CATALOG) - .with_context_windows(API_KEY_CONTEXT) - .with_vision_filter(vision_model) - .with_efforts(no_efforts) - .with_reasoning_effort(false) - } - - fn responses_provider(bearer: String) -> ResponsesProvider { - ResponsesProvider::new( - ProviderId::from(PROVIDER_ID), - BASE_URL, - Some(bearer), - AuthMethod::ApiKeyOrOAuth, - ) - .with_catalog(OAUTH_CATALOG) - .with_context_windows(OAUTH_CONTEXT) - .with_vision_filter(vision_model) - .with_model_filter(oauth_chat_model) - } - - async fn emit_models(provider: &XaiProvider, out: &mpsc::Sender) -> bool { - for id in provider.list_models() { - if out - .send(Model { - id: id.clone(), - supports_images: vision_model(&id), - }) - .await - .is_err() - { - return false; - } - } - true - } -} - -impl Provider for XaiProvider { - fn id(&self) -> ProviderId { - ProviderId::from(PROVIDER_ID) - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: true, - auth: AuthMethod::ApiKeyOrOAuth, - images: true, - } - } - - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata { - env_var: Some("XAI_API_KEY"), - validation: "network", - endpoint: None, - oauth: Some("browser or device code (SuperGrok / X Premium+)"), - login_endpoint: None, - setup: SETUP, - } - } - - fn authenticated(&self) -> bool { - self.store.resolve(&self.key, Some("XAI_API_KEY")).is_some() - } - - fn catalog(&self) -> &'static [&'static str] { - CATALOG - } - - fn list_models(&self) -> Vec { - match self.store.get(&self.key) { - Some(Credential::ApiKey(_) | Credential::ApiKeyWithEndpoint { .. }) => { - API_KEY_CATALOG.iter().map(|id| (*id).to_owned()).collect() - } - Some(Credential::OAuth(_)) => OAUTH_CATALOG.iter().map(|id| (*id).to_owned()).collect(), - None => CATALOG.iter().map(|id| (*id).to_owned()).collect(), - } - } - - fn efforts(&self, model: &str) -> Vec { - if Self::is_oauth_model(model) { - oauth_efforts(model) - } else { - no_efforts(model) - } - } - - fn context_window(&self, model: &str) -> Option { - if Self::is_oauth_model(model) { - OAUTH_CONTEXT - .iter() - .find_map(|(prefix, window)| model.starts_with(prefix).then_some(*window)) - } else { - API_KEY_CONTEXT - .iter() - .find_map(|(prefix, window)| model.starts_with(prefix).then_some(*window)) - } - } - - fn supports_images(&self, model: &str) -> bool { - vision_model(model) - } - - fn verifies_credentials(&self) -> bool { - true - } - - fn validate(&self) -> JoinHandle> { - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let provider = XaiProvider { store, key }; - let Some(auth) = provider.resolve_auth().await else { - return Err("no credentials".to_owned()); - }; - match auth { - XaiAuth::ApiKey(bearer) => XaiProvider::chat_provider(bearer) - .validate() - .await - .expect("validate panicked"), - XaiAuth::OAuth(bearer) => XaiProvider::responses_provider(bearer) - .validate() - .await - .expect("validate panicked"), - } - }) - } - - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { - let store = self.store.clone(); - let key = self.key.clone(); - let model = req.model.clone(); - tokio::spawn(async move { - let provider = XaiProvider { store, key }; - let Some(auth) = provider.resolve_auth_for_model(&model).await else { - let _ = events - .send(StreamEvent::Failed { - error: StreamError::auth("no credentials"), - }) - .await; - return; - }; - let handle = match auth { - XaiAuth::ApiKey(bearer) => XaiProvider::chat_provider(bearer).stream(req, events), - XaiAuth::OAuth(bearer) => { - if !XaiProvider::is_oauth_model(&model) - && API_KEY_CATALOG.contains(&model.as_str()) - { - let _ = events - .send(StreamEvent::Failed { - error: StreamError::invalid_request(format!( - "model {model} requires an xAI API key; OAuth supports {}", - OAUTH_CATALOG.join(", ") - )), - }) - .await; - return; - } - XaiProvider::responses_provider(bearer).stream(req, events) - } - }; - let _ = handle.await; - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - let store = self.store.clone(); - let key = self.key.clone(); - tokio::spawn(async move { - let provider = XaiProvider { store, key }; - let _ = XaiProvider::emit_models(&provider, &out).await; - }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - tokio::spawn(async move { oauth::login(&status).await.map_err(|err| err.to_string()) }) - } - - fn web_search(&self, query: String) -> JoinHandle> { - let _ = query; - tokio::spawn(async { Err(StreamError::other("web search is not supported")) }) - } -} - -fn oauth_chat_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - !(id.contains("embedding") - || id.contains("tts") - || id.contains("whisper") - || id.contains("image") - || id.contains("video")) -} - -fn oauth_efforts(model: &str) -> Vec { - let id = model.to_ascii_lowercase(); - if id.starts_with("grok-4.3") { - vec![Effort::Low, Effort::Medium, Effort::High] - } else { - Vec::new() - } -} - -fn vision_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - if id.starts_with("grok-composer") { - return false; - } - id.starts_with("grok-4") || id.contains("vision") -} - -#[cfg(test)] -mod tests { - use goat_auth::CredentialStore; - use goat_provider::{AuthMethod, Effort, Provider}; - - use super::*; - - fn store(name: &str) -> CredentialStore { - let _ = std::fs::remove_file(std::env::temp_dir().join(name)); - CredentialStore::new(std::env::temp_dir().join(name)) - } - - #[test] - fn xai_supports_api_key_and_oauth() { - let store = store("goat-provider-xai.json"); - let provider = build(&store, "default"); - assert_eq!(provider.capabilities().auth, AuthMethod::ApiKeyOrOAuth); - assert_eq!( - provider.metadata().oauth, - Some("browser or device code (SuperGrok / X Premium+)") - ); - assert!(!provider.authenticated()); - assert_eq!(provider.catalog(), CATALOG); - assert_eq!( - provider.context_window("grok-composer-2.5-fast"), - Some(200_000) - ); - assert_eq!(provider.context_window("grok-4.3"), Some(1_000_000)); - assert_eq!(provider.context_window("grok-4"), Some(256_000)); - assert!(!provider.supports_images("grok-composer-2.5-fast")); - assert_eq!( - provider.efforts("grok-4.3"), - vec![Effort::Low, Effort::Medium, Effort::High] - ); - } - - #[test] - fn list_models_follows_credential_kind() { - use goat_auth::{Credential, CredentialKey, SecretString, TokenSet}; - - let store = store("goat-provider-xai-list.json"); - let oauth = build(&store, "oauth"); - assert_eq!(oauth.list_models().len(), CATALOG.len()); - - store - .store( - &CredentialKey::model(PROVIDER_ID, "oauth"), - Credential::OAuth(TokenSet::from_parts( - "access".to_owned(), - Some("refresh".to_owned()), - Some(3600), - None, - )), - ) - .unwrap(); - let oauth = build(&store, "oauth"); - assert_eq!( - oauth.list_models(), - OAUTH_CATALOG - .iter() - .map(ToString::to_string) - .collect::>() - ); - - store - .store( - &CredentialKey::model(PROVIDER_ID, "api"), - Credential::ApiKey(SecretString::from("xai-key".to_owned())), - ) - .unwrap(); - let api = build(&store, "api"); - assert_eq!( - api.list_models(), - API_KEY_CATALOG - .iter() - .map(ToString::to_string) - .collect::>() - ); - } - - #[tokio::test] - async fn oauth_model_prefers_stored_oauth_for_composer() { - use goat_auth::{Credential, CredentialKey, TokenSet}; - - let store = store("goat-provider-xai-oauth-pref.json"); - store - .store( - &CredentialKey::model(PROVIDER_ID, "default"), - Credential::OAuth(TokenSet::from_parts( - "oauth-access".to_owned(), - None, - Some(3600), - None, - )), - ) - .unwrap(); - let provider = XaiProvider::new(store, CredentialKey::model(PROVIDER_ID, "default")); - let auth = provider - .resolve_auth_for_model("grok-composer-2.5-fast") - .await - .expect("oauth should win for composer"); - assert!(matches!(auth, XaiAuth::OAuth(token) if token == "oauth-access")); - } -} diff --git a/crates/goat-provider-xai/src/oauth.rs b/crates/goat-provider-xai/src/oauth.rs deleted file mode 100644 index 5455424..0000000 --- a/crates/goat-provider-xai/src/oauth.rs +++ /dev/null @@ -1,523 +0,0 @@ -use std::time::Duration; - -use goat_auth::{ - Credential, CredentialKey, CredentialStore, Pkce, TokenSet, capture_loopback_code, - ensure_valid, now_secs, random_state, -}; -use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}; -use serde::Deserialize; -use tokio::sync::mpsc; - -const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828"; -const SCOPE: &str = "openid profile email offline_access grok-cli:access api:access"; -const DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration"; -const CALLBACK_PORT: u16 = 56121; -const REDIRECT_URI: &str = "http://127.0.0.1:56121/callback"; -const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; -const LOGIN_TIMEOUT_SECS: i64 = 300; - -#[derive(Debug, thiserror::Error)] -pub enum XaiOAuthError { - #[error("http error: {0}")] - Http(#[from] reqwest::Error), - #[error("auth error: {0}")] - Auth(#[from] goat_auth::AuthError), - #[error("oauth error: {0}")] - OAuth(String), - #[error("no browser available")] - NoBrowser, -} - -struct OAuthDiscovery { - authorization_endpoint: String, - token_endpoint: String, -} - -struct DeviceDiscovery { - device_authorization_endpoint: String, - token_endpoint: String, -} - -#[derive(Deserialize)] -struct DiscoveryDocument { - #[serde(rename = "authorization_endpoint")] - authorization: Option, - #[serde(rename = "token_endpoint")] - token: Option, - #[serde(rename = "device_authorization_endpoint")] - device_authorization: Option, -} - -#[derive(Deserialize)] -struct DeviceAuthorizationResponse { - device_code: String, - user_code: String, - verification_uri: Option, - verification_uri_complete: Option, - expires_in: Option, - interval: Option, -} - -#[derive(Deserialize)] -struct TokenResponse { - access_token: String, - refresh_token: Option, - expires_in: Option, -} - -#[derive(Deserialize)] -struct OAuthErrorResponse { - error: Option, -} - -pub fn trusted_xai_host(endpoint: &str) -> bool { - let Ok(url) = reqwest::Url::parse(endpoint) else { - return false; - }; - if url.scheme() != "https" { - return false; - } - let Some(host) = url.host_str() else { - return false; - }; - host == "x.ai" || host.ends_with(".x.ai") -} - -fn require_trusted_endpoint(endpoint: &str, label: &str) -> Result { - if trusted_xai_host(endpoint) { - Ok(endpoint.to_owned()) - } else { - Err(XaiOAuthError::OAuth(format!( - "xAI OAuth discovery returned untrusted {label}" - ))) - } -} - -fn oauth_client() -> reqwest::Client { - reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .connect_timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .expect("reqwest client") -} - -fn oauth_headers() -> HeaderMap { - let mut headers = HeaderMap::new(); - headers.insert(ACCEPT, HeaderValue::from_static("application/json")); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("application/x-www-form-urlencoded"), - ); - let ua = format!("goat-code/{}", env!("CARGO_PKG_VERSION")); - if let Ok(value) = HeaderValue::from_str(&ua) { - headers.insert(USER_AGENT, value); - } - headers -} - -async fn fetch_discovery() -> Result { - let client = oauth_client(); - let response = client - .get(DISCOVERY_URL) - .headers(oauth_headers()) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(XaiOAuthError::OAuth(format!( - "OAuth discovery failed: {status}" - ))); - } - let doc: DiscoveryDocument = response.json().await?; - let authorization_endpoint = doc - .authorization - .ok_or_else(|| XaiOAuthError::OAuth("missing authorization_endpoint".to_owned()))?; - let token_endpoint = doc - .token - .ok_or_else(|| XaiOAuthError::OAuth("missing token_endpoint".to_owned()))?; - Ok(OAuthDiscovery { - authorization_endpoint: require_trusted_endpoint( - &authorization_endpoint, - "authorization endpoint", - )?, - token_endpoint: require_trusted_endpoint(&token_endpoint, "token endpoint")?, - }) -} - -async fn fetch_device_discovery() -> Result { - let client = oauth_client(); - let response = client - .get(DISCOVERY_URL) - .headers(oauth_headers()) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(XaiOAuthError::OAuth(format!( - "OAuth discovery failed: {status}" - ))); - } - let doc: DiscoveryDocument = response.json().await?; - let device_authorization_endpoint = doc - .device_authorization - .ok_or_else(|| XaiOAuthError::OAuth("missing device_authorization_endpoint".to_owned()))?; - let token_endpoint = doc - .token - .ok_or_else(|| XaiOAuthError::OAuth("missing token_endpoint".to_owned()))?; - Ok(DeviceDiscovery { - device_authorization_endpoint: require_trusted_endpoint( - &device_authorization_endpoint, - "device authorization endpoint", - )?, - token_endpoint: require_trusted_endpoint(&token_endpoint, "token endpoint")?, - }) -} - -pub fn build_authorize_url( - authorization_endpoint: &str, - challenge: &str, - state: &str, - nonce: &str, -) -> Result { - let endpoint = require_trusted_endpoint(authorization_endpoint, "authorization endpoint")?; - reqwest::Url::parse_with_params( - &endpoint, - &[ - ("response_type", "code"), - ("client_id", CLIENT_ID), - ("redirect_uri", REDIRECT_URI), - ("scope", SCOPE), - ("state", state), - ("nonce", nonce), - ("code_challenge", challenge), - ("code_challenge_method", "S256"), - ("plan", "generic"), - ("referrer", "goat-code"), - ], - ) - .map(|url| url.to_string()) - .map_err(|err| XaiOAuthError::OAuth(err.to_string())) -} - -fn random_nonce() -> String { - random_state() -} - -fn parse_token_response( - tokens: TokenResponse, - require_refresh: bool, -) -> Result { - if tokens.access_token.is_empty() { - return Err(XaiOAuthError::OAuth( - "token response is missing access_token".to_owned(), - )); - } - if require_refresh && tokens.refresh_token.as_deref().is_none_or(str::is_empty) { - return Err(XaiOAuthError::OAuth( - "token response is missing refresh_token".to_owned(), - )); - } - Ok(TokenSet::from_parts( - tokens.access_token, - tokens.refresh_token, - tokens.expires_in, - None, - )) -} - -async fn exchange_authorization_code( - token_endpoint: &str, - code: &str, - pkce: &Pkce, -) -> Result { - let endpoint = require_trusted_endpoint(token_endpoint, "token endpoint")?; - let client = oauth_client(); - let response = client - .post(endpoint) - .headers(oauth_headers()) - .form(&[ - ("grant_type", "authorization_code"), - ("code", code), - ("redirect_uri", REDIRECT_URI), - ("client_id", CLIENT_ID), - ("code_verifier", pkce.verifier.as_str()), - ("code_challenge", pkce.challenge.as_str()), - ("code_challenge_method", "S256"), - ]) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(XaiOAuthError::OAuth(format!( - "token exchange failed: {status}" - ))); - } - let tokens: TokenResponse = response.json().await?; - parse_token_response(tokens, true) -} - -pub async fn refresh_token(refresh_token: String) -> Result { - let discovery = fetch_discovery().await.map_err(|err| err.to_string())?; - let client = oauth_client(); - let response = client - .post(&discovery.token_endpoint) - .headers(oauth_headers()) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", CLIENT_ID), - ("refresh_token", refresh_token.as_str()), - ]) - .send() - .await - .map_err(|err| err.to_string())?; - let status = response.status(); - if !status.is_success() { - return Err(format!("token refresh failed: {status}")); - } - let tokens: TokenResponse = response.json().await.map_err(|err| err.to_string())?; - parse_token_response(tokens, false).map_err(|err| err.to_string()) -} - -pub async fn current_oauth_token(store: &CredentialStore, key: &CredentialKey) -> Option { - let Credential::OAuth(tokens) = store.get(key)? else { - return None; - }; - let tokens = ensure_valid(tokens, store, key, refresh_token).await?; - Some(tokens.access_token.expose().to_owned()) -} - -fn browser_available() -> bool { - if cfg!(any(target_os = "macos", target_os = "windows")) { - return true; - } - std::env::var_os("DISPLAY").is_some() || std::env::var_os("WAYLAND_DISPLAY").is_some() -} - -async fn login_browser(status: &mpsc::Sender) -> Result { - let discovery = fetch_discovery().await?; - let pkce = Pkce::generate(); - let state = random_state(); - let nonce = random_nonce(); - let url = build_authorize_url( - &discovery.authorization_endpoint, - &pkce.challenge, - &state, - &nonce, - )?; - let _ = status - .send(format!( - "opening browser to sign in\u{2026} if it does not open, visit:\n{url}" - )) - .await; - if open::that(&url).is_err() { - return Err(XaiOAuthError::NoBrowser); - } - let code = capture_loopback_code(CALLBACK_PORT, &state).await?; - exchange_authorization_code(&discovery.token_endpoint, &code, &pkce).await -} - -async fn request_device_authorization( - device_authorization_endpoint: &str, -) -> Result { - let endpoint = require_trusted_endpoint( - device_authorization_endpoint, - "device authorization endpoint", - )?; - let client = oauth_client(); - let response = client - .post(endpoint) - .headers(oauth_headers()) - .form(&[("client_id", CLIENT_ID), ("scope", SCOPE)]) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - return Err(XaiOAuthError::OAuth(format!( - "device authorization failed: {status}" - ))); - } - let device: DeviceAuthorizationResponse = response.json().await?; - if device.user_code.is_empty() || device.device_code.is_empty() { - return Err(XaiOAuthError::OAuth( - "device authorization response is missing required fields".to_owned(), - )); - } - Ok(device) -} - -pub fn valid_device_verification_url(url: &str) -> bool { - trusted_xai_host(url) -} - -async fn poll_device_token( - token_endpoint: &str, - device_code: &str, - expires_in: u64, - interval: u64, -) -> Result { - let endpoint = require_trusted_endpoint(token_endpoint, "token endpoint")?; - let client = oauth_client(); - let mut interval_secs = interval.max(1); - let deadline = now_secs() + i64::try_from(expires_in).unwrap_or(LOGIN_TIMEOUT_SECS); - loop { - if now_secs() > deadline { - return Err(XaiOAuthError::OAuth("device login timed out".to_owned())); - } - tokio::time::sleep(Duration::from_secs(interval_secs)).await; - let response = client - .post(&endpoint) - .headers(oauth_headers()) - .form(&[ - ("grant_type", DEVICE_GRANT_TYPE), - ("client_id", CLIENT_ID), - ("device_code", device_code), - ]) - .send() - .await?; - let status = response.status(); - if status.is_success() { - let tokens: TokenResponse = response.json().await?; - return parse_token_response(tokens, true); - } - let body: OAuthErrorResponse = response - .json() - .await - .unwrap_or(OAuthErrorResponse { error: None }); - match body.error.as_deref() { - Some("authorization_pending") => {} - Some("slow_down") => interval_secs = interval_secs.saturating_add(5), - Some("access_denied" | "authorization_denied") => { - return Err(XaiOAuthError::OAuth( - "device login access denied".to_owned(), - )); - } - Some("expired_token") => { - return Err(XaiOAuthError::OAuth("device login code expired".to_owned())); - } - Some(code) => { - return Err(XaiOAuthError::OAuth(format!( - "device token polling failed: {code}" - ))); - } - None => { - return Err(XaiOAuthError::OAuth(format!( - "device token polling failed: {status}" - ))); - } - } - } -} - -async fn login_device(status: &mpsc::Sender) -> Result { - let discovery = fetch_device_discovery().await?; - let device = request_device_authorization(&discovery.device_authorization_endpoint).await?; - let url = device - .verification_uri_complete - .as_deref() - .or(device.verification_uri.as_deref()) - .unwrap_or_default(); - if !valid_device_verification_url(url) { - return Err(XaiOAuthError::OAuth( - "device authorization returned an invalid verification URL".to_owned(), - )); - } - let _ = open::that(url); - let _ = status - .send(format!("open {url} and enter code: {}", device.user_code)) - .await; - poll_device_token( - &discovery.token_endpoint, - &device.device_code, - device.expires_in.unwrap_or(900), - device.interval.unwrap_or(5), - ) - .await -} - -pub async fn login(status: &mpsc::Sender) -> Result { - if browser_available() { - match login_browser(status).await { - Err(XaiOAuthError::NoBrowser) => login_device(status).await, - other => other, - } - } else { - login_device(status).await - } -} - -#[cfg(test)] -mod tests { - use super::{ - CLIENT_ID, REDIRECT_URI, SCOPE, build_authorize_url, parse_token_response, - trusted_xai_host, valid_device_verification_url, - }; - - #[test] - fn authorize_url_contains_required_params() { - let url = build_authorize_url( - "https://auth.x.ai/oauth2/authorize", - "challenge", - "state-value", - "nonce-value", - ) - .unwrap(); - let parsed = reqwest::Url::parse(&url).unwrap(); - assert_eq!(parsed.origin().ascii_serialization(), "https://auth.x.ai"); - let pairs: std::collections::HashMap<_, _> = parsed.query_pairs().collect(); - let value = |key: &str| pairs.get(key).map(|value| value.as_ref().to_owned()); - assert_eq!(value("client_id"), Some(CLIENT_ID.to_owned())); - assert_eq!(value("redirect_uri"), Some(REDIRECT_URI.to_owned())); - assert_eq!(value("scope"), Some(SCOPE.to_owned())); - assert_eq!(value("code_challenge"), Some("challenge".to_owned())); - assert_eq!(value("state"), Some("state-value".to_owned())); - assert_eq!(value("nonce"), Some("nonce-value".to_owned())); - assert_eq!(value("referrer"), Some("goat-code".to_owned())); - } - - #[test] - fn rejects_untrusted_authorize_host() { - let err = build_authorize_url( - "https://evil.example/oauth2/authorize", - "challenge", - "state", - "nonce", - ) - .unwrap_err() - .to_string(); - assert!(err.contains("untrusted")); - } - - #[test] - fn trusted_xai_hosts() { - assert!(trusted_xai_host("https://auth.x.ai/oauth2/authorize")); - assert!(trusted_xai_host("https://accounts.x.ai/sign-in")); - assert!(!trusted_xai_host("https://evil.example/oauth")); - assert!(!trusted_xai_host("http://auth.x.ai/oauth")); - } - - #[test] - fn validates_device_verification_url() { - assert!(valid_device_verification_url( - "https://accounts.x.ai/device?code=abc" - )); - assert!(!valid_device_verification_url( - "https://example.com/device?code=abc" - )); - } - - #[test] - fn token_parse_does_not_leak_secrets() { - let err = parse_token_response( - super::TokenResponse { - access_token: String::new(), - refresh_token: Some("refresh-secret".to_owned()), - expires_in: Some(3600), - }, - true, - ) - .unwrap_err() - .to_string(); - assert!(!err.contains("refresh-secret")); - } -} diff --git a/crates/goat-provider-zai-coding/Cargo.toml b/crates/goat-provider-zai-coding/Cargo.toml deleted file mode 100644 index 55c6f20..0000000 --- a/crates/goat-provider-zai-coding/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-zai-coding" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-zai-coding/src/lib.rs b/crates/goat-provider-zai-coding/src/lib.rs deleted file mode 100644 index d39384c..0000000 --- a/crates/goat-provider-zai-coding/src/lib.rs +++ /dev/null @@ -1,96 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider::{Effort, ProviderMetadata}; -use goat_provider_openai_compat::{ - ChatDiscovery, ChatValidation, OpenAiCompatProvider, api_key, no_vision, -}; - -pub const PROVIDER_ID: &str = "zai-coding"; - -const BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; -const HOST: &str = "api.z.ai"; -const ENV_VAR: &str = "ZAI_CODING_API_KEY"; - -const ZAI_CODING_SETUP: &[&str] = &[ - "Z.AI Coding Plan API-key provider.", - "Use `ZAI_CODING_API_KEY` or `goat-code provider login zai-coding --key sk-...`.", - "This is not OAuth and does not reuse the standard `zai` credential.", -]; - -const CATALOG: &[&str] = &["glm-5.2", "glm-5.1", "glm-5-turbo", "glm-4.7"]; - -const CONTEXT: &[(&str, u32)] = &[ - ("glm-5.2", 1_000_000), - ("glm-5.1", 198_000), - ("glm-5-turbo", 128_000), - ("glm-4.7", 128_000), -]; - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT) - .with_vision_filter(no_vision) - .with_efforts(zai_efforts) - .with_effort_wire(zai_effort_wire) - .with_validation(ChatValidation::CatalogOnly) - .with_discovery(ChatDiscovery::CatalogOnly) - .with_metadata(ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "catalog-only", - endpoint: Some(BASE_URL), - oauth: Some("not OAuth; uses Z.AI Coding Plan API key"), - login_endpoint: None, - setup: ZAI_CODING_SETUP, - }) -} - -fn zai_efforts(model: &str) -> Vec { - if model == "glm-5.2" { - vec![ - Effort::Off, - Effort::Low, - Effort::Medium, - Effort::High, - Effort::Xhigh, - Effort::Max, - ] - } else { - Vec::new() - } -} - -fn zai_effort_wire(effort: Effort) -> Option<&'static str> { - let wire = match effort { - Effort::Off => "none", - Effort::Low => "low", - Effort::Medium => "medium", - Effort::High => "high", - Effort::Xhigh => "xhigh", - Effort::Max => "max", - }; - (!wire.is_empty()).then_some(wire) -} - -#[cfg(test)] -mod tests { - use goat_auth::CredentialStore; - use goat_provider::{AuthMethod, Provider}; - - use super::*; - - fn store(name: &str) -> CredentialStore { - let _ = std::fs::remove_file(std::env::temp_dir().join(name)); - CredentialStore::new(std::env::temp_dir().join(name)) - } - - #[test] - fn zai_coding_is_distinct_api_key_provider() { - let store = store("goat-provider-zai-coding.json"); - let provider = build(&store, "default"); - assert_eq!(provider.capabilities().auth, AuthMethod::ApiKey); - assert_eq!(provider.metadata().env_var, Some("ZAI_CODING_API_KEY")); - assert_eq!(provider.metadata().endpoint, Some(BASE_URL)); - assert_eq!(provider.catalog(), CATALOG); - assert_eq!(provider.context_window("glm-5.2"), Some(1_000_000)); - } -} diff --git a/crates/goat-provider-zai/Cargo.toml b/crates/goat-provider-zai/Cargo.toml deleted file mode 100644 index 7dbb0b5..0000000 --- a/crates/goat-provider-zai/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-provider-zai" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-provider = { workspace = true } -goat-provider-openai-compat = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider-zai/src/lib.rs b/crates/goat-provider-zai/src/lib.rs deleted file mode 100644 index 221ab57..0000000 --- a/crates/goat-provider-zai/src/lib.rs +++ /dev/null @@ -1,122 +0,0 @@ -use goat_auth::CredentialStore; -use goat_provider::{Effort, ProviderMetadata}; -use goat_provider_openai_compat::{ChatDiscovery, ChatValidation, OpenAiCompatProvider, api_key}; - -pub const PROVIDER_ID: &str = "zai"; - -const BASE_URL: &str = "https://api.z.ai/api/paas/v4"; -const HOST: &str = "api.z.ai"; -const ENV_VAR: &str = "ZAI_API_KEY"; - -const CATALOG: &[&str] = &[ - "glm-5.2", - "glm-5.1", - "glm-5-turbo", - "glm-5", - "glm-4.7", - "glm-4.7-flash", - "glm-4.6", - "glm-4.5", - "glm-4.5-air", - "glm-4-32b-0414-128k", - "glm-5v-turbo", -]; - -const CONTEXT: &[(&str, u32)] = &[ - ("glm-5.2", 128_000), - ("glm-5.1", 128_000), - ("glm-5", 128_000), - ("glm-4.7", 128_000), - ("glm-4.6", 128_000), - ("glm-4.5", 128_000), - ("glm-4-32b", 128_000), - ("glm-5v", 128_000), -]; - -pub fn build(store: &CredentialStore, account: &str) -> OpenAiCompatProvider { - api_key(store, account, PROVIDER_ID, BASE_URL, HOST, ENV_VAR) - .with_catalog(CATALOG) - .with_context_windows(CONTEXT) - .with_vision_filter(zai_vision_model) - .with_efforts(zai_efforts) - .with_effort_wire(zai_effort_wire) - .with_validation(ChatValidation::CatalogOnly) - .with_discovery(ChatDiscovery::CatalogOnly) - .with_metadata(ProviderMetadata { - env_var: Some(ENV_VAR), - validation: "catalog-only", - endpoint: None, - oauth: Some("not supported by Z.AI API docs"), - login_endpoint: None, - setup: &[], - }) -} - -fn zai_vision_model(id: &str) -> bool { - let id = id.to_ascii_lowercase(); - id.contains("glm-5v") - || id.contains("glm-4.6v") - || id.contains("glm-4.5v") - || id.contains("glm-4v") - || id.contains("vision") -} - -fn zai_efforts(model: &str) -> Vec { - if model == "glm-5.2" { - vec![ - Effort::Off, - Effort::Low, - Effort::Medium, - Effort::High, - Effort::Xhigh, - Effort::Max, - ] - } else { - Vec::new() - } -} - -fn zai_effort_wire(effort: Effort) -> Option<&'static str> { - let wire = match effort { - Effort::Off => "none", - Effort::Low => "low", - Effort::Medium => "medium", - Effort::High => "high", - Effort::Xhigh => "xhigh", - Effort::Max => "max", - }; - (!wire.is_empty()).then_some(wire) -} - -#[cfg(test)] -mod tests { - use goat_auth::CredentialStore; - use goat_provider::{Effort, Provider}; - - use super::*; - - fn store(name: &str) -> CredentialStore { - let _ = std::fs::remove_file(std::env::temp_dir().join(name)); - CredentialStore::new(std::env::temp_dir().join(name)) - } - - #[test] - fn metadata_is_exposed() { - let store = store("goat-provider-zai-metadata.json"); - let provider = build(&store, "default"); - assert_eq!(provider.catalog(), CATALOG); - assert_eq!(provider.context_window("glm-5.2"), Some(128_000)); - assert_eq!( - provider.efforts("glm-5.2"), - vec![ - Effort::Off, - Effort::Low, - Effort::Medium, - Effort::High, - Effort::Xhigh, - Effort::Max - ] - ); - assert!(!provider.verifies_credentials()); - } -} diff --git a/crates/goat-provider/Cargo.toml b/crates/goat-provider/Cargo.toml deleted file mode 100644 index d3fd16a..0000000 --- a/crates/goat-provider/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "goat-provider" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-protocol = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -thiserror = { workspace = true } -tokio = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-provider/src/lib.rs b/crates/goat-provider/src/lib.rs deleted file mode 100644 index fabcb92..0000000 --- a/crates/goat-provider/src/lib.rs +++ /dev/null @@ -1,534 +0,0 @@ -use serde::{Deserialize, Deserializer, Serialize}; -use tokio::{sync::mpsc, task::JoinHandle}; - -pub use goat_auth::{TokenSet, now_secs}; -pub use goat_protocol::{AuthMethod, Effort, RateLimitSnapshot, RateWindow, Usage}; - -use std::fmt; -use std::fmt::Write as _; - -fn deser_tool_result_content<'de, D>(d: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - use serde::de::Error as _; - let v = serde_json::Value::deserialize(d)?; - match v { - serde_json::Value::String(s) => Ok(vec![ContentBlock::Text { text: s }]), - serde_json::Value::Array(arr) => arr - .into_iter() - .map(|item| serde_json::from_value(item).map_err(D::Error::custom)) - .collect(), - other => Err(D::Error::custom(format!( - "expected string or array for tool_result content, got {other}" - ))), - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ProviderId(pub String); - -impl fmt::Display for ProviderId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl From<&str> for ProviderId { - fn from(value: &str) -> Self { - Self(value.to_owned()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MessageRole { - System, - User, - Assistant, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum ContentBlock { - Text { - text: String, - }, - Thinking { - text: String, - signature: String, - }, - RedactedThinking { - data: String, - }, - ToolUse { - id: String, - name: String, - input: serde_json::Value, - }, - ToolResult { - tool_use_id: String, - #[serde(deserialize_with = "deser_tool_result_content")] - content: Vec, - is_error: bool, - }, - Image { - media_type: String, - data: String, - }, -} - -impl ContentBlock { - pub fn text_result( - tool_use_id: impl Into, - text: impl Into, - is_error: bool, - ) -> Self { - Self::ToolResult { - tool_use_id: tool_use_id.into(), - content: vec![Self::Text { text: text.into() }], - is_error, - } - } - - pub fn tool_result_text(content: &[ContentBlock]) -> String { - content - .iter() - .filter_map(|b| match b { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n") - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Message { - pub role: MessageRole, - pub content: Vec, -} - -impl Message { - pub fn text(role: MessageRole, text: impl Into) -> Self { - Self { - role, - content: vec![ContentBlock::Text { text: text.into() }], - } - } - - pub fn text_content(&self) -> String { - self.content - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n") - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Model { - pub id: String, - #[serde(default)] - pub supports_images: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolChoice { - #[default] - Auto, - None, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Request { - pub model: String, - pub messages: Vec, - #[serde(default)] - pub tools: Vec, - #[serde(default)] - pub effort: Option, - #[serde(default)] - pub tool_choice: ToolChoice, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct Capabilities { - pub tools: bool, - pub auth: AuthMethod, - pub images: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SearchResult { - pub title: String, - pub url: String, - pub snippet: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WebSearchOutput { - pub content: String, - pub results: Vec, -} - -impl WebSearchOutput { - #[must_use] - pub fn from_results(results: Vec) -> Self { - Self { - content: format_search_results(&results), - results, - } - } -} - -#[must_use] -pub fn format_search_results(results: &[SearchResult]) -> String { - if results.is_empty() { - return "No results found.".to_owned(); - } - let mut out = String::new(); - for (index, result) in results.iter().enumerate() { - let title = if result.title.is_empty() { - &result.url - } else { - &result.title - }; - let _ = write!(out, "{}. {title}\n {}", index + 1, result.url); - if !result.snippet.is_empty() { - let _ = write!(out, " · {}", result.snippet); - } - out.push('\n'); - } - out -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum StreamEvent { - TextDelta { - text: String, - }, - ThinkingDelta { - text: String, - }, - ThinkingSignature { - signature: String, - }, - RedactedThinking { - data: String, - }, - ToolCall { - id: String, - name: String, - input: String, - }, - Completed, - Failed { - error: StreamError, - }, - Usage { - usage: Usage, - }, - RateLimits { - snapshot: RateLimitSnapshot, - }, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, thiserror::Error)] -pub enum StreamError { - #[error("rate limited: {message}")] - RateLimited { - retry_after: Option, - message: String, - }, - #[error("provider overloaded: {message}")] - Overloaded { message: String }, - #[error("context window exceeded: {message}")] - ContextOverflow { message: String }, - #[error("authentication failed: {message}")] - Auth { message: String }, - #[error("invalid request: {message}")] - InvalidRequest { message: String }, - #[error("connection failed: {message}")] - Transport { message: String }, - #[error("{message}")] - Other { message: String }, -} - -impl StreamError { - pub fn rate_limited( - message: impl Into, - retry_after: Option, - ) -> Self { - Self::RateLimited { - retry_after, - message: message.into(), - } - } - - pub fn overloaded(message: impl Into) -> Self { - Self::Overloaded { - message: message.into(), - } - } - - pub fn context_overflow(message: impl Into) -> Self { - Self::ContextOverflow { - message: message.into(), - } - } - - pub fn auth(message: impl Into) -> Self { - Self::Auth { - message: message.into(), - } - } - - pub fn invalid_request(message: impl Into) -> Self { - Self::InvalidRequest { - message: message.into(), - } - } - - pub fn transport(message: impl Into) -> Self { - Self::Transport { - message: message.into(), - } - } - - pub fn other(message: impl Into) -> Self { - Self::Other { - message: message.into(), - } - } -} - -pub type EndpointValidator = fn(&str) -> Result; - -#[derive(Debug, Clone, Copy)] -pub struct LoginEndpointMetadata { - pub env_var: Option<&'static str>, - pub default: Option<&'static str>, - pub validate: Option, -} - -#[derive(Debug, Clone, Copy)] -pub struct ProviderMetadata { - pub env_var: Option<&'static str>, - pub validation: &'static str, - pub endpoint: Option<&'static str>, - pub oauth: Option<&'static str>, - pub login_endpoint: Option, - pub setup: &'static [&'static str], -} - -impl ProviderMetadata { - pub const fn default() -> Self { - Self { - env_var: None, - validation: "network", - endpoint: None, - oauth: None, - login_endpoint: None, - setup: &[], - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModelListSource { - Catalog, - Discover, -} - -pub trait Provider: Send + Sync + 'static { - fn id(&self) -> ProviderId; - fn capabilities(&self) -> Capabilities; - fn metadata(&self) -> ProviderMetadata { - ProviderMetadata::default() - } - fn stream(&self, req: Request, tx: mpsc::Sender) -> JoinHandle<()>; - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()>; - fn catalog(&self) -> &'static [&'static str] { - &[] - } - - fn model_list_source(&self) -> ModelListSource { - if self.catalog().is_empty() { - ModelListSource::Discover - } else { - ModelListSource::Catalog - } - } - - fn list_models(&self) -> Vec { - self.catalog().iter().map(|id| (*id).to_owned()).collect() - } - - fn efforts(&self, _model: &str) -> Vec { - Vec::new() - } - fn authenticated(&self) -> bool { - true - } - fn validate(&self) -> JoinHandle> { - tokio::spawn(async { Ok(()) }) - } - - fn verifies_credentials(&self) -> bool { - true - } - - fn context_window(&self, _model: &str) -> Option { - None - } - - fn supports_images(&self, _model: &str) -> bool { - self.capabilities().images - } - - fn supports_web_search(&self) -> bool { - false - } - - fn web_search(&self, query: String) -> JoinHandle> { - let _ = query; - tokio::spawn(async { Err(StreamError::other("web search is not supported")) }) - } - - fn login(&self, status: mpsc::Sender) -> JoinHandle> { - let _ = status; - tokio::spawn(async { Err("login not supported".into()) }) - } -} - -#[cfg(test)] -mod tests { - use tokio::{sync::mpsc, task::JoinHandle}; - - use super::{ - AuthMethod, Capabilities, Message, MessageRole, Model, Provider, ProviderId, Request, - StreamEvent, - }; - - struct MockProvider; - - impl Provider for MockProvider { - fn id(&self) -> ProviderId { - ProviderId::from("mock") - } - - fn capabilities(&self) -> Capabilities { - Capabilities { - tools: false, - auth: AuthMethod::None, - images: false, - } - } - - fn stream(&self, _req: Request, tx: mpsc::Sender) -> JoinHandle<()> { - tokio::spawn(async move { - let _ = tx.send(StreamEvent::TextDelta { text: "hi".into() }).await; - let _ = tx.send(StreamEvent::Completed).await; - }) - } - - fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { - tokio::spawn(async move { - let _ = out - .send(Model { - id: "mock-1".into(), - supports_images: false, - }) - .await; - }) - } - } - - #[tokio::test] - async fn mock_provider_streams_events() { - let provider = MockProvider; - assert_eq!(provider.id(), ProviderId::from("mock")); - assert!(!provider.capabilities().tools); - let (tx, mut rx) = mpsc::channel(8); - let handle = provider.stream( - Request { - model: "mock-1".into(), - messages: vec![Message::text(MessageRole::User, "hi")], - tools: vec![], - effort: None, - tool_choice: super::ToolChoice::Auto, - }, - tx, - ); - let mut events = Vec::new(); - while let Some(ev) = rx.recv().await { - events.push(ev); - } - handle.await.unwrap(); - assert_eq!( - events, - vec![ - StreamEvent::TextDelta { text: "hi".into() }, - StreamEvent::Completed - ] - ); - } - - #[tokio::test] - async fn mock_provider_discovers_models() { - let provider = MockProvider; - let (tx, mut rx) = mpsc::channel(8); - let handle = provider.discover(tx); - let info = rx.recv().await.unwrap(); - assert_eq!(info.id, "mock-1"); - handle.await.unwrap(); - } - - #[test] - fn content_blocks_round_trip_through_json() { - use super::ContentBlock; - let blocks = vec![ - ContentBlock::Text { - text: "hello".into(), - }, - ContentBlock::Thinking { - text: "step one".into(), - signature: "sig-abc".into(), - }, - ContentBlock::RedactedThinking { - data: "opaque".into(), - }, - ContentBlock::ToolUse { - id: "call-1".into(), - name: "Read".into(), - input: serde_json::json!({"path": "src/lib.rs"}), - }, - ContentBlock::ToolResult { - tool_use_id: "call-1".into(), - content: vec![ContentBlock::Text { - text: "result".into(), - }], - is_error: false, - }, - ContentBlock::Image { - media_type: "image/png".into(), - data: "base64data".into(), - }, - ]; - let json = serde_json::to_string(&blocks).unwrap(); - let restored: Vec = serde_json::from_str(&json).unwrap(); - assert_eq!(restored, blocks); - } -} diff --git a/crates/goat-providers/Cargo.toml b/crates/goat-providers/Cargo.toml deleted file mode 100644 index f67c6cc..0000000 --- a/crates/goat-providers/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "goat-providers" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-provider = { workspace = true } -goat-auth = { workspace = true } -tokio = { workspace = true } -goat-provider-openai = { workspace = true } -goat-provider-openai-codex = { workspace = true } -goat-provider-anthropic = { workspace = true } -goat-provider-gemini = { workspace = true } -goat-provider-local = { workspace = true } -goat-provider-openrouter = { workspace = true } -goat-provider-groq = { workspace = true } -goat-provider-deepseek = { workspace = true } -goat-provider-mistral = { workspace = true } -goat-provider-zai = { workspace = true } -goat-provider-zai-coding = { workspace = true } -goat-provider-kimi = { workspace = true } -goat-provider-kimi-code = { workspace = true } -goat-provider-qwen = { workspace = true } -goat-provider-xai = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-providers/src/lib.rs b/crates/goat-providers/src/lib.rs deleted file mode 100644 index eafe770..0000000 --- a/crates/goat-providers/src/lib.rs +++ /dev/null @@ -1,100 +0,0 @@ -use std::sync::Arc; - -use goat_auth::{CredentialStore, TokenSet}; -use goat_provider::{Provider, ProviderId}; - -pub const DEFAULT_ACCOUNT: &str = "default"; - -pub struct Registry { - providers: Vec>, -} - -impl Registry { - pub fn new(store: &CredentialStore) -> Self { - Self::load(store, DEFAULT_ACCOUNT) - } - - pub fn load(store: &CredentialStore, account: &str) -> Self { - let providers: Vec> = vec![ - Arc::new(goat_provider_openai::build(store, account)), - Arc::new(goat_provider_openai_codex::build(store, account)), - Arc::new(goat_provider_anthropic::build(store, account)), - Arc::new(goat_provider_gemini::build(store, account)), - Arc::new(goat_provider_openrouter::build(store, account)), - Arc::new(goat_provider_groq::build(store, account)), - Arc::new(goat_provider_deepseek::build(store, account)), - Arc::new(goat_provider_xai::build(store, account)), - Arc::new(goat_provider_mistral::build(store, account)), - Arc::new(goat_provider_zai::build(store, account)), - Arc::new(goat_provider_zai_coding::build(store, account)), - Arc::new(goat_provider_kimi::build(store, account)), - Arc::new(goat_provider_kimi_code::build(store, account)), - Arc::new(goat_provider_qwen::build(store, account)), - Arc::new(goat_provider_local::ollama()), - Arc::new(goat_provider_local::lmstudio()), - Arc::new(goat_provider_local::llama_cpp()), - ]; - Self { providers } - } - - pub fn from_providers(providers: Vec>) -> Self { - Self { providers } - } - - pub fn get(&self, id: &ProviderId) -> Option> { - self.providers.iter().find(|p| &p.id() == id).cloned() - } - - pub fn all(&self) -> &[Arc] { - &self.providers - } - - pub async fn login( - &self, - provider: &str, - status: tokio::sync::mpsc::Sender, - ) -> Result { - let p = self - .get(&ProviderId::from(provider)) - .ok_or_else(|| format!("unknown provider: {provider}"))?; - p.login(status) - .await - .unwrap_or_else(|err| Err(err.to_string())) - } -} - -#[cfg(test)] -mod tests { - use goat_provider::{AuthMethod, ProviderId}; - - use super::Registry; - - #[test] - fn builtin_registers_known_providers() { - let store = goat_auth::CredentialStore::new( - std::env::temp_dir().join("goat-providers-registry-test.json"), - ); - let registry = Registry::new(&store); - assert_eq!(registry.all().len(), 17); - assert!(registry.get(&ProviderId::from("anthropic")).is_some()); - assert!(registry.get(&ProviderId::from("openrouter")).is_some()); - assert!(registry.get(&ProviderId::from("groq")).is_some()); - assert!(registry.get(&ProviderId::from("deepseek")).is_some()); - let xai = registry - .get(&ProviderId::from("xai")) - .expect("xai provider"); - assert_eq!(xai.capabilities().auth, AuthMethod::ApiKeyOrOAuth); - assert_eq!( - xai.metadata().oauth, - Some("browser or device code (SuperGrok / X Premium+)") - ); - assert!(registry.get(&ProviderId::from("mistral")).is_some()); - assert!(registry.get(&ProviderId::from("zai")).is_some()); - assert!(registry.get(&ProviderId::from("zai-coding")).is_some()); - assert!(registry.get(&ProviderId::from("kimi")).is_some()); - assert!(registry.get(&ProviderId::from("kimi-code")).is_some()); - assert!(registry.get(&ProviderId::from("qwen")).is_some()); - assert!(registry.get(&ProviderId::from("ollama")).is_some()); - assert!(registry.get(&ProviderId::from("does-not-exist")).is_none()); - } -} diff --git a/crates/goat-sandbox/src/lib.rs b/crates/goat-sandbox/src/lib.rs index 3dfc9ae..12c86c8 100644 --- a/crates/goat-sandbox/src/lib.rs +++ b/crates/goat-sandbox/src/lib.rs @@ -54,7 +54,7 @@ mod backend { const SECRET_SUBDIRS: [&str; 9] = [ ".ssh", - ".goat-code", + ".goat", ".aws", ".gnupg", ".config/gcloud", @@ -137,7 +137,7 @@ mod backend { const SECRET_SUBDIRS: [&str; 9] = [ ".ssh", - ".goat-code", + ".goat", ".aws", ".gnupg", ".config/gcloud", @@ -269,7 +269,7 @@ mod tests { assert!(profile.contains("(allow file-read*)")); assert!(profile.contains(&format!("(deny file-read* (subpath \"{home}/.ssh\"))"))); assert!(profile.contains(&format!( - "(deny file-read* (subpath \"{home}/.goat-code\"))" + "(deny file-read* (subpath \"{home}/.goat\"))" ))); assert!(profile.contains(&format!("(deny file-read* (literal \"{home}/.netrc\"))"))); assert!(!profile.contains("/work")); diff --git a/crates/goat-search-provider-brave/Cargo.toml b/crates/goat-search-provider-brave/Cargo.toml deleted file mode 100644 index 9df8156..0000000 --- a/crates/goat-search-provider-brave/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "goat-search-provider-brave" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-search-provider = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-provider-brave/src/lib.rs b/crates/goat-search-provider-brave/src/lib.rs deleted file mode 100644 index 15284a9..0000000 --- a/crates/goat-search-provider-brave/src/lib.rs +++ /dev/null @@ -1,116 +0,0 @@ -use std::time::Duration; - -use goat_auth::CredentialStore; -use goat_search_provider::{ - SearchCredentialMetadata, SearchFuture, SearchProvider, SearchProviderKind, - SearchProviderMetadata, SearchRequest, SearchResult, SearchTarget, build_query, clean_text, - client, finish_results, search_secret, strip_tags, -}; -use reqwest::Url; - -const SEARCH_TIMEOUT: Duration = Duration::from_secs(15); -const ENV_VAR: &str = "BRAVE_API_KEY"; - -pub fn metadata() -> SearchProviderMetadata { - SearchProviderMetadata { - id: "brave", - default_account: "default", - kind: SearchProviderKind::Brave, - credential: SearchCredentialMetadata::EnvApiKey { env_var: ENV_VAR }, - setup: "set BRAVE_API_KEY or run `goat-code search login brave --key `", - builtins: &[], - } -} - -pub struct BraveProvider { - account: String, -} - -impl BraveProvider { - pub fn new(account: impl Into) -> Self { - Self { - account: account.into(), - } - } -} - -impl SearchProvider for BraveProvider { - fn metadata(&self) -> SearchProviderMetadata { - metadata() - } - - fn target(&self) -> SearchTarget { - SearchTarget { - provider: "brave".to_owned(), - account: self.account.clone(), - } - } - - fn search<'a>( - &'a self, - request: SearchRequest, - credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a> { - Box::pin(async move { - let query = build_query(&request)?; - let token = search_secret(credentials, "brave", &self.account, Some(ENV_VAR))?; - let mut url = Url::parse("https://api.search.brave.com/res/v1/web/search") - .map_err(|err| goat_search_provider::SearchError::Url(err.to_string()))?; - url.query_pairs_mut() - .append_pair("q", &query) - .append_pair("count", &request.max_results.to_string()); - if let Some(language) = &request.language { - url.query_pairs_mut().append_pair("search_lang", language); - } - let value = client(SEARCH_TIMEOUT)? - .get(url) - .header("X-Subscription-Token", token) - .header("Accept", "application/json") - .send() - .await - .map_err(goat_search_provider::SearchError::Request)? - .json::() - .await - .map_err(goat_search_provider::SearchError::Request)?; - let results = parse_brave_json(&value, request.max_results); - Ok(finish_results( - self.target(), - query, - request, - vec!["brave_api"], - results, - )) - }) - } -} - -pub fn parse_brave_json(value: &serde_json::Value, max_results: usize) -> Vec { - value - .get("web") - .and_then(|web| web.get("results")) - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .take(max_results) - .enumerate() - .filter_map(|(index, item)| { - let title = item.get("title").and_then(serde_json::Value::as_str)?; - let url = item.get("url").and_then(serde_json::Value::as_str)?; - let snippet = item - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - Some(SearchResult { - title: clean_text(&strip_tags(title)), - url: url.to_owned(), - snippet: clean_text(&strip_tags(snippet)), - rank: index + 1, - provider: String::new(), - published_at: item - .get("age") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - }) - }) - .collect() -} diff --git a/crates/goat-search-provider-duckduckgo/Cargo.toml b/crates/goat-search-provider-duckduckgo/Cargo.toml deleted file mode 100644 index 64dd204..0000000 --- a/crates/goat-search-provider-duckduckgo/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "goat-search-provider-duckduckgo" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-search-provider = { workspace = true } -chromiumoxide = { workspace = true } -futures = { workspace = true } -tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt"] } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-provider-duckduckgo/src/lib.rs b/crates/goat-search-provider-duckduckgo/src/lib.rs deleted file mode 100644 index 47f96d1..0000000 --- a/crates/goat-search-provider-duckduckgo/src/lib.rs +++ /dev/null @@ -1,348 +0,0 @@ -use std::time::Duration; - -use chromiumoxide::cdp::browser_protocol::page::StopLoadingParams; -use chromiumoxide::handler::viewport::Viewport; -use chromiumoxide::{Browser, BrowserConfig}; -use futures::StreamExt as _; -use goat_auth::CredentialStore; -use goat_search_provider::{ - SearchBuiltinTarget, SearchCredentialMetadata, SearchFuture, SearchProvider, - SearchProviderKind, SearchProviderMetadata, SearchRequest, SearchResult, SearchTarget, - build_query, clean_text, client, decode_entities, finish_results, strip_tags, -}; -use reqwest::Url; - -const SEARCH_TIMEOUT: Duration = Duration::from_secs(15); -const BROWSER_SEARCH_TIMEOUT: Duration = Duration::from_secs(25); - -const BROWSER_BUILTINS: &[SearchBuiltinTarget] = &[SearchBuiltinTarget { - provider: "browser", - account: "duckduckgo", - target: "browser/duckduckgo", - kind: "built-in", - setup: "no setup required", -}]; - -const DUCKDUCKGO_BUILTINS: &[SearchBuiltinTarget] = &[SearchBuiltinTarget { - provider: "duckduckgo", - account: "html", - target: "duckduckgo/html", - kind: "built-in", - setup: "no setup required", -}]; - -pub fn browser_metadata() -> SearchProviderMetadata { - SearchProviderMetadata { - id: "browser", - default_account: "duckduckgo", - kind: SearchProviderKind::Browser { - default_engine: "duckduckgo", - }, - credential: SearchCredentialMetadata::None, - setup: "no setup required", - builtins: BROWSER_BUILTINS, - } -} - -pub fn duckduckgo_metadata() -> SearchProviderMetadata { - SearchProviderMetadata { - id: "duckduckgo", - default_account: "html", - kind: SearchProviderKind::Duckduckgo, - credential: SearchCredentialMetadata::None, - setup: "no setup required", - builtins: DUCKDUCKGO_BUILTINS, - } -} - -pub struct BrowserDuckDuckGoProvider { - account: String, -} - -impl BrowserDuckDuckGoProvider { - pub fn new(account: impl Into) -> Self { - Self { - account: account.into(), - } - } -} - -impl SearchProvider for BrowserDuckDuckGoProvider { - fn metadata(&self) -> SearchProviderMetadata { - browser_metadata() - } - - fn target(&self) -> SearchTarget { - SearchTarget { - provider: "browser".to_owned(), - account: self.account.clone(), - } - } - - fn search<'a>( - &'a self, - request: SearchRequest, - _credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a> { - Box::pin(async move { - let query = build_query(&request)?; - let body = fetch_duckduckgo_with_ephemeral_browser(&query).await?; - let results = parse_duckduckgo_html(&body, request.max_results); - Ok(finish_results( - self.target(), - query, - request, - vec!["ephemeral_no_cookie_browser", "duckduckgo_html_extraction"], - results, - )) - }) - } -} - -pub struct DuckDuckGoHtmlProvider { - account: String, -} - -impl DuckDuckGoHtmlProvider { - pub fn new(account: impl Into) -> Self { - Self { - account: account.into(), - } - } -} - -impl SearchProvider for DuckDuckGoHtmlProvider { - fn metadata(&self) -> SearchProviderMetadata { - duckduckgo_metadata() - } - - fn target(&self) -> SearchTarget { - SearchTarget { - provider: "duckduckgo".to_owned(), - account: self.account.clone(), - } - } - - fn search<'a>( - &'a self, - request: SearchRequest, - _credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a> { - Box::pin(async move { - let query = build_query(&request)?; - let mut url = Url::parse("https://html.duckduckgo.com/html/") - .map_err(|err| goat_search_provider::SearchError::Url(err.to_string()))?; - url.query_pairs_mut().append_pair("q", &query); - let body = client(SEARCH_TIMEOUT)? - .get(url) - .send() - .await - .map_err(goat_search_provider::SearchError::Request)? - .text() - .await - .map_err(goat_search_provider::SearchError::Request)?; - let results = parse_duckduckgo_html(&body, request.max_results); - Ok(finish_results( - self.target(), - query, - request, - vec!["html_result_extraction"], - results, - )) - }) - } -} - -fn browser_launch_args() -> [&'static str; 22] { - [ - "disable-background-networking", - "enable-features=NetworkService,NetworkServiceInProcess", - "disable-background-timer-throttling", - "disable-backgrounding-occluded-windows", - "disable-breakpad", - "disable-client-side-phishing-detection", - "disable-component-extensions-with-background-pages", - "disable-default-apps", - "disable-dev-shm-usage", - "disable-features=TranslateUI", - "disable-hang-monitor", - "disable-ipc-flooding-protection", - "disable-popup-blocking", - "disable-prompt-on-repost", - "disable-renderer-backgrounding", - "disable-sync", - "force-color-profile=srgb", - "metrics-recording-only", - "no-first-run", - "no-default-browser-check", - "disable-blink-features=AutomationControlled", - "incognito", - ] -} - -async fn fetch_duckduckgo_with_ephemeral_browser( - query: &str, -) -> Result { - let profile = tempfile::Builder::new() - .prefix("goat-websearch-") - .tempdir() - .map_err(|err| goat_search_provider::SearchError::Browser(err.to_string()))?; - let config = BrowserConfig::builder() - .new_headless_mode() - .user_data_dir(profile.path()) - .viewport(None::) - .launch_timeout(BROWSER_SEARCH_TIMEOUT) - .request_timeout(BROWSER_SEARCH_TIMEOUT) - .disable_default_args() - .args(browser_launch_args()) - .build() - .map_err(goat_search_provider::SearchError::Browser)?; - let (mut browser, mut handler) = Browser::launch(config) - .await - .map_err(|err| goat_search_provider::SearchError::Browser(err.to_string()))?; - let handler_task = tokio::spawn(async move { while handler.next().await.is_some() {} }); - let result = async { - let mut url = Url::parse("https://html.duckduckgo.com/html/") - .map_err(|err| goat_search_provider::SearchError::Url(err.to_string()))?; - url.query_pairs_mut().append_pair("q", query); - let page = browser - .new_page("about:blank") - .await - .map_err(|err| goat_search_provider::SearchError::Browser(err.to_string()))?; - match tokio::time::timeout(BROWSER_SEARCH_TIMEOUT, page.goto(url.to_string())).await { - Ok(Ok(_)) => {} - Ok(Err(err)) => { - return Err(goat_search_provider::SearchError::Browser(err.to_string())); - } - Err(_) => { - let _ = page.execute(StopLoadingParams::default()).await; - } - } - page.content() - .await - .map_err(|err| goat_search_provider::SearchError::Browser(err.to_string())) - } - .await; - let _ = browser.close().await; - handler_task.abort(); - result -} - -pub fn parse_duckduckgo_html(html: &str, max_results: usize) -> Vec { - let mut results = Vec::new(); - let mut index = 0usize; - while results.len() < max_results { - let Some(link_rel) = html[index..].find("result__a") else { - break; - }; - let link_pos = index + link_rel; - let Some(tag_start_rel) = html[..link_pos].rfind("') else { - break; - }; - let tag_end = tag_start_rel + tag_end_rel + 1; - let tag = &html[tag_start_rel..tag_end]; - let Some(raw_href) = attr_value(tag, "href") else { - index = tag_end; - continue; - }; - let Some(end_rel) = html[tag_end..].find("") else { - break; - }; - let end = tag_end + end_rel; - let title = clean_text(&strip_tags(&html[tag_end..end])); - let url = normalize_duckduckgo_url(&raw_href); - if title.is_empty() || url.is_empty() { - index = end + 4; - continue; - } - let snippet = find_snippet(&html[end..]).unwrap_or_default(); - let rank = results.len() + 1; - results.push(SearchResult { - title, - url, - snippet, - rank, - provider: String::new(), - published_at: None, - }); - index = end + 4; - } - results -} - -fn find_snippet(after: &str) -> Option { - let pos = after.find("result__snippet")?; - let open = after[pos..].find('>')? + pos + 1; - let close = after[open..] - .find("") - .or_else(|| after[open..].find(""))? - + open; - let snippet = clean_text(&strip_tags(&after[open..close])); - (!snippet.is_empty()).then_some(snippet) -} - -fn normalize_duckduckgo_url(raw: &str) -> String { - let decoded = decode_entities(raw); - let normalized = if decoded.starts_with("//") { - format!("https:{decoded}") - } else { - decoded - }; - if let Ok(url) = Url::parse(&normalized) { - if url.domain() == Some("duckduckgo.com") - && url.path() == "/l/" - && let Some(target) = url - .query_pairs() - .find_map(|(key, value)| (key == "uddg").then_some(value)) - { - return target.into_owned(); - } - return url.to_string(); - } - normalized -} - -fn attr_value(attrs: &str, name: &str) -> Option { - let lower = attrs.to_ascii_lowercase(); - let needle = format!("{name}="); - let start = lower.find(&needle)? + needle.len(); - let quote = attrs[start..].chars().next()?; - if quote == '"' || quote == '\'' { - let rest = &attrs[start + quote.len_utf8()..]; - let end = rest.find(quote)?; - Some(rest[..end].to_owned()) - } else { - let rest = &attrs[start..]; - let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); - Some(rest[..end].trim_end_matches('>').to_owned()) - } -} - -#[cfg(test)] -mod tests { - use super::parse_duckduckgo_html; - - #[test] - fn parses_duckduckgo_results() { - let html = r#" - -
- Example B -
Snippet B
-
- "#; - let results = parse_duckduckgo_html(html, 10); - assert_eq!(results.len(), 2); - assert_eq!(results[0].title, "Example A"); - assert_eq!(results[0].url, "https://example.com/a"); - assert_eq!(results[0].snippet, "Snippet A"); - assert_eq!(results[1].url, "https://example.org/b"); - } -} diff --git a/crates/goat-search-provider-searxng/Cargo.toml b/crates/goat-search-provider-searxng/Cargo.toml deleted file mode 100644 index def81c2..0000000 --- a/crates/goat-search-provider-searxng/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "goat-search-provider-searxng" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-search-provider = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-provider-searxng/src/lib.rs b/crates/goat-search-provider-searxng/src/lib.rs deleted file mode 100644 index d124c17..0000000 --- a/crates/goat-search-provider-searxng/src/lib.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::time::Duration; - -use goat_auth::CredentialStore; -use goat_search_provider::{ - SearchCredentialMetadata, SearchFuture, SearchProvider, SearchProviderKind, - SearchProviderMetadata, SearchRequest, SearchResult, SearchTarget, build_query, clean_text, - client, finish_results, -}; -use reqwest::Url; - -const SEARCH_TIMEOUT: Duration = Duration::from_secs(15); - -pub fn metadata() -> SearchProviderMetadata { - SearchProviderMetadata { - id: "searxng", - default_account: "default", - kind: SearchProviderKind::Searxng, - credential: SearchCredentialMetadata::None, - setup: "run `goat-code search login searxng --endpoint `", - builtins: &[], - } -} - -pub struct SearxngProvider { - account: String, - endpoint: String, -} - -impl SearxngProvider { - pub fn new(account: impl Into, endpoint: impl Into) -> Self { - Self { - account: account.into(), - endpoint: endpoint.into(), - } - } -} - -impl SearchProvider for SearxngProvider { - fn metadata(&self) -> SearchProviderMetadata { - metadata() - } - - fn target(&self) -> SearchTarget { - SearchTarget { - provider: "searxng".to_owned(), - account: self.account.clone(), - } - } - - fn search<'a>( - &'a self, - request: SearchRequest, - _credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a> { - Box::pin(async move { - let query = build_query(&request)?; - let base = Url::parse(&self.endpoint) - .map_err(|err| goat_search_provider::SearchError::Url(err.to_string()))?; - if !matches!(base.scheme(), "http" | "https") { - return Err(goat_search_provider::SearchError::Url(format!( - "unsupported searxng endpoint scheme: {}", - base.scheme() - ))); - } - let mut url = base - .join("search") - .map_err(|err| goat_search_provider::SearchError::Url(err.to_string()))?; - url.query_pairs_mut() - .append_pair("q", &query) - .append_pair("format", "json"); - if let Some(language) = &request.language { - url.query_pairs_mut().append_pair("language", language); - } - if let Some(time_range) = &request.time_range { - url.query_pairs_mut().append_pair("time_range", time_range); - } - let value = client(SEARCH_TIMEOUT)? - .get(url) - .send() - .await - .map_err(goat_search_provider::SearchError::Request)? - .json::() - .await - .map_err(goat_search_provider::SearchError::Request)?; - let results = parse_searxng_json(&value, request.max_results); - Ok(finish_results( - self.target(), - query, - request, - vec!["searxng_json"], - results, - )) - }) - } -} - -pub fn parse_searxng_json(value: &serde_json::Value, max_results: usize) -> Vec { - value - .get("results") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .take(max_results) - .enumerate() - .filter_map(|(index, item)| { - let title = item.get("title").and_then(serde_json::Value::as_str)?; - let url = item.get("url").and_then(serde_json::Value::as_str)?; - let snippet = item - .get("content") - .or_else(|| item.get("snippet")) - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - Some(SearchResult { - title: clean_text(title), - url: url.to_owned(), - snippet: clean_text(snippet), - rank: index + 1, - provider: String::new(), - published_at: item - .get("publishedDate") - .or_else(|| item.get("published_at")) - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - }) - }) - .collect() -} diff --git a/crates/goat-search-provider-tavily/Cargo.toml b/crates/goat-search-provider-tavily/Cargo.toml deleted file mode 100644 index aaedb14..0000000 --- a/crates/goat-search-provider-tavily/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "goat-search-provider-tavily" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-search-provider = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-provider-tavily/src/lib.rs b/crates/goat-search-provider-tavily/src/lib.rs deleted file mode 100644 index b297cd7..0000000 --- a/crates/goat-search-provider-tavily/src/lib.rs +++ /dev/null @@ -1,113 +0,0 @@ -use std::time::Duration; - -use goat_auth::CredentialStore; -use goat_search_provider::{ - SearchCredentialMetadata, SearchFuture, SearchProvider, SearchProviderKind, - SearchProviderMetadata, SearchRequest, SearchResult, SearchTarget, build_query, clean_text, - client, finish_results, search_secret, -}; - -const SEARCH_TIMEOUT: Duration = Duration::from_secs(15); -const ENV_VAR: &str = "TAVILY_API_KEY"; - -pub fn metadata() -> SearchProviderMetadata { - SearchProviderMetadata { - id: "tavily", - default_account: "default", - kind: SearchProviderKind::Tavily, - credential: SearchCredentialMetadata::EnvApiKey { env_var: ENV_VAR }, - setup: "set TAVILY_API_KEY or run `goat-code search login tavily --key `", - builtins: &[], - } -} - -pub struct TavilyProvider { - account: String, -} - -impl TavilyProvider { - pub fn new(account: impl Into) -> Self { - Self { - account: account.into(), - } - } -} - -impl SearchProvider for TavilyProvider { - fn metadata(&self) -> SearchProviderMetadata { - metadata() - } - - fn target(&self) -> SearchTarget { - SearchTarget { - provider: "tavily".to_owned(), - account: self.account.clone(), - } - } - - fn search<'a>( - &'a self, - request: SearchRequest, - credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a> { - Box::pin(async move { - let query = build_query(&request)?; - let token = search_secret(credentials, "tavily", &self.account, Some(ENV_VAR))?; - let mut body = serde_json::json!({ - "api_key": token, - "query": query, - "max_results": request.max_results, - }); - if let Some(site) = &request.site { - body["include_domains"] = serde_json::json!([site]); - } - let value = client(SEARCH_TIMEOUT)? - .post("https://api.tavily.com/search") - .json(&body) - .send() - .await - .map_err(goat_search_provider::SearchError::Request)? - .json::() - .await - .map_err(goat_search_provider::SearchError::Request)?; - let results = parse_tavily_json(&value, request.max_results); - Ok(finish_results( - self.target(), - query, - request, - vec!["tavily_api"], - results, - )) - }) - } -} - -pub fn parse_tavily_json(value: &serde_json::Value, max_results: usize) -> Vec { - value - .get("results") - .and_then(serde_json::Value::as_array) - .into_iter() - .flatten() - .take(max_results) - .enumerate() - .filter_map(|(index, item)| { - let title = item.get("title").and_then(serde_json::Value::as_str)?; - let url = item.get("url").and_then(serde_json::Value::as_str)?; - let snippet = item - .get("content") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - Some(SearchResult { - title: clean_text(title), - url: url.to_owned(), - snippet: clean_text(snippet), - rank: index + 1, - provider: String::new(), - published_at: item - .get("published_date") - .and_then(serde_json::Value::as_str) - .map(ToOwned::to_owned), - }) - }) - .collect() -} diff --git a/crates/goat-search-provider/Cargo.toml b/crates/goat-search-provider/Cargo.toml deleted file mode 100644 index 56e579b..0000000 --- a/crates/goat-search-provider/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "goat-search-provider" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -thiserror = { workspace = true } -reqwest = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-provider/src/lib.rs b/crates/goat-search-provider/src/lib.rs deleted file mode 100644 index 104d395..0000000 --- a/crates/goat-search-provider/src/lib.rs +++ /dev/null @@ -1,221 +0,0 @@ -use std::{future::Future, pin::Pin}; - -use goat_auth::CredentialStore; - -pub type SearchFuture<'a> = - Pin> + Send + 'a>>; - -#[derive(Debug, Clone)] -pub struct SearchRequest { - pub query: String, - pub max_results: usize, - pub site: Option, - pub language: Option, - pub time_range: Option, - pub target: Option, -} - -#[derive(Debug, Clone)] -pub struct SearchResults { - pub query: String, - pub provider: SearchTarget, - pub language: Option, - pub time_range: Option, - pub limitations: Vec<&'static str>, - pub results: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SearchTarget { - pub provider: String, - pub account: String, -} - -impl SearchTarget { - pub fn parse(s: &str) -> Option { - let (provider, account) = s.split_once('/')?; - if provider.is_empty() || account.is_empty() { - return None; - } - Some(Self { - provider: provider.to_owned(), - account: account.to_owned(), - }) - } - - pub fn as_str(&self) -> String { - format!("{}/{}", self.provider, self.account) - } -} - -#[derive(Debug, Clone)] -pub struct SearchResult { - pub title: String, - pub url: String, - pub snippet: String, - pub rank: usize, - pub provider: String, - pub published_at: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SearchCredentialMetadata { - None, - EnvApiKey { env_var: &'static str }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SearchProviderKind { - Browser { default_engine: &'static str }, - Duckduckgo, - Searxng, - Brave, - Tavily, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SearchTargetMetadata<'a> { - pub provider: &'a str, - pub account: &'a str, - pub target: &'a str, - pub kind: &'static str, - pub setup: &'static str, - pub credential: SearchCredentialMetadata, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SearchBuiltinTarget { - pub provider: &'static str, - pub account: &'static str, - pub target: &'static str, - pub kind: &'static str, - pub setup: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct SearchProviderMetadata { - pub id: &'static str, - pub default_account: &'static str, - pub kind: SearchProviderKind, - pub credential: SearchCredentialMetadata, - pub setup: &'static str, - pub builtins: &'static [SearchBuiltinTarget], -} - -pub trait SearchProvider: Send + Sync { - fn metadata(&self) -> SearchProviderMetadata; - fn target(&self) -> SearchTarget; - fn search<'a>( - &'a self, - request: SearchRequest, - credentials: Option<&'a CredentialStore>, - ) -> SearchFuture<'a>; -} - -#[derive(Debug, thiserror::Error)] -pub enum SearchError { - #[error("query is empty")] - EmptyQuery, - #[error("invalid search target: {0}")] - InvalidTarget(String), - #[error("unknown search target: {0}")] - UnknownTarget(String), - #[error("invalid search provider config: {0}")] - InvalidProvider(String), - #[error("missing search credential for {0}/{1}")] - MissingCredential(String, String), - #[error("browser-backed search failed: {0}")] - Browser(String), - #[error("invalid search url: {0}")] - Url(String), - #[error("search request failed: {0}")] - Request(reqwest::Error), -} - -pub fn build_query(request: &SearchRequest) -> Result { - let mut query = request.query.trim().to_owned(); - if query.is_empty() { - return Err(SearchError::EmptyQuery); - } - if let Some(site) = &request.site - && !site.trim().is_empty() - { - query = format!("site:{} {query}", site.trim()); - } - Ok(query) -} - -pub fn client(timeout: std::time::Duration) -> Result { - reqwest::Client::builder() - .timeout(timeout) - .redirect(reqwest::redirect::Policy::limited(4)) - .user_agent("goat-code WebSearch") - .build() - .map_err(SearchError::Request) -} - -pub fn clean_text(text: &str) -> String { - text.split_whitespace().collect::>().join(" ") -} - -pub fn strip_tags(html: &str) -> String { - let mut out = String::with_capacity(html.len()); - let mut in_tag = false; - for ch in html.chars() { - match ch { - '<' => { - in_tag = true; - out.push(' '); - } - '>' => in_tag = false, - _ if !in_tag => out.push(ch), - _ => {} - } - } - decode_entities(&out) -} - -pub fn decode_entities(text: &str) -> String { - text.replace(" ", " ") - .replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") -} - -pub fn search_secret( - credentials: Option<&CredentialStore>, - provider: &str, - account: &str, - env_var: Option<&str>, -) -> Result { - let key = goat_auth::CredentialKey::search(provider, account); - credentials - .and_then(|store| store.resolve(&key, env_var)) - .map(|credential| credential.bearer().to_owned()) - .ok_or_else(|| SearchError::MissingCredential(provider.to_owned(), account.to_owned())) -} - -pub fn finish_results( - target: SearchTarget, - query: String, - request: SearchRequest, - mut limitations: Vec<&'static str>, - mut results: Vec, -) -> SearchResults { - let target_label = target.as_str(); - for result in &mut results { - result.provider.clone_from(&target_label); - } - limitations.push("results_are_untrusted_candidates"); - limitations.push("verify_with_webfetch"); - SearchResults { - query, - provider: target, - language: request.language, - time_range: request.time_range, - limitations, - results, - } -} diff --git a/crates/goat-search-providers/Cargo.toml b/crates/goat-search-providers/Cargo.toml deleted file mode 100644 index 829d0a5..0000000 --- a/crates/goat-search-providers/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "goat-search-providers" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -[dependencies] -goat-auth = { workspace = true } -goat-config = { workspace = true } -goat-search-provider = { workspace = true } -goat-search-provider-duckduckgo = { workspace = true } -goat-search-provider-searxng = { workspace = true } -goat-search-provider-brave = { workspace = true } -goat-search-provider-tavily = { workspace = true } - -[lints] -workspace = true diff --git a/crates/goat-search-providers/src/lib.rs b/crates/goat-search-providers/src/lib.rs deleted file mode 100644 index 847196e..0000000 --- a/crates/goat-search-providers/src/lib.rs +++ /dev/null @@ -1,214 +0,0 @@ -use goat_auth::CredentialStore; -use goat_config::{Config, SearchAccountConfig}; -use goat_search_provider::{ - SearchBuiltinTarget, SearchCredentialMetadata, SearchError, SearchProvider, SearchProviderKind, - SearchProviderMetadata, SearchRequest, SearchResults, SearchTarget, SearchTargetMetadata, -}; - -const DEFAULT_TARGET: &str = "browser/duckduckgo"; - -pub fn default_search_target() -> &'static str { - DEFAULT_TARGET -} - -pub fn metadata() -> Vec { - vec![ - goat_search_provider_duckduckgo::browser_metadata(), - goat_search_provider_duckduckgo::duckduckgo_metadata(), - goat_search_provider_searxng::metadata(), - goat_search_provider_brave::metadata(), - goat_search_provider_tavily::metadata(), - ] -} - -pub fn search_providers() -> Vec { - metadata() -} - -pub fn search_provider(id: &str) -> Option { - metadata().into_iter().find(|provider| provider.id == id) -} - -pub fn search_builtin_targets() -> Vec> { - metadata() - .into_iter() - .flat_map(|provider| { - provider - .builtins - .iter() - .map(move |builtin| builtin_target(provider.credential, builtin)) - }) - .collect() -} - -fn builtin_target( - credential: SearchCredentialMetadata, - builtin: &SearchBuiltinTarget, -) -> SearchTargetMetadata<'static> { - SearchTargetMetadata { - provider: builtin.provider, - account: builtin.account, - target: builtin.target, - kind: builtin.kind, - setup: builtin.setup, - credential, - } -} - -pub fn is_builtin_search_target(target: &str) -> bool { - search_builtin_targets() - .into_iter() - .any(|builtin| builtin.target == target) -} - -pub fn build_search_account_config( - provider: &str, - account: &str, - endpoint: Option<&str>, - engine: Option<&str>, -) -> Result { - let metadata = - search_provider(provider).ok_or_else(|| format!("unknown search provider: {provider}"))?; - match metadata.kind { - SearchProviderKind::Browser { default_engine } => { - let engine = engine.unwrap_or(default_engine); - if engine != default_engine { - return Err(format!("unsupported browser search engine: {engine}")); - } - Ok(SearchAccountConfig::Browser { - account: account.to_owned(), - engine: engine.to_owned(), - }) - } - SearchProviderKind::Duckduckgo => Ok(SearchAccountConfig::Duckduckgo { - account: account.to_owned(), - }), - SearchProviderKind::Searxng => Ok(SearchAccountConfig::Searxng { - account: account.to_owned(), - endpoint: endpoint - .ok_or_else(|| "searxng requires --endpoint".to_owned())? - .to_owned(), - }), - SearchProviderKind::Brave => Ok(SearchAccountConfig::Brave { - account: account.to_owned(), - }), - SearchProviderKind::Tavily => Ok(SearchAccountConfig::Tavily { - account: account.to_owned(), - }), - } -} - -pub fn configured_search_target(account: &SearchAccountConfig) -> SearchTargetMetadata<'_> { - let provider = configured_search_provider(account); - let metadata = search_provider(provider).expect("configured search provider metadata"); - SearchTargetMetadata { - provider, - account: configured_search_account(account), - target: "", - kind: "configured", - setup: metadata.setup, - credential: metadata.credential, - } -} - -pub fn configured_search_provider(account: &SearchAccountConfig) -> &'static str { - match account { - SearchAccountConfig::Duckduckgo { .. } => "duckduckgo", - SearchAccountConfig::Browser { .. } => "browser", - SearchAccountConfig::Searxng { .. } => "searxng", - SearchAccountConfig::Brave { .. } => "brave", - SearchAccountConfig::Tavily { .. } => "tavily", - } -} - -pub fn configured_search_account(account: &SearchAccountConfig) -> &str { - match account { - SearchAccountConfig::Duckduckgo { account } - | SearchAccountConfig::Browser { account, .. } - | SearchAccountConfig::Searxng { account, .. } - | SearchAccountConfig::Brave { account } - | SearchAccountConfig::Tavily { account } => account, - } -} - -pub struct SearchRegistry { - providers: Vec>, - default_target: SearchTarget, - credentials: Option, -} - -impl SearchRegistry { - pub fn load() -> Self { - let config = Config::load(); - let credentials = goat_config::auth_path().map(CredentialStore::new); - Self::from_config(config, credentials) - } - - pub fn from_config(config: Config, credentials: Option) -> Self { - let default_target = config - .search - .default_target - .as_deref() - .and_then(SearchTarget::parse) - .unwrap_or_else(|| SearchTarget::parse(DEFAULT_TARGET).expect("valid default target")); - let providers = providers_from_config(config); - let default_target = if providers - .iter() - .any(|provider| provider.target() == default_target) - { - default_target - } else { - SearchTarget::parse(DEFAULT_TARGET).expect("valid default target") - }; - Self { - providers, - default_target, - credentials, - } - } - - pub async fn search(&self, request: SearchRequest) -> Result { - let target = match request.target.as_deref() { - Some(raw) => SearchTarget::parse(raw) - .ok_or_else(|| SearchError::InvalidTarget(raw.to_owned()))?, - None => self.default_target.clone(), - }; - let provider = self - .providers - .iter() - .find(|provider| provider.target() == target) - .ok_or_else(|| SearchError::UnknownTarget(target.as_str()))?; - provider.search(request, self.credentials.as_ref()).await - } -} - -pub fn providers_from_config(config: Config) -> Vec> { - let mut providers: Vec> = vec![ - Box::new(goat_search_provider_duckduckgo::BrowserDuckDuckGoProvider::new("duckduckgo")), - Box::new(goat_search_provider_duckduckgo::DuckDuckGoHtmlProvider::new("html")), - ]; - for account in config.search.accounts { - match account { - SearchAccountConfig::Duckduckgo { account } => providers.push(Box::new( - goat_search_provider_duckduckgo::DuckDuckGoHtmlProvider::new(account), - )), - SearchAccountConfig::Browser { account, engine } => { - if engine == "duckduckgo" { - providers.push(Box::new( - goat_search_provider_duckduckgo::BrowserDuckDuckGoProvider::new(account), - )); - } - } - SearchAccountConfig::Searxng { account, endpoint } => providers.push(Box::new( - goat_search_provider_searxng::SearxngProvider::new(account, endpoint), - )), - SearchAccountConfig::Brave { account } => providers.push(Box::new( - goat_search_provider_brave::BraveProvider::new(account), - )), - SearchAccountConfig::Tavily { account } => providers.push(Box::new( - goat_search_provider_tavily::TavilyProvider::new(account), - )), - } - } - providers -} diff --git a/crates/goat-tool-search/Cargo.toml b/crates/goat-tool-search/Cargo.toml index 0d2f610..390f0b1 100644 --- a/crates/goat-tool-search/Cargo.toml +++ b/crates/goat-tool-search/Cargo.toml @@ -11,6 +11,7 @@ publish = false goat-protocol = { workspace = true } goat-tool = { workspace = true } goat-config = { workspace = true } +goat-auth = { workspace = true } goat-search-provider = { workspace = true } goat-search-providers = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/crates/goat-tool-search/src/web_search.rs b/crates/goat-tool-search/src/web_search.rs index 1ca87e9..cfccf49 100644 --- a/crates/goat-tool-search/src/web_search.rs +++ b/crates/goat-tool-search/src/web_search.rs @@ -13,8 +13,14 @@ pub struct WebSearchTool { impl WebSearchTool { pub fn new() -> Self { + let config = goat_config::Config::load(); + let credentials = goat_config::auth_path().map(goat_auth::CredentialStore::new); Self { - registry: goat_search_providers::SearchRegistry::load(), + registry: goat_search_providers::SearchRegistry::from_parts( + config.search.default_target.as_deref(), + config.search.accounts, + credentials, + ), } } } From 946bb3cc32554b471cbb3ce5cf26345bed94ec93 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 00:04:49 +0900 Subject: [PATCH 2/7] refactor: consume provider stream via pull instead of push --- Cargo.lock | 36 +++++++ Cargo.toml | 2 + crates/goat-agent/Cargo.toml | 2 + crates/goat-agent/src/compaction.rs | 20 ++-- crates/goat-agent/src/lib.rs | 149 ++++++++++++---------------- crates/goat-agent/src/rounds.rs | 41 +++++--- 6 files changed, 140 insertions(+), 110 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b73e31..cd4ea1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -289,6 +289,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "async-task" version = "4.7.1" @@ -1823,6 +1845,8 @@ dependencies = [ name = "goat-agent" version = "0.1.23" dependencies = [ + "async-stream", + "async-trait", "futures", "goat-auth", "goat-config", @@ -2031,6 +2055,9 @@ dependencies = [ name = "goat-provider" version = "0.1.23" dependencies = [ + "async-stream", + "async-trait", + "futures", "goat-auth", "goat-protocol", "serde", @@ -2043,6 +2070,8 @@ dependencies = [ name = "goat-provider-anthropic" version = "0.1.23" dependencies = [ + "async-stream", + "async-trait", "eventsource-stream", "futures", "goat-auth", @@ -2069,6 +2098,8 @@ dependencies = [ name = "goat-provider-gemini" version = "0.1.23" dependencies = [ + "async-stream", + "async-trait", "eventsource-stream", "futures", "goat-auth", @@ -2104,6 +2135,7 @@ dependencies = [ name = "goat-provider-kimi-code" version = "0.1.23" dependencies = [ + "async-trait", "goat-auth", "goat-provider", "goat-provider-openai-compat", @@ -2142,6 +2174,7 @@ dependencies = [ name = "goat-provider-openai-codex" version = "0.1.23" dependencies = [ + "async-trait", "base64", "goat-auth", "goat-provider", @@ -2159,6 +2192,8 @@ dependencies = [ name = "goat-provider-openai-compat" version = "0.1.23" dependencies = [ + "async-stream", + "async-trait", "eventsource-stream", "futures", "goat-auth", @@ -2192,6 +2227,7 @@ dependencies = [ name = "goat-provider-xai" version = "0.1.23" dependencies = [ + "async-trait", "goat-auth", "goat-provider", "goat-provider-openai-compat", diff --git a/Cargo.toml b/Cargo.toml index f5f01dd..0a7dedd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time" tokio-util = { version = "0.7", features = ["rt", "codec"] } bytes = "1" futures = "0.3" +async-trait = "0.1" +async-stream = "0.3" ratatui = "0.30" ratatui-image = { version = "11.0.4", default-features = false, features = ["crossterm"] } diff --git a/crates/goat-agent/Cargo.toml b/crates/goat-agent/Cargo.toml index 98369cd..0298626 100644 --- a/crates/goat-agent/Cargo.toml +++ b/crates/goat-agent/Cargo.toml @@ -35,6 +35,8 @@ unicode-width = { workspace = true } [dev-dependencies] tempfile = { workspace = true } tokio = { workspace = true, features = ["test-util"] } +async-trait = { workspace = true } +async-stream = { workspace = true } [lints] workspace = true diff --git a/crates/goat-agent/src/compaction.rs b/crates/goat-agent/src/compaction.rs index e675b5f..436a397 100644 --- a/crates/goat-agent/src/compaction.rs +++ b/crates/goat-agent/src/compaction.rs @@ -295,23 +295,25 @@ async fn collect_text( request: goat_provider::Request, token: &tokio_util::sync::CancellationToken, ) -> Result<(String, Usage), CollectEnd> { - let (tx, mut rx) = tokio::sync::mpsc::channel(64); - let handle = provider.stream(request, tx); + use futures::StreamExt; + let mut stream = match provider.stream(request).await { + Ok(stream) => stream, + Err(error) => return Err(CollectEnd::Failed(error)), + }; let mut text = String::new(); let mut usage = Usage::default(); loop { tokio::select! { biased; () = token.cancelled() => { - handle.abort(); return Err(CollectEnd::Cancelled); } - maybe_event = rx.recv() => match maybe_event { - Some(goat_provider::StreamEvent::TextDelta { text: chunk }) => text.push_str(&chunk), - Some(goat_provider::StreamEvent::Usage { usage: collected }) => usage = collected, - Some(goat_provider::StreamEvent::Failed { error }) => return Err(CollectEnd::Failed(error)), - Some(goat_provider::StreamEvent::Completed) | None => return Ok((text, usage)), - Some(_) => {} + maybe_chunk = stream.next() => match maybe_chunk { + Some(Ok(goat_provider::StreamChunk::TextDelta { text: chunk })) => text.push_str(&chunk), + Some(Ok(goat_provider::StreamChunk::Usage { usage: collected })) => usage = collected, + Some(Ok(_)) => {} + Some(Err(error)) => return Err(CollectEnd::Failed(error)), + None => return Ok((text, usage)), } } } diff --git a/crates/goat-agent/src/lib.rs b/crates/goat-agent/src/lib.rs index 9e63d0c..1b223e7 100644 --- a/crates/goat-agent/src/lib.rs +++ b/crates/goat-agent/src/lib.rs @@ -465,7 +465,8 @@ mod tests { use goat_core::Session; use goat_protocol::{Event, ModelTarget, Op, TaskId}; use goat_provider::{ - AuthMethod, Capabilities, Model, Provider, ProviderId, Request, StreamError, StreamEvent, + AuthMethod, Capabilities, ChunkStream, Model, Provider, ProviderId, Request, StreamChunk, + StreamError, }; use goat_providers::Registry; use goat_store::Store; @@ -479,6 +480,7 @@ mod tests { delay_ms: u64, } + #[async_trait::async_trait] impl Provider for MockProvider { fn id(&self) -> ProviderId { ProviderId::from(self.id.as_str()) @@ -492,16 +494,15 @@ mod tests { } } - fn stream(&self, _req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, _req: Request) -> Result { let reply = self.reply.clone(); let delay = self.delay_ms; - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { if delay > 0 { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; } - let _ = events.send(StreamEvent::TextDelta { text: reply }).await; - let _ = events.send(StreamEvent::Completed).await; - }) + yield StreamChunk::TextDelta { text: reply }; + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { @@ -515,6 +516,7 @@ mod tests { calls: Arc, } + #[async_trait::async_trait] impl Provider for ScriptedProvider { fn id(&self) -> ProviderId { ProviderId::from("mock") @@ -528,37 +530,30 @@ mod tests { } } - fn stream(&self, _req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, _req: Request) -> Result { let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { match n { 0 => { - let _ = events - .send(StreamEvent::ToolCall { - id: "call-1".to_owned(), - name: "Agent".to_owned(), - input: "{\"agent_type\":\"explore\",\"prompt\":\"look into it\"}" - .to_owned(), - }) - .await; + yield StreamChunk::ToolCall { + id: "call-1".to_owned(), + name: "Agent".to_owned(), + input: "{\"agent_type\":\"explore\",\"prompt\":\"look into it\"}" + .to_owned(), + }; } 1 => { - let _ = events - .send(StreamEvent::TextDelta { - text: "child findings".to_owned(), - }) - .await; + yield StreamChunk::TextDelta { + text: "child findings".to_owned(), + }; } _ => { - let _ = events - .send(StreamEvent::TextDelta { - text: "final answer".to_owned(), - }) - .await; + yield StreamChunk::TextDelta { + text: "final answer".to_owned(), + }; } } - let _ = events.send(StreamEvent::Completed).await; - }) + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { @@ -573,6 +568,7 @@ mod tests { delay_ms: u64, } + #[async_trait::async_trait] impl Provider for SeqTextProvider { fn id(&self) -> ProviderId { ProviderId::from("mock") @@ -586,18 +582,15 @@ mod tests { } } - fn stream(&self, _req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, _req: Request) -> Result { let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let delay = self.delay_ms; - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; - let _ = events - .send(StreamEvent::TextDelta { - text: format!("reply {n}"), - }) - .await; - let _ = events.send(StreamEvent::Completed).await; - }) + yield StreamChunk::TextDelta { + text: format!("reply {n}"), + }; + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { @@ -612,6 +605,7 @@ mod tests { captured: Arc>>, } + #[async_trait::async_trait] impl Provider for CapturingProvider { fn id(&self) -> ProviderId { ProviderId::from("mock") @@ -625,27 +619,22 @@ mod tests { } } - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, req: Request) -> Result { self.captured.lock().unwrap().push(req); let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { if n == 0 { - let _ = events - .send(StreamEvent::ToolCall { - id: "call-1".to_owned(), - name: "Read".to_owned(), - input: "{\"path\":\"does-not-exist.txt\"}".to_owned(), - }) - .await; + yield StreamChunk::ToolCall { + id: "call-1".to_owned(), + name: "Read".to_owned(), + input: "{\"path\":\"does-not-exist.txt\"}".to_owned(), + }; } else { - let _ = events - .send(StreamEvent::TextDelta { - text: "final answer".to_owned(), - }) - .await; + yield StreamChunk::TextDelta { + text: "final answer".to_owned(), + }; } - let _ = events.send(StreamEvent::Completed).await; - }) + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { @@ -863,6 +852,7 @@ mod tests { requests: Arc>>, } + #[async_trait::async_trait] impl Provider for OverflowThenRecoverProvider { fn id(&self) -> ProviderId { ProviderId::from("mock") @@ -876,41 +866,31 @@ mod tests { } } - fn stream(&self, req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, req: Request) -> Result { let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); self.requests .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .push(req); - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { match n { 0 => { - let _ = events - .send(StreamEvent::Failed { - error: StreamError::context_overflow("prompt is too long"), - }) - .await; + Err(StreamError::context_overflow("prompt is too long"))?; } 1 => { - let _ = events - .send(StreamEvent::TextDelta { - text: - "walk## Task\nthe work" - .to_owned(), - }) - .await; - let _ = events.send(StreamEvent::Completed).await; + yield StreamChunk::TextDelta { + text: + "walk## Task\nthe work" + .to_owned(), + }; } _ => { - let _ = events - .send(StreamEvent::TextDelta { - text: "recovered after compaction".to_owned(), - }) - .await; - let _ = events.send(StreamEvent::Completed).await; + yield StreamChunk::TextDelta { + text: "recovered after compaction".to_owned(), + }; } } - }) + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { @@ -1092,6 +1072,7 @@ mod tests { error: StreamError, } + #[async_trait::async_trait] impl Provider for FailingProvider { fn id(&self) -> ProviderId { ProviderId::from("mock") @@ -1105,22 +1086,18 @@ mod tests { } } - fn stream(&self, _req: Request, events: mpsc::Sender) -> JoinHandle<()> { + async fn stream(&self, _req: Request) -> Result { let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let failures = self.failures; let error = self.error.clone(); - tokio::spawn(async move { + Ok(Box::pin(async_stream::try_stream! { if n < failures { - let _ = events.send(StreamEvent::Failed { error }).await; - return; + Err(error)?; } - let _ = events - .send(StreamEvent::TextDelta { - text: "recovered".to_owned(), - }) - .await; - let _ = events.send(StreamEvent::Completed).await; - }) + yield StreamChunk::TextDelta { + text: "recovered".to_owned(), + }; + })) } fn discover(&self, out: mpsc::Sender) -> JoinHandle<()> { diff --git a/crates/goat-agent/src/rounds.rs b/crates/goat-agent/src/rounds.rs index 4cb3ca9..141edd7 100644 --- a/crates/goat-agent/src/rounds.rs +++ b/crates/goat-agent/src/rounds.rs @@ -1,9 +1,9 @@ +use futures::StreamExt; use goat_protocol::Event; use goat_provider::{ - ContentBlock, Message, MessageRole, Provider, Request, StreamError, StreamEvent, + ContentBlock, Message, MessageRole, Provider, Request, StreamChunk, StreamError, }; use goat_tool::ToolContext; -use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; use crate::{ @@ -166,8 +166,20 @@ pub(crate) async fn run_round( request: Request, token: &CancellationToken, ) -> RoundResult { - let (mev_tx, mut mev_rx) = mpsc::channel(64); - let handle = provider.stream(request, mev_tx); + let mut stream = match provider.stream(request).await { + Ok(stream) => stream, + Err(error) => { + return RoundResult { + end: RoundEnd::Failed(error), + raw: String::new(), + thinking: None, + redacted: Vec::new(), + pending_calls: Vec::new(), + usage: None, + rate_limits: None, + }; + } + }; let mut raw = String::new(); let mut thinking = String::new(); let mut signature = String::new(); @@ -179,41 +191,40 @@ pub(crate) async fn run_round( tokio::select! { biased; () = token.cancelled() => { - handle.abort(); break RoundEnd::Cancelled; } - maybe_event = mev_rx.recv() => match maybe_event { - Some(StreamEvent::TextDelta { text }) => { + maybe_chunk = stream.next() => match maybe_chunk { + Some(Ok(StreamChunk::TextDelta { text })) => { raw.push_str(&text); let _ = ctx .events .send(Event::TextDelta { id: run.id, chunk: text }) .await; } - Some(StreamEvent::ThinkingDelta { text }) => { + Some(Ok(StreamChunk::ThinkingDelta { text })) => { thinking.push_str(&text); let _ = ctx .events .send(Event::ThinkingDelta { id: run.id, chunk: text }) .await; } - Some(StreamEvent::ThinkingSignature { signature: sig }) => { + Some(Ok(StreamChunk::ThinkingSignature { signature: sig })) => { signature.push_str(&sig); } - Some(StreamEvent::RedactedThinking { data }) => { + Some(Ok(StreamChunk::RedactedThinking { data })) => { redacted.push(data); } - Some(StreamEvent::ToolCall { id: vendor_id, name, input }) => { + Some(Ok(StreamChunk::ToolCall { id: vendor_id, name, input })) => { pending_calls.push((vendor_id, name, input)); } - Some(StreamEvent::Usage { usage: u }) => { + Some(Ok(StreamChunk::Usage { usage: u })) => { usage = Some(u); } - Some(StreamEvent::RateLimits { snapshot }) => { + Some(Ok(StreamChunk::RateLimits { snapshot })) => { rate_limits = Some(snapshot); } - Some(StreamEvent::Completed) | None => break RoundEnd::Completed, - Some(StreamEvent::Failed { error }) => break RoundEnd::Failed(error), + Some(Err(error)) => break RoundEnd::Failed(error), + None => break RoundEnd::Completed, } } }; From ca317c97c6f2a8e810e96512a20b19f81b4bb7e0 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 01:42:02 +0900 Subject: [PATCH 3/7] build: depend on goat-sdk via git rev for ci --- Cargo.lock | 26 ++++++++++++++++++++++++++ Cargo.toml | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cd4ea1d..88b6068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1876,6 +1876,7 @@ dependencies = [ [[package]] name = "goat-auth" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "base64", "fs2", @@ -2046,6 +2047,7 @@ dependencies = [ [[package]] name = "goat-protocol" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "schemars", "serde", @@ -2054,6 +2056,7 @@ dependencies = [ [[package]] name = "goat-provider" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-stream", "async-trait", @@ -2069,6 +2072,7 @@ dependencies = [ [[package]] name = "goat-provider-anthropic" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-stream", "async-trait", @@ -2088,6 +2092,7 @@ dependencies = [ [[package]] name = "goat-provider-deepseek" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2097,6 +2102,7 @@ dependencies = [ [[package]] name = "goat-provider-gemini" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-stream", "async-trait", @@ -2116,6 +2122,7 @@ dependencies = [ [[package]] name = "goat-provider-groq" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2125,6 +2132,7 @@ dependencies = [ [[package]] name = "goat-provider-kimi" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2134,6 +2142,7 @@ dependencies = [ [[package]] name = "goat-provider-kimi-code" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-trait", "goat-auth", @@ -2148,6 +2157,7 @@ dependencies = [ [[package]] name = "goat-provider-local" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-provider-openai-compat", ] @@ -2155,6 +2165,7 @@ dependencies = [ [[package]] name = "goat-provider-mistral" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2164,6 +2175,7 @@ dependencies = [ [[package]] name = "goat-provider-openai" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2173,6 +2185,7 @@ dependencies = [ [[package]] name = "goat-provider-openai-codex" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-trait", "base64", @@ -2191,6 +2204,7 @@ dependencies = [ [[package]] name = "goat-provider-openai-compat" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-stream", "async-trait", @@ -2207,6 +2221,7 @@ dependencies = [ [[package]] name = "goat-provider-openrouter" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2216,6 +2231,7 @@ dependencies = [ [[package]] name = "goat-provider-qwen" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2226,6 +2242,7 @@ dependencies = [ [[package]] name = "goat-provider-xai" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "async-trait", "goat-auth", @@ -2241,6 +2258,7 @@ dependencies = [ [[package]] name = "goat-provider-zai" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2250,6 +2268,7 @@ dependencies = [ [[package]] name = "goat-provider-zai-coding" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2259,6 +2278,7 @@ dependencies = [ [[package]] name = "goat-providers" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-provider", @@ -2315,6 +2335,7 @@ dependencies = [ [[package]] name = "goat-search-provider" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "reqwest 0.12.28", @@ -2325,6 +2346,7 @@ dependencies = [ [[package]] name = "goat-search-provider-brave" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-search-provider", @@ -2335,6 +2357,7 @@ dependencies = [ [[package]] name = "goat-search-provider-duckduckgo" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "chromiumoxide", "futures", @@ -2348,6 +2371,7 @@ dependencies = [ [[package]] name = "goat-search-provider-searxng" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-search-provider", @@ -2358,6 +2382,7 @@ dependencies = [ [[package]] name = "goat-search-provider-tavily" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-search-provider", @@ -2368,6 +2393,7 @@ dependencies = [ [[package]] name = "goat-search-providers" version = "0.1.23" +source = "git+https://github.com/goat-agent/goat-sdk.git?rev=3afccf288250f764fd64b19ab9b9bb40d9733176#3afccf288250f764fd64b19ab9b9bb40d9733176" dependencies = [ "goat-auth", "goat-search-provider", diff --git a/Cargo.toml b/Cargo.toml index 0a7dedd..4a8a606 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,37 +11,37 @@ repository = "https://github.com/goat-agent/goat-code" authors = ["jbj338033"] [workspace.dependencies] -goat-protocol = { path = "../goat-sdk/crates/goat-protocol" } +goat-protocol = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-protocol" } goat-config = { path = "crates/goat-config" } goat-core = { path = "crates/goat-core" } goat-mcp = { path = "crates/goat-mcp" } goat-tui = { path = "crates/goat-tui" } -goat-provider = { path = "../goat-sdk/crates/goat-provider" } -goat-auth = { path = "../goat-sdk/crates/goat-auth" } +goat-provider = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider" } +goat-auth = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-auth" } goat-store = { path = "crates/goat-store" } -goat-provider-openai-compat = { path = "../goat-sdk/crates/goat-provider-openai-compat" } -goat-provider-openrouter = { path = "../goat-sdk/crates/goat-provider-openrouter" } -goat-provider-groq = { path = "../goat-sdk/crates/goat-provider-groq" } -goat-provider-deepseek = { path = "../goat-sdk/crates/goat-provider-deepseek" } -goat-provider-mistral = { path = "../goat-sdk/crates/goat-provider-mistral" } -goat-provider-zai = { path = "../goat-sdk/crates/goat-provider-zai" } -goat-provider-zai-coding = { path = "../goat-sdk/crates/goat-provider-zai-coding" } -goat-provider-kimi = { path = "../goat-sdk/crates/goat-provider-kimi" } -goat-provider-qwen = { path = "../goat-sdk/crates/goat-provider-qwen" } -goat-provider-kimi-code = { path = "../goat-sdk/crates/goat-provider-kimi-code" } -goat-provider-xai = { path = "../goat-sdk/crates/goat-provider-xai" } -goat-provider-openai = { path = "../goat-sdk/crates/goat-provider-openai" } -goat-provider-openai-codex = { path = "../goat-sdk/crates/goat-provider-openai-codex" } -goat-provider-anthropic = { path = "../goat-sdk/crates/goat-provider-anthropic" } -goat-provider-gemini = { path = "../goat-sdk/crates/goat-provider-gemini" } -goat-provider-local = { path = "../goat-sdk/crates/goat-provider-local" } -goat-providers = { path = "../goat-sdk/crates/goat-providers" } -goat-search-provider = { path = "../goat-sdk/crates/goat-search-provider" } -goat-search-provider-duckduckgo = { path = "../goat-sdk/crates/goat-search-provider-duckduckgo" } -goat-search-provider-searxng = { path = "../goat-sdk/crates/goat-search-provider-searxng" } -goat-search-provider-brave = { path = "../goat-sdk/crates/goat-search-provider-brave" } -goat-search-provider-tavily = { path = "../goat-sdk/crates/goat-search-provider-tavily" } -goat-search-providers = { path = "../goat-sdk/crates/goat-search-providers" } +goat-provider-openai-compat = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-openai-compat" } +goat-provider-openrouter = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-openrouter" } +goat-provider-groq = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-groq" } +goat-provider-deepseek = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-deepseek" } +goat-provider-mistral = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-mistral" } +goat-provider-zai = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-zai" } +goat-provider-zai-coding = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-zai-coding" } +goat-provider-kimi = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-kimi" } +goat-provider-qwen = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-qwen" } +goat-provider-kimi-code = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-kimi-code" } +goat-provider-xai = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-xai" } +goat-provider-openai = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-openai" } +goat-provider-openai-codex = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-openai-codex" } +goat-provider-anthropic = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-anthropic" } +goat-provider-gemini = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-gemini" } +goat-provider-local = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-provider-local" } +goat-providers = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-providers" } +goat-search-provider = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-provider" } +goat-search-provider-duckduckgo = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-provider-duckduckgo" } +goat-search-provider-searxng = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-provider-searxng" } +goat-search-provider-brave = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-provider-brave" } +goat-search-provider-tavily = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-provider-tavily" } +goat-search-providers = { git = "https://github.com/goat-agent/goat-sdk.git", rev = "3afccf288250f764fd64b19ab9b9bb40d9733176", package = "goat-search-providers" } goat-agent = { path = "crates/goat-agent" } goat-tool = { path = "crates/goat-tool" } goat-sandbox = { path = "crates/goat-sandbox" } From 67af821243ea61eed9217855f4a6ba1db2775ff5 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 01:42:40 +0900 Subject: [PATCH 4/7] style: fix rustfmt in goat-sandbox --- crates/goat-sandbox/src/lib.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/goat-sandbox/src/lib.rs b/crates/goat-sandbox/src/lib.rs index 12c86c8..e553206 100644 --- a/crates/goat-sandbox/src/lib.rs +++ b/crates/goat-sandbox/src/lib.rs @@ -268,9 +268,7 @@ mod tests { .expect("profile arg"); assert!(profile.contains("(allow file-read*)")); assert!(profile.contains(&format!("(deny file-read* (subpath \"{home}/.ssh\"))"))); - assert!(profile.contains(&format!( - "(deny file-read* (subpath \"{home}/.goat\"))" - ))); + assert!(profile.contains(&format!("(deny file-read* (subpath \"{home}/.goat\"))"))); assert!(profile.contains(&format!("(deny file-read* (literal \"{home}/.netrc\"))"))); assert!(!profile.contains("/work")); } From 332ed006ea4c5c3cf76900bb07aaf20a837810c4 Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 02:13:29 +0900 Subject: [PATCH 5/7] ci: retrigger From 52700e8f49f4cead0f1d9e2e7496b8911d5a58db Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 09:38:16 +0900 Subject: [PATCH 6/7] ci: diagnose ubuntu test hang --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c584c6b..8b66e33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,10 @@ jobs: with: save-if: ${{ github.ref == 'refs/heads/main' }} cache-on-failure: true - - run: cargo test --workspace + - if: runner.os != 'Linux' + run: cargo test --workspace + - if: runner.os == 'Linux' + run: cargo test --workspace -- --nocapture --test-threads=1 2>&1 | tee /tmp/t.log; echo "LAST TEST BEFORE HANG:"; tail -5 /tmp/t.log msrv: runs-on: ubuntu-latest From 7ebdfd7cad1ab236638ca4e2a9e7c90e89811c8e Mon Sep 17 00:00:00 2001 From: jbj338033 Date: Thu, 9 Jul 2026 09:46:22 +0900 Subject: [PATCH 7/7] ci: per-binary timeout diagnostic --- .github/workflows/ci.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8b66e33..2f08152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,20 @@ jobs: - if: runner.os != 'Linux' run: cargo test --workspace - if: runner.os == 'Linux' - run: cargo test --workspace -- --nocapture --test-threads=1 2>&1 | tee /tmp/t.log; echo "LAST TEST BEFORE HANG:"; tail -5 /tmp/t.log + run: | + cargo test --workspace --no-run --message-format=json 2>/dev/null \ + | jq -r 'select(.profile.test==true) | .executable' > /tmp/bins.txt + cat /tmp/bins.txt + rc=0 + while read -r bin; do + [ -z "$bin" ] && continue + echo "=== RUNNING $bin ===" + if ! timeout 90 "$bin" --test-threads=1; then + echo "!!! HUNG OR FAILED: $bin !!!" + rc=1 + fi + done < /tmp/bins.txt + exit $rc msrv: runs-on: ubuntu-latest