From 19e93adf546d71319c7ade01e5339c4fc8a6246c Mon Sep 17 00:00:00 2001 From: Thibault Jaigu Date: Wed, 1 Jul 2026 16:31:32 +0100 Subject: [PATCH] Add Requesty as an LLM provider Mirror the existing OpenRouter provider to add Requesty (https://router.requesty.ai/v1), an OpenAI-compatible LLM router. Adds a dedicated requesty provider module, the Requesty ProviderConfig variant handled across all match arms, client/registry wiring, CLI config and onboarding entries, and login support. Signed-off-by: Thibault Jaigu --- cli/src/commands/auth/login.rs | 5 +- cli/src/config/app.rs | 4 +- cli/src/config/profile.rs | 9 ++ cli/src/onboarding/auth_flow.rs | 8 +- cli/src/onboarding/config_templates.rs | 47 +++++++++ libs/ai/src/client/builder.rs | 9 +- libs/ai/src/client/config.rs | 44 +++++++- libs/ai/src/providers/mod.rs | 2 + libs/ai/src/providers/requesty/mod.rs | 7 ++ libs/ai/src/providers/requesty/provider.rs | 109 +++++++++++++++++++ libs/ai/src/providers/requesty/types.rs | 44 ++++++++ libs/shared/src/models/llm.rs | 76 +++++++++++++- libs/shared/src/models/stakai_adapter.rs | 29 +++++ libs/shared/src/oauth/providers/mod.rs | 2 + libs/shared/src/oauth/providers/requesty.rs | 111 ++++++++++++++++++++ libs/shared/src/oauth/registry.rs | 3 +- 16 files changed, 500 insertions(+), 9 deletions(-) create mode 100644 libs/ai/src/providers/requesty/mod.rs create mode 100644 libs/ai/src/providers/requesty/provider.rs create mode 100644 libs/ai/src/providers/requesty/types.rs create mode 100644 libs/shared/src/oauth/providers/requesty.rs diff --git a/cli/src/commands/auth/login.rs b/cli/src/commands/auth/login.rs index 25ee844c..ed525a3e 100644 --- a/cli/src/commands/auth/login.rs +++ b/cli/src/commands/auth/login.rs @@ -4,7 +4,7 @@ use crate::config::{ProfileConfig, ProviderType}; use crate::onboarding::auth_flow::{AuthFlowConfig, run_provider_auth_flow}; use crate::onboarding::config_templates::{ generate_anthropic_profile, generate_gemini_profile, generate_github_copilot_profile, - generate_openai_profile, generate_openrouter_profile, + generate_openai_profile, generate_openrouter_profile, generate_requesty_profile, }; use crate::onboarding::menu::select_option_no_header; use crate::onboarding::navigation::NavResult; @@ -362,6 +362,7 @@ async fn handle_non_interactive_api_setup( "openai" => generate_openai_profile(), "gemini" => generate_gemini_profile(), "openrouter" => generate_openrouter_profile(), + "requesty" => generate_requesty_profile(), "github-copilot" => { return Err("GitHub Copilot does not support API key authentication.\n\ It requires OAuth via your GitHub account.\n\n\ @@ -374,7 +375,7 @@ async fn handle_non_interactive_api_setup( } _ => { return Err(format!( - "Unsupported provider '{}'. Supported: anthropic, openai, gemini, openrouter, stakpak, amazon-bedrock, github-copilot\n\ + "Unsupported provider '{}'. Supported: anthropic, openai, gemini, openrouter, requesty, stakpak, amazon-bedrock, github-copilot\n\ For bedrock, use: stakpak auth login --provider amazon-bedrock --region \n\ For github-copilot, run without --api-key to use the device flow.", provider_id diff --git a/cli/src/config/app.rs b/cli/src/config/app.rs index 798188a5..0937602f 100644 --- a/cli/src/config/app.rs +++ b/cli/src/config/app.rs @@ -455,6 +455,7 @@ impl AppConfig { "openai" => "OPENAI_API_KEY", "gemini" => "GEMINI_API_KEY", "openrouter" => "OPENROUTER_API_KEY", + "requesty" => "REQUESTY_API_KEY", _ => return None, }; @@ -856,7 +857,7 @@ impl AppConfig { } let config_provider = Some("Local".to_string()); - let builtin_providers = ["anthropic", "openai", "gemini", "openrouter"]; + let builtin_providers = ["anthropic", "openai", "gemini", "openrouter", "requesty"]; for provider_name in builtin_providers { if let Some(auth) = self.resolve_provider_auth(provider_name) { @@ -865,6 +866,7 @@ impl AppConfig { "openai" => "OpenAI", "gemini" => "Gemini", "openrouter" => "OpenRouter", + "requesty" => "Requesty", _ => provider_name, }; diff --git a/cli/src/config/profile.rs b/cli/src/config/profile.rs index 64ee536f..5b049c60 100644 --- a/cli/src/config/profile.rs +++ b/cli/src/config/profile.rs @@ -306,6 +306,15 @@ impl ProfileConfig { } } } + ProviderConfig::Requesty { api_endpoint, .. } => { + if let Some(ep) = api_endpoint { + let clean = Self::clean_api_endpoint(Some(ep.clone())); + if clean.as_ref() != Some(ep) { + *api_endpoint = clean; + cleaned = true; + } + } + } } } diff --git a/cli/src/onboarding/auth_flow.rs b/cli/src/onboarding/auth_flow.rs index 0461eeca..853778ab 100644 --- a/cli/src/onboarding/auth_flow.rs +++ b/cli/src/onboarding/auth_flow.rs @@ -4,6 +4,7 @@ use crate::onboarding::config_templates::{ BuiltinProvider, DEFAULT_MODEL, ProviderSetup, config_to_toml_preview, generate_anthropic_profile, generate_gemini_profile, generate_github_copilot_profile, generate_multi_provider_profile, generate_openai_profile, generate_openrouter_profile, + generate_requesty_profile, }; use crate::onboarding::menu::{ prompt_password, prompt_yes_no, select_option, select_option_no_header, @@ -355,6 +356,7 @@ async fn execute_hybrid_flow(config: &AuthFlowConfig) -> FlowOutcome { (BuiltinProvider::OpenAI, "OpenAI", false), (BuiltinProvider::Gemini, "Gemini", false), (BuiltinProvider::OpenRouter, "OpenRouter", false), + (BuiltinProvider::Requesty, "Requesty", false), ]; loop { @@ -518,6 +520,7 @@ fn render_default_model_for_provider(provider_id: &str) { "openai" => "gpt-4.1", "gemini" => "gemini-2.5-pro", "openrouter" => "openrouter/anthropic/claude-sonnet-4", + "requesty" => "requesty/anthropic/claude-sonnet-4", "github-copilot" => "github-copilot/gpt-4o", _ => return, }; @@ -584,7 +587,8 @@ fn provider_order_key(provider_id: &str, label: &str) -> (u8, String) { "openai" => 1, "gemini" => 2, "openrouter" => 3, - "github-copilot" => 4, + "requesty" => 4, + "github-copilot" => 5, _ => 100, }; (priority, label.to_string()) @@ -596,6 +600,7 @@ fn build_provider_profile(provider_id: &str) -> Result { "openai" => Ok(generate_openai_profile()), "gemini" => Ok(generate_gemini_profile()), "openrouter" => Ok(generate_openrouter_profile()), + "requesty" => Ok(generate_requesty_profile()), "github-copilot" => Ok(generate_github_copilot_profile()), other => Err(format!("Unsupported provider for auth flow: {}", other)), } @@ -653,6 +658,7 @@ mod tests { "OpenAI", "Google (Gemini)", "OpenRouter", + "Requesty", "GitHub Copilot", "Hybrid providers (e.g., Google and Anthropic)", "Bring your own model", diff --git a/cli/src/onboarding/config_templates.rs b/cli/src/onboarding/config_templates.rs index e89e5f7b..9eef8139 100644 --- a/cli/src/onboarding/config_templates.rs +++ b/cli/src/onboarding/config_templates.rs @@ -150,6 +150,24 @@ pub fn generate_openrouter_profile() -> ProfileConfig { profile } +/// Generate Requesty profile configuration (credentials stored separately in config.toml auth field) +pub fn generate_requesty_profile() -> ProfileConfig { + let mut profile = ProfileConfig { + provider: Some(ProviderType::Local), + model: Some("requesty/anthropic/claude-sonnet-4".to_string()), + ..ProfileConfig::default() + }; + profile.providers.insert( + "requesty".to_string(), + ProviderConfig::Requesty { + api_key: None, + api_endpoint: None, + auth: None, + }, + ); + profile +} + /// Generate custom provider profile configuration /// /// This creates a profile with a custom OpenAI-compatible provider (e.g., LiteLLM, Ollama). @@ -200,6 +218,7 @@ pub enum BuiltinProvider { Gemini, Anthropic, OpenRouter, + Requesty, } impl BuiltinProvider { @@ -209,6 +228,7 @@ impl BuiltinProvider { BuiltinProvider::Gemini => "Gemini", BuiltinProvider::Anthropic => "Anthropic", BuiltinProvider::OpenRouter => "OpenRouter", + BuiltinProvider::Requesty => "Requesty", } } @@ -218,6 +238,7 @@ impl BuiltinProvider { BuiltinProvider::Gemini => "gemini-2.5-pro", BuiltinProvider::Anthropic => DEFAULT_MODEL, BuiltinProvider::OpenRouter => "openrouter/anthropic/claude-sonnet-4", + BuiltinProvider::Requesty => "requesty/anthropic/claude-sonnet-4", } } } @@ -290,6 +311,16 @@ pub fn generate_multi_provider_profile( }, ); } + BuiltinProvider::Requesty => { + profile.providers.insert( + "requesty".to_string(), + ProviderConfig::Requesty { + api_key: Some(setup.api_key), + api_endpoint: None, + auth: None, + }, + ); + } } } @@ -442,6 +473,22 @@ pub fn config_to_toml_preview(profile: &ProfileConfig, profile_name: &str) -> St )); } } + ProviderConfig::Requesty { + api_key, + api_endpoint, + .. + } => { + toml.push_str("type = \"requesty\"\n"); + if let Some(endpoint) = api_endpoint { + toml.push_str(&format!("api_endpoint = \"{}\"\n", endpoint)); + } + if let Some(key) = api_key { + toml.push_str(&format!( + "api_key = \"{}\"\n", + if key.is_empty() { "" } else { "***" } + )); + } + } } } diff --git a/libs/ai/src/client/builder.rs b/libs/ai/src/client/builder.rs index 8c1ebc91..43f4eda4 100644 --- a/libs/ai/src/client/builder.rs +++ b/libs/ai/src/client/builder.rs @@ -5,7 +5,7 @@ use crate::error::Result; use crate::provider::Provider; use crate::providers::{ anthropic::AnthropicProvider, gemini::GeminiProvider, openai::OpenAIProvider, - openrouter::OpenRouterProvider, stakpak::StakpakProvider, + openrouter::OpenRouterProvider, requesty::RequestyProvider, stakpak::StakpakProvider, }; use crate::registry::ProviderRegistry; @@ -79,6 +79,13 @@ impl ClientBuilder { registry = registry.register("openrouter", provider); } + // Register Requesty if configured + if let Some(config) = inference_config.requesty_config + && let Ok(provider) = RequestyProvider::new(config) + { + registry = registry.register("requesty", provider); + } + // Register Bedrock if configured #[cfg(feature = "bedrock")] if let Some(config) = inference_config.bedrock_config { diff --git a/libs/ai/src/client/config.rs b/libs/ai/src/client/config.rs index 64e0916c..072c422a 100644 --- a/libs/ai/src/client/config.rs +++ b/libs/ai/src/client/config.rs @@ -2,7 +2,7 @@ use crate::providers::{ anthropic::AnthropicConfig, gemini::GeminiConfig, openai::OpenAIConfig, - openrouter::OpenRouterConfig, stakpak::StakpakProviderConfig, + openrouter::OpenRouterConfig, requesty::RequestyConfig, stakpak::StakpakProviderConfig, }; #[cfg(feature = "bedrock")] @@ -68,6 +68,7 @@ pub struct InferenceConfig { pub(crate) gemini_config: Option, pub(crate) stakpak_config: Option, pub(crate) openrouter_config: Option, + pub(crate) requesty_config: Option, #[cfg(feature = "bedrock")] pub(crate) bedrock_config: Option, pub(crate) client_config: ClientConfig, @@ -299,6 +300,47 @@ impl InferenceConfig { self } + /// Configure Requesty provider with API key and optional base URL + /// + /// # Example + /// + /// ```rust,no_run + /// # use stakai::InferenceConfig; + /// let config = InferenceConfig::new() + /// .requesty("sk-...", None); + /// + /// // With custom base URL + /// let config = InferenceConfig::new() + /// .requesty("sk-...", Some("https://custom.requesty.ai/v1".to_string())); + /// ``` + pub fn requesty(mut self, api_key: impl Into, base_url: Option) -> Self { + let mut config = RequestyConfig::new(api_key); + if let Some(url) = base_url { + config = config.with_base_url(url); + } + self.requesty_config = Some(config); + self + } + + /// Configure Requesty provider with full RequestyConfig + /// + /// # Example + /// + /// ```rust,no_run + /// # use stakai::{InferenceConfig, providers::requesty::RequestyConfig}; + /// let requesty_config = RequestyConfig::new("sk-...") + /// .with_base_url("https://custom.requesty.ai/v1") + /// .with_http_referer("https://example.com") + /// .with_site_title("My App"); + /// + /// let config = InferenceConfig::new() + /// .requesty_config(requesty_config); + /// ``` + pub fn requesty_config(mut self, config: RequestyConfig) -> Self { + self.requesty_config = Some(config); + self + } + /// Configure AWS Bedrock provider with a region /// /// Uses the AWS credential chain for authentication (env vars, shared credentials, diff --git a/libs/ai/src/providers/mod.rs b/libs/ai/src/providers/mod.rs index 3e8b9c11..1afffcba 100644 --- a/libs/ai/src/providers/mod.rs +++ b/libs/ai/src/providers/mod.rs @@ -7,6 +7,7 @@ pub mod copilot; pub mod gemini; pub mod openai; pub mod openrouter; +pub mod requesty; pub mod stakpak; pub(crate) mod tls; @@ -18,4 +19,5 @@ pub use copilot::{CopilotConfig, CopilotProvider}; pub use gemini::GeminiProvider; pub use openai::OpenAIProvider; pub use openrouter::{OpenRouterConfig, OpenRouterProvider}; +pub use requesty::{RequestyConfig, RequestyProvider}; pub use stakpak::StakpakProvider; diff --git a/libs/ai/src/providers/requesty/mod.rs b/libs/ai/src/providers/requesty/mod.rs new file mode 100644 index 00000000..afb99513 --- /dev/null +++ b/libs/ai/src/providers/requesty/mod.rs @@ -0,0 +1,7 @@ +//! Requesty provider + +mod provider; +pub mod types; + +pub use provider::RequestyProvider; +pub use types::RequestyConfig; diff --git a/libs/ai/src/providers/requesty/provider.rs b/libs/ai/src/providers/requesty/provider.rs new file mode 100644 index 00000000..5053e282 --- /dev/null +++ b/libs/ai/src/providers/requesty/provider.rs @@ -0,0 +1,109 @@ +use super::types::RequestyConfig; +use crate::error::{Error, Result}; +use crate::provider::Provider; +use crate::providers::openai::convert::{from_openai_response, to_openai_request}; +use crate::providers::openai::stream::create_completions_stream; +use crate::providers::openai::types::ChatCompletionResponse; +use crate::providers::tls::create_platform_tls_client; +use crate::types::{GenerateRequest, GenerateResponse, GenerateStream, Headers, Model}; +use async_trait::async_trait; +use reqwest::Client; +use reqwest_eventsource::EventSource; + +pub struct RequestyProvider { + config: RequestyConfig, + client: Client, +} + +impl RequestyProvider { + pub fn new(config: RequestyConfig) -> Result { + if config.api_key.is_empty() { + return Err(Error::MissingApiKey("requesty".to_string())); + } + let client = create_platform_tls_client()?; + Ok(Self { config, client }) + } +} + +#[async_trait] +impl Provider for RequestyProvider { + fn provider_id(&self) -> &str { + "requesty" + } + + fn build_headers(&self, custom_headers: Option<&Headers>) -> Headers { + let mut headers = Headers::new(); + + headers.insert("Authorization", format!("Bearer {}", self.config.api_key)); + headers.insert("Content-Type", "application/json"); + + if let Some(referer) = &self.config.http_referer { + headers.insert("HTTP-Referer", referer.clone()); + } + + if let Some(title) = &self.config.site_title { + headers.insert("X-Requesty-Title", title.clone()); + } + + if let Some(custom) = custom_headers { + headers.merge_with(custom); + } + + headers + } + + async fn generate(&self, request: GenerateRequest) -> Result { + let url = format!("{}/chat/completions", self.config.base_url); + let headers = self.build_headers(request.options.headers.as_ref()); + + let openai_req = to_openai_request(&request, false); + + let response = self + .client + .post(&url) + .headers(headers.to_reqwest_headers()) + .json(&openai_req) + .send() + .await + .map_err(|e| Error::provider_error(format!("Requesty request failed: {}", e)))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + return Err(Error::provider_error(format!( + "Requesty returned error {}: {}", + status, text + ))); + } + + let openai_resp: ChatCompletionResponse = response.json().await?; + from_openai_response(openai_resp) + } + + async fn stream(&self, request: GenerateRequest) -> Result { + let url = format!("{}/chat/completions", self.config.base_url); + let headers = self.build_headers(request.options.headers.as_ref()); + + let openai_req = to_openai_request(&request, true); + + let request_builder = self + .client + .post(&url) + .headers(headers.to_reqwest_headers()) + .json(&openai_req); + + let event_source = EventSource::new(request_builder) + .map_err(|e| Error::provider_error(format!("Failed to create event source: {}", e)))?; + + create_completions_stream(event_source).await + } + + async fn list_models(&self) -> Result> { + crate::registry::models_dev::load_models_for_provider("requesty") + } + + async fn get_model(&self, id: &str) -> Result> { + let models = crate::registry::models_dev::load_models_for_provider("requesty")?; + Ok(models.into_iter().find(|m| m.id == id)) + } +} diff --git a/libs/ai/src/providers/requesty/types.rs b/libs/ai/src/providers/requesty/types.rs new file mode 100644 index 00000000..a39f7bbc --- /dev/null +++ b/libs/ai/src/providers/requesty/types.rs @@ -0,0 +1,44 @@ +#[derive(Debug, Clone)] +pub struct RequestyConfig { + pub api_key: String, + pub base_url: String, + pub http_referer: Option, + pub site_title: Option, +} + +impl RequestyConfig { + pub fn new(api_key: impl Into) -> Self { + Self { + api_key: api_key.into(), + base_url: "https://router.requesty.ai/v1".to_string(), + http_referer: None, + site_title: None, + } + } + + pub fn with_base_url(mut self, base_url: impl Into) -> Self { + self.base_url = base_url.into(); + self + } + + pub fn with_http_referer(mut self, referer: impl Into) -> Self { + self.http_referer = Some(referer.into()); + self + } + + pub fn with_site_title(mut self, title: impl Into) -> Self { + self.site_title = Some(title.into()); + self + } +} + +impl Default for RequestyConfig { + fn default() -> Self { + Self { + api_key: std::env::var("REQUESTY_API_KEY").unwrap_or_else(|_| String::new()), + base_url: "https://router.requesty.ai/v1".to_string(), + http_referer: None, + site_title: None, + } + } +} diff --git a/libs/shared/src/models/llm.rs b/libs/shared/src/models/llm.rs index 928c4e63..bbc736dd 100644 --- a/libs/shared/src/models/llm.rs +++ b/libs/shared/src/models/llm.rs @@ -259,6 +259,36 @@ pub enum ProviderConfig { #[serde(skip_serializing_if = "Option::is_none")] auth: Option, }, + + /// Requesty provider configuration + /// + /// OpenAI-compatible router. Uses an API key with `Authorization: Bearer` + /// and the `provider/model` naming convention (e.g. `openai/gpt-4o-mini`). + /// + /// # Example TOML + /// ```toml + /// [profiles.myprofile.providers.requesty] + /// type = "requesty" + /// + /// [profiles.myprofile.providers.requesty.auth] + /// type = "api" + /// key = "sk-..." + /// + /// # Then use models as: + /// model = "requesty/anthropic/claude-sonnet-4" + /// ``` + #[serde(rename = "requesty")] + Requesty { + /// Legacy API key field (prefer `auth` field) + #[serde(skip_serializing_if = "Option::is_none")] + api_key: Option, + /// Optional custom API endpoint (defaults to https://router.requesty.ai/v1) + #[serde(skip_serializing_if = "Option::is_none")] + api_endpoint: Option, + /// Authentication credentials (preferred over api_key) + #[serde(skip_serializing_if = "Option::is_none")] + auth: Option, + }, } impl ProviderConfig { @@ -273,6 +303,7 @@ impl ProviderConfig { ProviderConfig::Bedrock { .. } => "amazon-bedrock", ProviderConfig::GitHubCopilot { .. } => "github-copilot", ProviderConfig::OpenRouter { .. } => "openrouter", + ProviderConfig::Requesty { .. } => "requesty", } } @@ -292,6 +323,7 @@ impl ProviderConfig { ProviderConfig::Custom { api_key, .. } => api_key.as_deref(), ProviderConfig::Stakpak { api_key, .. } => api_key.as_deref(), ProviderConfig::OpenRouter { api_key, .. } => api_key.as_deref(), + ProviderConfig::Requesty { api_key, .. } => api_key.as_deref(), ProviderConfig::Bedrock { .. } => None, // AWS credential chain, no API key ProviderConfig::GitHubCopilot { .. } => None, // OAuth only, no API key } @@ -308,6 +340,7 @@ impl ProviderConfig { ProviderConfig::Bedrock { .. } => None, ProviderConfig::GitHubCopilot { auth, .. } => auth.as_ref(), ProviderConfig::OpenRouter { auth, .. } => auth.as_ref(), + ProviderConfig::Requesty { auth, .. } => auth.as_ref(), } } @@ -329,7 +362,8 @@ impl ProviderConfig { | ProviderConfig::Gemini { api_key, .. } | ProviderConfig::Custom { api_key, .. } | ProviderConfig::Stakpak { api_key, .. } - | ProviderConfig::OpenRouter { api_key, .. } => { + | ProviderConfig::OpenRouter { api_key, .. } + | ProviderConfig::Requesty { api_key, .. } => { api_key.as_ref().map(ProviderAuth::api_key) } ProviderConfig::Anthropic { @@ -389,6 +423,14 @@ impl ProviderConfig { *auth_field = Some(auth); *api_key = None; } + ProviderConfig::Requesty { + auth: auth_field, + api_key, + .. + } => { + *auth_field = Some(auth); + *api_key = None; + } ProviderConfig::Anthropic { auth: auth_field, api_key, @@ -440,6 +482,11 @@ impl ProviderConfig { auth: auth_field, api_key, .. + } + | ProviderConfig::Requesty { + auth: auth_field, + api_key, + .. } => { *auth_field = None; *api_key = None; @@ -474,6 +521,7 @@ impl ProviderConfig { ProviderConfig::Custom { api_endpoint, .. } => Some(api_endpoint.as_str()), ProviderConfig::Stakpak { api_endpoint, .. } => api_endpoint.as_deref(), ProviderConfig::OpenRouter { api_endpoint, .. } => api_endpoint.as_deref(), + ProviderConfig::Requesty { api_endpoint, .. } => api_endpoint.as_deref(), ProviderConfig::Bedrock { .. } => None, // No custom endpoint in config ProviderConfig::GitHubCopilot { api_endpoint, .. } => api_endpoint.as_deref(), } @@ -490,7 +538,8 @@ impl ProviderConfig { | ProviderConfig::Gemini { api_endpoint, .. } | ProviderConfig::Stakpak { api_endpoint, .. } | ProviderConfig::GitHubCopilot { api_endpoint, .. } - | ProviderConfig::OpenRouter { api_endpoint, .. } => { + | ProviderConfig::OpenRouter { api_endpoint, .. } + | ProviderConfig::Requesty { api_endpoint, .. } => { *api_endpoint = endpoint; } ProviderConfig::Custom { api_endpoint, .. } => { @@ -630,6 +679,24 @@ impl ProviderConfig { } } + /// Create a Requesty provider config (legacy, uses api_key field) + pub fn requesty(api_key: Option, api_endpoint: Option) -> Self { + ProviderConfig::Requesty { + api_key, + api_endpoint, + auth: None, + } + } + + /// Create a Requesty provider config with auth + pub fn requesty_with_auth(auth: ProviderAuth, api_endpoint: Option) -> Self { + ProviderConfig::Requesty { + api_key: None, + api_endpoint, + auth: Some(auth), + } + } + /// Create a GitHub Copilot provider config with auth (OAuth token from device flow) pub fn github_copilot_with_auth(auth: ProviderAuth) -> Self { ProviderConfig::GitHubCopilot { @@ -698,6 +765,11 @@ impl ProviderConfig { api_endpoint: None, auth: None, }), + "requesty" => Some(ProviderConfig::Requesty { + api_key: None, + api_endpoint: None, + auth: None, + }), // Custom providers need an endpoint, Bedrock uses AWS credential chain _ => None, } diff --git a/libs/shared/src/models/stakai_adapter.rs b/libs/shared/src/models/stakai_adapter.rs index 46896c15..88633b7c 100644 --- a/libs/shared/src/models/stakai_adapter.rs +++ b/libs/shared/src/models/stakai_adapter.rs @@ -18,6 +18,7 @@ use stakai::{ StreamEvent, ThinkingOptions, Tool, ToolFunction, Usage, providers::anthropic::AnthropicConfig as StakaiAnthropicConfig, providers::openai::OpenAIConfig as StakaiOpenAIConfig, providers::openrouter::OpenRouterConfig, + providers::requesty::RequestyConfig, registry::ProviderRegistry, }; @@ -486,6 +487,17 @@ pub fn build_inference_config(config: &LLMProviderConfig) -> Result { + if let Some(api_key) = provider_config.api_key() { + let mut requesty_config = RequestyConfig::new(api_key.to_string()); + requesty_config = requesty_config.with_http_referer("https://stakpak.dev/"); + requesty_config = requesty_config.with_site_title("Stakpak"); + if let Some(endpoint) = api_endpoint { + requesty_config = requesty_config.with_base_url(endpoint.clone()); + } + inference_config = inference_config.requesty_config(requesty_config); + } + } ProviderConfig::GitHubCopilot { .. } => { tracing::debug!( provider = %name, @@ -534,6 +546,7 @@ fn build_provider_registry_direct(config: &LLMProviderConfig) -> Result Result { + if let Some(api_key) = provider_config.api_key() { + let mut requesty_config = RequestyConfig::new(api_key.to_string()); + requesty_config = requesty_config.with_http_referer("https://stakpak.dev/"); + requesty_config = requesty_config.with_site_title("Stakpak"); + if let Some(endpoint) = api_endpoint { + requesty_config = requesty_config.with_base_url(endpoint.clone()); + } + let provider = RequestyProvider::new(requesty_config).map_err( + |e: stakai::prelude::Error| { + format!("Failed to create Requesty provider: {}", e) + }, + )?; + registry = registry.register("requesty", provider); + } + } ProviderConfig::GitHubCopilot { api_endpoint, .. } => { if let Some(access_token) = provider_config.access_token() { let mut copilot_config = CopilotConfig::new(access_token.to_string()); diff --git a/libs/shared/src/oauth/providers/mod.rs b/libs/shared/src/oauth/providers/mod.rs index 6bbc2cc2..6c0164a7 100644 --- a/libs/shared/src/oauth/providers/mod.rs +++ b/libs/shared/src/oauth/providers/mod.rs @@ -5,6 +5,7 @@ mod gemini; mod github_copilot; mod openai_codex; mod openrouter; +mod requesty; mod stakpak; pub use anthropic::AnthropicProvider; @@ -12,4 +13,5 @@ pub use gemini::GeminiProvider; pub use github_copilot::GitHubCopilotProvider; pub use openai_codex::OpenAICodexProvider; pub use openrouter::OpenRouterProvider; +pub use requesty::RequestyProvider; pub use stakpak::StakpakProvider; diff --git a/libs/shared/src/oauth/providers/requesty.rs b/libs/shared/src/oauth/providers/requesty.rs new file mode 100644 index 00000000..70f4eb2e --- /dev/null +++ b/libs/shared/src/oauth/providers/requesty.rs @@ -0,0 +1,111 @@ +//! Requesty API key auth provider + +use crate::models::auth::ProviderAuth; +use crate::oauth::config::OAuthConfig; +use crate::oauth::error::{OAuthError, OAuthResult}; +use crate::oauth::flow::TokenResponse; +use crate::oauth::provider::{AuthMethod, OAuthProvider}; +use async_trait::async_trait; +use reqwest::header::HeaderMap; + +/// Requesty provider. +pub struct RequestyProvider; + +impl RequestyProvider { + /// Create a new Requesty provider. + pub fn new() -> Self { + Self + } +} + +impl Default for RequestyProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl OAuthProvider for RequestyProvider { + fn id(&self) -> &'static str { + "requesty" + } + + fn name(&self) -> &'static str { + "Requesty" + } + + fn auth_methods(&self) -> Vec { + vec![AuthMethod::api_key( + "api-key", + "API Key", + Some("Enter an existing Requesty API key".to_string()), + )] + } + + fn oauth_config(&self, _method_id: &str) -> Option { + None + } + + async fn post_authorize( + &self, + method_id: &str, + _tokens: &TokenResponse, + ) -> OAuthResult { + Err(OAuthError::unknown_method(method_id)) + } + + fn apply_auth_headers(&self, auth: &ProviderAuth, headers: &mut HeaderMap) -> OAuthResult<()> { + let api_key = match auth { + ProviderAuth::Api { key } => key, + ProviderAuth::OAuth { access, .. } => access, + }; + + headers.insert( + "authorization", + format!("Bearer {}", api_key) + .parse() + .map_err(|_| OAuthError::InvalidHeader)?, + ); + Ok(()) + } + + fn api_key_env_var(&self) -> Option<&'static str> { + Some("REQUESTY_API_KEY") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_id_name_and_methods() { + let provider = RequestyProvider::new(); + let methods = provider.auth_methods(); + + assert_eq!(provider.id(), "requesty"); + assert_eq!(provider.name(), "Requesty"); + assert_eq!(methods.len(), 1); + assert_eq!(methods[0].id, "api-key"); + } + + #[test] + fn test_oauth_config_is_not_supported() { + let provider = RequestyProvider::new(); + assert!(provider.oauth_config("api-key").is_none()); + } + + #[test] + fn test_apply_auth_headers_api_key() { + let provider = RequestyProvider::new(); + let auth = ProviderAuth::api_key("***"); + let mut headers = HeaderMap::new(); + + let result = provider.apply_auth_headers(&auth, &mut headers); + assert!(result.is_ok()); + assert_eq!( + headers.get("authorization"), + Some(&"Bearer ***".parse().expect("valid header")) + ); + } +} diff --git a/libs/shared/src/oauth/registry.rs b/libs/shared/src/oauth/registry.rs index 206cf84b..02108743 100644 --- a/libs/shared/src/oauth/registry.rs +++ b/libs/shared/src/oauth/registry.rs @@ -3,7 +3,7 @@ use super::provider::OAuthProvider; use super::providers::{ AnthropicProvider, GeminiProvider, GitHubCopilotProvider, OpenAICodexProvider, - OpenRouterProvider, StakpakProvider, + OpenRouterProvider, RequestyProvider, StakpakProvider, }; use std::collections::HashMap; @@ -25,6 +25,7 @@ impl ProviderRegistry { registry.register(Box::new(OpenAICodexProvider::new())); registry.register(Box::new(GeminiProvider::new())); registry.register(Box::new(OpenRouterProvider::new())); + registry.register(Box::new(RequestyProvider::new())); registry.register(Box::new(GitHubCopilotProvider::new())); registry