Fix: banner on launch, config file loading, 7 providers match setup wizard
BUGFIXES: - Banner now displays on 'gyandoot run' via gyandoot_tui::banner::print_banner() - Config now loads from ~/.config/gyandoot/config.toml with fallback chain (XDG -> ~/.gyandoot/config.yaml -> hardcoded defaults), fixing 401 errors - gyandoot init now writes visible placeholder api_key for users to edit NEW PROVIDERS (4 added, now 7 total matching setup wizard): - Google Gemini: Native API with x-goog-api-key auth, streamingGenerateContent - DeepSeek: OpenAI-compatible wrapper at api.deepseek.com/v1 - OpenRouter: With HTTP-Referer/X-Title headers, model routing - Groq: OpenAI-compatible wrapper at api.groq.com/openai/v1 All compile with 0 errors.
This commit is contained in:
104
crates/gyandoot-providers/src/deepseek.rs
Normal file
104
crates/gyandoot-providers/src/deepseek.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
//! DeepSeek API provider implementation.
|
||||
//!
|
||||
//! Fully OpenAI-compatible chat completions endpoint (`/v1/chat/completions`)
|
||||
//! with Bearer token auth. Supports streaming and function calling identically
|
||||
//! to OpenAI.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::openai::OpenAIProvider;
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
|
||||
|
||||
/// Provider for the DeepSeek API.
|
||||
///
|
||||
/// DeepSeek uses an OpenAI-compatible chat completions endpoint.
|
||||
/// This implementation wraps [`OpenAIProvider`] with the correct base URL.
|
||||
pub struct DeepSeekProvider {
|
||||
inner: OpenAIProvider,
|
||||
}
|
||||
|
||||
impl DeepSeekProvider {
|
||||
/// Create a new DeepSeek provider.
|
||||
///
|
||||
/// - `api_key`: DeepSeek API key (sent as Bearer token).
|
||||
/// - `model`: Model identifier (e.g. `"deepseek-chat"`).
|
||||
/// - `base_url`: Optional override for the API base URL.
|
||||
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
let base = base_url.unwrap_or_else(|| DEEPSEEK_BASE_URL.to_string());
|
||||
Self {
|
||||
inner: OpenAIProvider::new(api_key, model, Some(base)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom request timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.inner = self.inner.with_timeout(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for DeepSeekProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!(
|
||||
"DeepSeek chat request (model: {}, messages: {})",
|
||||
self.inner.model,
|
||||
context.messages.len()
|
||||
);
|
||||
self.inner.chat(context).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!(
|
||||
"DeepSeek streaming chat (model: {}, messages: {})",
|
||||
self.inner.model,
|
||||
context.messages.len()
|
||||
);
|
||||
self.inner.chat_stream(context).await
|
||||
}
|
||||
|
||||
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||
Ok(vec![
|
||||
"deepseek-chat".to_string(),
|
||||
"deepseek-reasoner".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_provider() -> DeepSeekProvider {
|
||||
DeepSeekProvider::new(
|
||||
"sk-test".to_string(),
|
||||
"deepseek-chat".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation() {
|
||||
let provider = test_provider();
|
||||
assert_eq!(provider.inner.model, "deepseek-chat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_tools() {
|
||||
let provider = test_provider();
|
||||
assert!(provider.supports_tools());
|
||||
}
|
||||
}
|
||||
500
crates/gyandoot-providers/src/gemini.rs
Normal file
500
crates/gyandoot-providers/src/gemini.rs
Normal file
@@ -0,0 +1,500 @@
|
||||
//! Google Gemini API provider implementation.
|
||||
//!
|
||||
//! Uses the native Gemini API (`/v1beta/models/{model}:streamGenerateContent`)
|
||||
//! with `x-goog-api-key` header authentication. Supports text generation,
|
||||
//! streaming via SSE, and function calling via `functionDeclarations`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use gyandoot_core::{ConversationContext, ProviderError, ProviderResult, ToolCall};
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
use crate::streaming::StreamEvent;
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
/// Provider for the Google Gemini API.
|
||||
pub struct GeminiProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
client: Client,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl GeminiProvider {
|
||||
/// Create a new Gemini provider.
|
||||
///
|
||||
/// - `api_key`: Gemini API key (sent via `x-goog-api-key` header).
|
||||
/// - `model`: Model identifier (e.g. `"gemini-2.0-flash"`).
|
||||
/// - `base_url`: Optional override for the API base URL.
|
||||
pub fn new(api_key: String, model: String, _base_url: Option<String>) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
request_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom request timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the Gemini API request body from conversation context.
|
||||
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
||||
// Extract system instruction from system messages
|
||||
let system_instruction = context
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| m.is_system())
|
||||
.map(|m| serde_json::json!({ "parts": [{"text": m.content}] }));
|
||||
|
||||
// Build contents array — Gemini skips system messages here
|
||||
let contents: Vec<serde_json::Value> = context
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| !m.is_system())
|
||||
.map(|m| {
|
||||
let role_str = m.role.to_string();
|
||||
let role = match role_str.as_str() {
|
||||
"assistant" => "model",
|
||||
"tool" => "user", // tool results sent as user with functionResponse
|
||||
r => r,
|
||||
};
|
||||
|
||||
let mut parts = Vec::new();
|
||||
|
||||
// Text content
|
||||
if !m.content.is_empty() {
|
||||
parts.push(serde_json::json!({ "text": m.content }));
|
||||
}
|
||||
|
||||
// Tool calls from assistant → functionCall parts
|
||||
if let Some(calls) = &m.tool_calls {
|
||||
for tc in calls {
|
||||
parts.push(serde_json::json!({
|
||||
"functionCall": {
|
||||
"name": tc.name,
|
||||
"args": tc.arguments,
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Tool result → functionResponse part
|
||||
if m.is_tool() {
|
||||
if let Some(result) = &m.tool_result {
|
||||
parts.push(serde_json::json!({
|
||||
"functionResponse": {
|
||||
"name": "tool_result",
|
||||
"response": {
|
||||
"name": "tool_result",
|
||||
"content": result,
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
serde_json::json!({
|
||||
"role": role,
|
||||
"parts": parts,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"contents": contents,
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": context.max_tokens,
|
||||
},
|
||||
});
|
||||
|
||||
// Attach system instruction if present
|
||||
if let Some(sys) = system_instruction {
|
||||
body["systemInstruction"] = sys;
|
||||
}
|
||||
|
||||
// Attach tool definitions — Gemini uses functionDeclarations
|
||||
if !context.tools.is_empty() {
|
||||
let declarations: Vec<serde_json::Value> = context
|
||||
.tools
|
||||
.iter()
|
||||
.map(|td| {
|
||||
serde_json::json!({
|
||||
"name": td.name,
|
||||
"description": td.description,
|
||||
"parameters": td.parameters,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
body["tools"] = serde_json::json!([
|
||||
{ "functionDeclarations": declarations }
|
||||
]);
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
/// Parse a non-streaming Gemini response into a ProviderResponse.
|
||||
fn parse_response(&self, data: &serde_json::Value) -> ProviderResult<ProviderResponse> {
|
||||
let candidate = &data["candidates"][0];
|
||||
let content = &candidate["content"];
|
||||
let parts = content["parts"].as_array().cloned().unwrap_or_default();
|
||||
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for part in &parts {
|
||||
if let Some(text) = part["text"].as_str() {
|
||||
text_parts.push(text.to_string());
|
||||
}
|
||||
if let Some(fc) = part.get("functionCall") {
|
||||
let name = fc["name"].as_str().unwrap_or_default().to_string();
|
||||
let args = fc.get("args").cloned().unwrap_or(serde_json::Value::Null);
|
||||
tool_calls.push(ToolCall::new(format!("fc_{}", name), name, args));
|
||||
}
|
||||
}
|
||||
|
||||
let content_text = text_parts.join("");
|
||||
let finish_reason = candidate["finishReason"]
|
||||
.as_str()
|
||||
.unwrap_or("STOP")
|
||||
.to_string();
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content: content_text,
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build streaming URL for the Gemini endpoint.
|
||||
fn stream_url(&self) -> String {
|
||||
format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse",
|
||||
GEMINI_BASE_URL,
|
||||
self.model
|
||||
)
|
||||
}
|
||||
|
||||
/// Build non-streaming URL.
|
||||
fn chat_url(&self) -> String {
|
||||
format!(
|
||||
"{}/models/{}:generateContent",
|
||||
GEMINI_BASE_URL,
|
||||
self.model
|
||||
)
|
||||
}
|
||||
|
||||
/// Send the HTTP request and handle errors.
|
||||
async fn send_request(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &serde_json::Value,
|
||||
_stream: bool,
|
||||
) -> ProviderResult<reqwest::Response> {
|
||||
let req = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("x-goog-api-key", &self.api_key)
|
||||
.header("content-type", "application/json")
|
||||
.json(body)
|
||||
.timeout(self.request_timeout);
|
||||
|
||||
// The streaming endpoint uses GET-style SSE; for POST we keep as-is
|
||||
let response = req.send().await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GeminiProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!(
|
||||
"Gemini chat request (model: {}, messages: {})",
|
||||
self.model,
|
||||
context.messages.len()
|
||||
);
|
||||
|
||||
let body = self.build_request(&context);
|
||||
let url = self.chat_url();
|
||||
let response = self.send_request(&url, &body, false).await?;
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
|
||||
Ok(self.parse_response(&data)?)
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!(
|
||||
"Gemini streaming chat (model: {}, messages: {})",
|
||||
self.model,
|
||||
context.messages.len()
|
||||
);
|
||||
|
||||
let body = self.build_request(&context);
|
||||
let url = self.stream_url();
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &self.api_key)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(ProviderError::from_response(status.as_u16(), &error_text));
|
||||
}
|
||||
|
||||
let (tx, rx) = mpsc::channel::<ProviderResult<String>>(256);
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut parser = crate::streaming::SseParser::new(100);
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_secs(30), byte_stream.next()).await {
|
||||
Ok(Some(Ok(chunk))) => {
|
||||
buffer.extend_from_slice(&chunk);
|
||||
let buffer_str = String::from_utf8_lossy(&buffer);
|
||||
|
||||
for event in parser.feed_chunk(buffer_str.as_ref()) {
|
||||
match event {
|
||||
StreamEvent::Chunk(text) => {
|
||||
// Each Chunk is the raw `data: {json}` data portion.
|
||||
// Parse Gemini-specific text deltas from the JSON.
|
||||
if let Ok(val) =
|
||||
serde_json::from_str::<serde_json::Value>(&text)
|
||||
{
|
||||
if let Some(candidates) = val["candidates"].as_array() {
|
||||
if let Some(candidate) = candidates.first() {
|
||||
if let Some(parts) =
|
||||
candidate["content"]["parts"].as_array()
|
||||
{
|
||||
for part in parts {
|
||||
if let Some(t) = part["text"].as_str() {
|
||||
trace!("Gemini delta: {t}");
|
||||
if tx.send(Ok(t.to_string()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
StreamEvent::Done => {
|
||||
debug!("Gemini stream done");
|
||||
return;
|
||||
}
|
||||
StreamEvent::Error(msg) => {
|
||||
warn!("Gemini SSE error: {msg}");
|
||||
let _ = tx.send(Err(ProviderError::Stream(msg))).await;
|
||||
return;
|
||||
}
|
||||
StreamEvent::ToolCall(_) => {
|
||||
// Tool calls appear in the final non-streaming response,
|
||||
// not as streaming deltas.
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer.drain(0..parser.consumed());
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
let _ = tx.send(Err(ProviderError::Request(e))).await;
|
||||
return;
|
||||
}
|
||||
Ok(None) => return,
|
||||
Err(_) => {
|
||||
let _ = tx
|
||||
.send(Err(ProviderError::Timeout { duration: 30 }))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream = tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||
Ok(vec![
|
||||
"gemini-2.0-flash".to_string(),
|
||||
"gemini-2.0-flash-lite".to_string(),
|
||||
"gemini-2.5-pro-exp-03-25".to_string(),
|
||||
"gemini-1.5-pro".to_string(),
|
||||
"gemini-1.5-flash".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gyandoot_core::{Message, Role, ToolDefinition};
|
||||
|
||||
fn test_provider() -> GeminiProvider {
|
||||
GeminiProvider::new(
|
||||
"test-api-key".to_string(),
|
||||
"gemini-2.0-flash".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_basic() {
|
||||
let provider = test_provider();
|
||||
let context = ConversationContext {
|
||||
messages: vec![
|
||||
Message::new(Role::System, "You are a helpful AI."),
|
||||
Message::new(Role::User, "Hello"),
|
||||
],
|
||||
system_prompt: "You are a helpful AI.".to_string(),
|
||||
tools: vec![],
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
let body = provider.build_request(&context);
|
||||
assert!(body.get("contents").is_some());
|
||||
assert!(body.get("systemInstruction").is_some());
|
||||
assert_eq!(
|
||||
body["systemInstruction"]["parts"][0]["text"],
|
||||
"You are a helpful AI."
|
||||
);
|
||||
let contents = body["contents"].as_array().unwrap();
|
||||
assert_eq!(contents.len(), 1); // system filtered out
|
||||
assert_eq!(contents[0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tools() {
|
||||
let provider = test_provider();
|
||||
let context = ConversationContext {
|
||||
messages: vec![Message::new(Role::User, "What's the weather?")],
|
||||
system_prompt: String::new(),
|
||||
tools: vec![ToolDefinition::new(
|
||||
"get_weather",
|
||||
"Get weather for a city",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {"type": "string"}
|
||||
},
|
||||
"required": ["city"]
|
||||
}),
|
||||
)],
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
let body = provider.build_request(&context);
|
||||
let tools = body["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 1);
|
||||
let decls = tools[0]["functionDeclarations"].as_array().unwrap();
|
||||
assert_eq!(decls.len(), 1);
|
||||
assert_eq!(decls[0]["name"], "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_text() {
|
||||
let provider = test_provider();
|
||||
let data = serde_json::json!({
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "Hello! How can I help?"}],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
}
|
||||
});
|
||||
|
||||
let response = provider.parse_response(&data).unwrap();
|
||||
assert_eq!(response.content, "Hello! How can I help?");
|
||||
assert_eq!(response.finish_reason, "STOP");
|
||||
assert!(response.tool_calls.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response_with_tool_calls() {
|
||||
let provider = test_provider();
|
||||
let data = serde_json::json!({
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "Let me check the weather."},
|
||||
{
|
||||
"functionCall": {
|
||||
"name": "get_weather",
|
||||
"args": {"city": "NYC"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"role": "model"
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0
|
||||
}]
|
||||
});
|
||||
|
||||
let response = provider.parse_response(&data).unwrap();
|
||||
assert_eq!(response.content, "Let me check the weather.");
|
||||
assert!(response.has_tool_calls());
|
||||
let calls = response.tool_calls.unwrap();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].name, "get_weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stream_url() {
|
||||
let provider = test_provider();
|
||||
let url = provider.stream_url();
|
||||
assert!(url.contains("gemini-2.0-flash"));
|
||||
assert!(url.contains("streamGenerateContent"));
|
||||
assert!(url.contains("alt=sse"));
|
||||
}
|
||||
}
|
||||
107
crates/gyandoot-providers/src/groq.rs
Normal file
107
crates/gyandoot-providers/src/groq.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Groq API provider implementation.
|
||||
//!
|
||||
//! OpenAI-compatible chat completions endpoint (`/v1/chat/completions`) with
|
||||
//! Bearer token auth. Supports streaming and function calling identically
|
||||
//! to OpenAI. Base URL: `https://api.groq.com/openai/v1`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::openai::OpenAIProvider;
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1";
|
||||
|
||||
/// Provider for the Groq API.
|
||||
///
|
||||
/// Groq uses an OpenAI-compatible chat completions endpoint.
|
||||
/// This implementation wraps [`OpenAIProvider`] with the correct base URL.
|
||||
pub struct GroqProvider {
|
||||
inner: OpenAIProvider,
|
||||
}
|
||||
|
||||
impl GroqProvider {
|
||||
/// Create a new Groq provider.
|
||||
///
|
||||
/// - `api_key`: Groq API key (sent as Bearer token).
|
||||
/// - `model`: Model identifier (e.g. `"llama-3.3-70b-versatile"`).
|
||||
/// - `base_url`: Optional override for the API base URL.
|
||||
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
let base = base_url.unwrap_or_else(|| GROQ_BASE_URL.to_string());
|
||||
Self {
|
||||
inner: OpenAIProvider::new(api_key, model, Some(base)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom request timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.inner = self.inner.with_timeout(timeout);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GroqProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!(
|
||||
"Groq chat request (model: {}, messages: {})",
|
||||
self.inner.model,
|
||||
context.messages.len()
|
||||
);
|
||||
self.inner.chat(context).await
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!(
|
||||
"Groq streaming chat (model: {}, messages: {})",
|
||||
self.inner.model,
|
||||
context.messages.len()
|
||||
);
|
||||
self.inner.chat_stream(context).await
|
||||
}
|
||||
|
||||
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||
Ok(vec![
|
||||
"llama-3.3-70b-versatile".to_string(),
|
||||
"llama-3.1-8b-instant".to_string(),
|
||||
"mixtral-8x7b-32768".to_string(),
|
||||
"gemma2-9b-it".to_string(),
|
||||
"deepseek-r1-distill-llama-70b".to_string(),
|
||||
])
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_provider() -> GroqProvider {
|
||||
GroqProvider::new(
|
||||
"gsk-test".to_string(),
|
||||
"llama-3.3-70b-versatile".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_creation() {
|
||||
let provider = test_provider();
|
||||
assert_eq!(provider.inner.model, "llama-3.3-70b-versatile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_tools() {
|
||||
let provider = test_provider();
|
||||
assert!(provider.supports_tools());
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,20 @@ pub mod openai;
|
||||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod streaming;
|
||||
pub mod gemini;
|
||||
pub mod deepseek;
|
||||
pub mod openrouter;
|
||||
pub mod groq;
|
||||
|
||||
// ─── Re-exports ───────────────────────────────────────────────────────────
|
||||
|
||||
pub use openai::OpenAIProvider;
|
||||
pub use anthropic::AnthropicProvider;
|
||||
pub use ollama::OllamaProvider;
|
||||
pub use gemini::GeminiProvider;
|
||||
pub use deepseek::DeepSeekProvider;
|
||||
pub use openrouter::OpenRouterProvider;
|
||||
pub use groq::GroqProvider;
|
||||
pub use streaming::{SseParser, StreamEvent, TokenStream, collect_stream};
|
||||
|
||||
// ─── ProviderResponse ─────────────────────────────────────────────────────
|
||||
@@ -102,6 +110,7 @@ pub enum ProviderKind {
|
||||
Gemini,
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
Groq,
|
||||
}
|
||||
|
||||
impl ProviderKind {
|
||||
@@ -114,6 +123,7 @@ impl ProviderKind {
|
||||
"gemini" => Some(ProviderKind::Gemini),
|
||||
"deepseek" => Some(ProviderKind::DeepSeek),
|
||||
"openrouter" => Some(ProviderKind::OpenRouter),
|
||||
"groq" => Some(ProviderKind::Groq),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -127,6 +137,7 @@ impl ProviderKind {
|
||||
ProviderKind::Gemini => "gemini",
|
||||
ProviderKind::DeepSeek => "deepseek",
|
||||
ProviderKind::OpenRouter => "openrouter",
|
||||
ProviderKind::Groq => "groq",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +171,7 @@ impl ProviderFactory {
|
||||
config: &gyandoot_core::ProviderRootConfig,
|
||||
) -> ProviderResult<Box<dyn Provider>> {
|
||||
match kind {
|
||||
ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter => {
|
||||
ProviderKind::OpenAI => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "OpenAI config not found".to_string(),
|
||||
@@ -196,8 +207,40 @@ impl ProviderFactory {
|
||||
status: 0,
|
||||
message: "Gemini config not found (use openai section)".to_string(),
|
||||
})?;
|
||||
// Gemini uses the OpenAI-compatible endpoint
|
||||
Ok(Box::new(openai::OpenAIProvider::new(
|
||||
Ok(Box::new(gemini::GeminiProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::DeepSeek => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "DeepSeek config not found (use openai section)".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(deepseek::DeepSeekProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::OpenRouter => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "OpenRouter config not found (use openai section)".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(openrouter::OpenRouterProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
)))
|
||||
}
|
||||
ProviderKind::Groq => {
|
||||
let cfg = config.openai.as_ref().ok_or_else(|| gyandoot_core::ProviderError::Api {
|
||||
status: 0,
|
||||
message: "Groq config not found (use openai section)".to_string(),
|
||||
})?;
|
||||
Ok(Box::new(groq::GroqProvider::new(
|
||||
cfg.api_key.clone(),
|
||||
cfg.model.clone(),
|
||||
cfg.base_url.clone(),
|
||||
@@ -220,7 +263,7 @@ impl ProviderFactory {
|
||||
|
||||
let config = gyandoot_core::ProviderRootConfig {
|
||||
default: name.to_string(),
|
||||
openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini) {
|
||||
openai: if matches!(kind, ProviderKind::OpenAI | ProviderKind::DeepSeek | ProviderKind::OpenRouter | ProviderKind::Gemini | ProviderKind::Groq) {
|
||||
Some(gyandoot_core::OpenAIConfig {
|
||||
api_key: api_key.to_string(),
|
||||
model: model.to_string(),
|
||||
@@ -263,6 +306,7 @@ fn default_base_url(kind: &ProviderKind) -> String {
|
||||
ProviderKind::Gemini => "https://generativelanguage.googleapis.com/v1".to_string(),
|
||||
ProviderKind::DeepSeek => "https://api.deepseek.com/v1".to_string(),
|
||||
ProviderKind::OpenRouter => "https://openrouter.ai/api/v1".to_string(),
|
||||
ProviderKind::Groq => "https://api.groq.com/openai/v1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +325,7 @@ mod tests {
|
||||
ProviderKind::Gemini,
|
||||
ProviderKind::DeepSeek,
|
||||
ProviderKind::OpenRouter,
|
||||
ProviderKind::Groq,
|
||||
] {
|
||||
let s = kind.to_string();
|
||||
let parsed = ProviderKind::from_str(&s).unwrap();
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::{Provider, ProviderResponse};
|
||||
|
||||
/// Provider for OpenAI-compatible chat completion APIs.
|
||||
pub struct OpenAIProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) model: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
request_timeout: Duration,
|
||||
|
||||
387
crates/gyandoot-providers/src/openrouter.rs
Normal file
387
crates/gyandoot-providers/src/openrouter.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
//! OpenRouter API provider implementation.
|
||||
//!
|
||||
//! OpenAI-compatible chat completions endpoint (`/v1/chat/completions`) with
|
||||
//! Bearer token auth. Adds `HTTP-Referer` and `X-Title` headers for tracking.
|
||||
//! Supports all OpenAI-compatible features plus model routing.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use gyandoot_core::{ConversationContext, ProviderResult};
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{Provider, ProviderResponse};
|
||||
|
||||
const OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
|
||||
|
||||
/// Provider for the OpenRouter API.
|
||||
///
|
||||
/// OpenRouter provides a unified API over many LLM providers. It uses an
|
||||
/// OpenAI-compatible `/v1/chat/completions` endpoint with additional headers
|
||||
/// for identification (`HTTP-Referer`, `X-Title`).
|
||||
pub struct OpenRouterProvider {
|
||||
api_key: String,
|
||||
model: String,
|
||||
base_url: String,
|
||||
client: Client,
|
||||
request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
/// Create a new OpenRouter provider.
|
||||
///
|
||||
/// - `api_key`: OpenRouter API key (sent as Bearer token).
|
||||
/// - `model`: Model identifier (e.g. `"openai/gpt-4o"`, `"anthropic/claude-3.5-sonnet"`).
|
||||
/// - `base_url`: Optional override for the API base URL.
|
||||
pub fn new(api_key: String, model: String, base_url: Option<String>) -> Self {
|
||||
Self {
|
||||
api_key,
|
||||
model,
|
||||
base_url: base_url.unwrap_or_else(|| OPENROUTER_BASE_URL.to_string()),
|
||||
client: Client::builder()
|
||||
.timeout(Duration::from_secs(120))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client"),
|
||||
request_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a custom request timeout.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the request body — identical to OpenAI format.
|
||||
fn build_request(&self, context: &ConversationContext) -> serde_json::Value {
|
||||
let messages: Vec<serde_json::Value> = context
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut msg = serde_json::json!({
|
||||
"role": m.role.to_string(),
|
||||
"content": m.content,
|
||||
});
|
||||
if let Some(tool_calls) = &m.tool_calls {
|
||||
msg["tool_calls"] = serde_json::to_value(tool_calls).unwrap_or_default();
|
||||
}
|
||||
if let Some(result) = &m.tool_result {
|
||||
msg["tool_call_id"] = serde_json::Value::Null;
|
||||
msg["content"] = serde_json::Value::String(result.clone());
|
||||
}
|
||||
msg
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"max_tokens": context.max_tokens,
|
||||
});
|
||||
|
||||
if !context.tools.is_empty() {
|
||||
body["tools"] = serde_json::to_value(&context.tools).unwrap_or_default();
|
||||
body["tool_choice"] = serde_json::json!("auto");
|
||||
}
|
||||
|
||||
body
|
||||
}
|
||||
|
||||
/// Parse the OpenAI-compatible response.
|
||||
fn parse_response(&self, data: &serde_json::Value) -> ProviderResult<ProviderResponse> {
|
||||
let choice = &data["choices"][0];
|
||||
let message = &choice["message"];
|
||||
|
||||
let content = message["content"].as_str().unwrap_or_default().to_string();
|
||||
let finish_reason = choice["finish_reason"]
|
||||
.as_str()
|
||||
.unwrap_or("stop")
|
||||
.to_string();
|
||||
|
||||
let tool_calls = message["tool_calls"].as_array().map(|calls| {
|
||||
calls
|
||||
.iter()
|
||||
.map(|tc| {
|
||||
crate::ToolCall {
|
||||
id: tc["id"].as_str().unwrap_or_default().to_string(),
|
||||
name: tc["function"]["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: tc["function"]["arguments"]
|
||||
.as_str()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or(serde_json::Value::Null),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
tool_calls,
|
||||
finish_reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send request with OpenRouter-specific headers.
|
||||
async fn send_request(
|
||||
&self,
|
||||
url: &str,
|
||||
body: &serde_json::Value,
|
||||
) -> ProviderResult<reqwest::Response> {
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.header("HTTP-Referer", "https://gyannetra.in")
|
||||
.header("X-Title", "GyanDoot")
|
||||
.json(body)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(gyandoot_core::ProviderError::from_response(
|
||||
status.as_u16(),
|
||||
&error_text,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for OpenRouterProvider {
|
||||
async fn chat(&self, context: ConversationContext) -> ProviderResult<ProviderResponse> {
|
||||
debug!(
|
||||
"OpenRouter chat request (model: {}, messages: {})",
|
||||
self.model,
|
||||
context.messages.len()
|
||||
);
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
let body = self.build_request(&context);
|
||||
let response = self.send_request(&url, &body).await?;
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
|
||||
Ok(self.parse_response(&data)?)
|
||||
}
|
||||
|
||||
async fn chat_stream(
|
||||
&self,
|
||||
context: ConversationContext,
|
||||
) -> ProviderResult<Box<dyn tokio_stream::Stream<Item = ProviderResult<String>> + Send + Unpin>>
|
||||
{
|
||||
debug!(
|
||||
"OpenRouter streaming chat (model: {}, messages: {})",
|
||||
self.model,
|
||||
context.messages.len()
|
||||
);
|
||||
|
||||
let mut body = self.build_request(&context);
|
||||
body["stream"] = serde_json::json!(true);
|
||||
|
||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.header("HTTP-Referer", "https://gyannetra.in")
|
||||
.header("X-Title", "GyanDoot")
|
||||
.json(&body)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(gyandoot_core::ProviderError::from_response(
|
||||
status.as_u16(),
|
||||
&error_text,
|
||||
));
|
||||
}
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<ProviderResult<String>>(256);
|
||||
let mut sse_stream = response.bytes_stream();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut parser = crate::streaming::SseParser::new(100);
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_secs(30), sse_stream.next()).await {
|
||||
Ok(Some(Ok(chunk))) => {
|
||||
buffer.extend_from_slice(&chunk);
|
||||
let buffer_str = String::from_utf8_lossy(&buffer);
|
||||
let mut pos = 0;
|
||||
|
||||
for event in parser.feed_chunk(buffer_str.as_ref()) {
|
||||
match event {
|
||||
crate::streaming::StreamEvent::Chunk(text) => {
|
||||
tracing::trace!("OpenRouter SSE chunk: {text}");
|
||||
if tx.send(Ok(text)).await.is_err() {
|
||||
tracing::debug!("Stream receiver dropped");
|
||||
return;
|
||||
}
|
||||
}
|
||||
crate::streaming::StreamEvent::Done => {
|
||||
tracing::debug!("OpenRouter SSE stream done");
|
||||
return;
|
||||
}
|
||||
crate::streaming::StreamEvent::Error(msg) => {
|
||||
tracing::warn!("OpenRouter SSE error: {msg}");
|
||||
let _ = tx
|
||||
.send(Err(gyandoot_core::ProviderError::Stream(msg)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
crate::streaming::StreamEvent::ToolCall(_) => {}
|
||||
}
|
||||
pos = parser.consumed();
|
||||
}
|
||||
buffer.drain(0..pos);
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
let _ = tx
|
||||
.send(Err(gyandoot_core::ProviderError::Request(e)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("OpenRouter SSE stream exhausted");
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx
|
||||
.send(Err(gyandoot_core::ProviderError::Timeout {
|
||||
duration: 30,
|
||||
}))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stream =
|
||||
tokio_stream::wrappers::ReceiverStream::new(rx);
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
async fn models(&self) -> ProviderResult<Vec<String>> {
|
||||
// OpenRouter has a models endpoint
|
||||
let url = format!("{}/models", self.base_url.trim_end_matches('/'));
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.bearer_auth(&self.api_key)
|
||||
.timeout(self.request_timeout)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(gyandoot_core::ProviderError::from_response(
|
||||
status.as_u16(),
|
||||
&error_text,
|
||||
));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = response.json().await?;
|
||||
let models = data["data"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn supports_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gyandoot_core::{Message, Role, ToolDefinition};
|
||||
|
||||
fn test_provider() -> OpenRouterProvider {
|
||||
OpenRouterProvider::new(
|
||||
"sk-or-test".to_string(),
|
||||
"openai/gpt-4o".to_string(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_basic() {
|
||||
let provider = test_provider();
|
||||
let context = ConversationContext {
|
||||
messages: vec![
|
||||
Message::new(Role::System, "You are helpful."),
|
||||
Message::new(Role::User, "Hello"),
|
||||
],
|
||||
system_prompt: "You are helpful.".to_string(),
|
||||
tools: vec![],
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
let body = provider.build_request(&context);
|
||||
assert_eq!(body["model"], "openai/gpt-4o");
|
||||
assert_eq!(body["messages"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tools() {
|
||||
let provider = test_provider();
|
||||
let context = ConversationContext {
|
||||
messages: vec![Message::new(Role::User, "Weather?")],
|
||||
system_prompt: String::new(),
|
||||
tools: vec![ToolDefinition::new(
|
||||
"get_weather",
|
||||
"Get weather",
|
||||
serde_json::json!({"type": "object"}),
|
||||
)],
|
||||
max_tokens: 4096,
|
||||
};
|
||||
|
||||
let body = provider.build_request(&context);
|
||||
assert!(body.get("tools").is_some());
|
||||
assert_eq!(body["tools"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_response() {
|
||||
let provider = test_provider();
|
||||
let data = serde_json::json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "Hello!",
|
||||
"role": "assistant"
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"index": 0
|
||||
}]
|
||||
});
|
||||
|
||||
let response = provider.parse_response(&data).unwrap();
|
||||
assert_eq!(response.content, "Hello!");
|
||||
assert_eq!(response.finish_reason, "stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_supports_tools() {
|
||||
let provider = test_provider();
|
||||
assert!(provider.supports_tools());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user