Skip to main content

rab/
adapter.rs

1use crate::agent::provider::{Provider, StreamEvent, ToolDef};
2use crate::agent::types::{AgentMessage, Role, ToolCall};
3use crate::auth::AuthStorage;
4use async_trait::async_trait;
5use futures::{Stream, StreamExt};
6use genai::chat::{
7    ChatMessage, ChatOptions, ChatRequest, ContentPart, MessageContent, ReasoningEffort, Tool,
8    ToolCall as GenaiToolCall, ToolResponse,
9};
10use genai::resolver::{AuthData, AuthResolver};
11use std::pin::Pin;
12use std::sync::RwLock;
13
14/// Build a reqwest::Client that uses webpki-roots (embedded Mozilla CA list)
15/// instead of rustls-platform-verifier, which panics on Android/Termux
16/// because it requires JNI initialization.
17fn build_reqwest_client() -> reqwest::Client {
18    let mut root_store = rustls::RootCertStore::empty();
19    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
20    let tls_config = rustls::ClientConfig::builder()
21        .with_root_certificates(root_store)
22        .with_no_client_auth();
23    reqwest::Client::builder()
24        .tls_backend_preconfigured(tls_config)
25        .timeout(std::time::Duration::from_secs(300)) // overall request timeout
26        .connect_timeout(std::time::Duration::from_secs(30))
27        .build()
28        .expect("Failed to build reqwest client")
29}
30
31pub struct GenaiProvider {
32    client: genai::Client,
33    model_prefix: String,
34    reasoning_effort: RwLock<Option<ReasoningEffort>>,
35}
36
37impl GenaiProvider {
38    pub fn new(auth: &AuthStorage, thinking_level: Option<&str>) -> anyhow::Result<Self> {
39        let api_key = auth
40            .api_key("opencode-go")
41            .ok_or_else(|| anyhow::anyhow!("No API key found for opencode_go in auth.json"))?;
42
43        let auth_resolver = AuthResolver::from_resolver_fn(move |_model_iden: genai::ModelIden| {
44            Ok(Some(AuthData::from_single(api_key.clone())))
45        });
46
47        let reqwest_client = build_reqwest_client();
48        let client = genai::Client::builder()
49            .with_reqwest(reqwest_client)
50            .with_auth_resolver(auth_resolver)
51            .build();
52
53        // Reasoning effort mapping:
54        // - When thinking_level is None (not configured), default to High (highest
55        //   commonly supported value across OpenAI-compatible providers).
56        // - When "off" or "none", set to None so the parameter is omitted entirely
57        //   ("none" is not widely supported and causes 400 errors with DeepSeek).
58        // - Values beyond "high" ("xhigh", "max") are clamped to "high" since few
59        //   providers support them.
60        // - "minimal" maps to "low" for the same reason.
61        // - Any unrecognized value also defaults to High.
62        let reasoning_effort = match thinking_level {
63            Some("off" | "none") => None,
64            Some("minimal" | "low") => Some(ReasoningEffort::Low),
65            Some("medium") => Some(ReasoningEffort::Medium),
66            _ => Some(ReasoningEffort::High), // None, xhigh, max, or unknown → highest commonly supported
67        };
68
69        Ok(Self {
70            client,
71            model_prefix: "opencode_go::".into(),
72            reasoning_effort: RwLock::new(reasoning_effort),
73        })
74    }
75
76    fn full_model(&self, model: &str) -> String {
77        if model.contains("::") {
78            model.to_string()
79        } else {
80            format!("{}{}", self.model_prefix, model)
81        }
82    }
83
84    /// Convert a thinking level string to ReasoningEffort.
85    fn thinking_level_to_effort(level: Option<&str>) -> Option<ReasoningEffort> {
86        match level {
87            Some("off" | "none") => None,
88            Some("minimal" | "low") => Some(ReasoningEffort::Low),
89            Some("medium") => Some(ReasoningEffort::Medium),
90            _ => Some(ReasoningEffort::High), // None, xhigh, max, or unknown
91        }
92    }
93
94    fn convert_messages(messages: &[AgentMessage]) -> Vec<ChatMessage> {
95        messages
96            .iter()
97            .map(|m| match m.role {
98                Role::User => ChatMessage::user(&m.content),
99                Role::Assistant => {
100                    let mut parts: Vec<ContentPart> = Vec::new();
101
102                    // Include text content if present (supports models that emit both
103                    // text and tool calls in the same assistant turn)
104                    if !m.content.is_empty() {
105                        parts.push(ContentPart::from_text(&m.content));
106                    }
107
108                    for tc in &m.tool_calls {
109                        parts.push(ContentPart::ToolCall(GenaiToolCall {
110                            call_id: tc.id.clone(),
111                            fn_name: tc.name.clone(),
112                            fn_arguments: tc.arguments.clone(),
113                            thought_signatures: None,
114                        }));
115                    }
116
117                    ChatMessage::assistant(MessageContent::from_parts(parts))
118                }
119                Role::ToolResult => ChatMessage::from(ToolResponse::new(
120                    m.tool_call_id.clone().unwrap_or_default(),
121                    &m.content,
122                )),
123            })
124            .collect()
125    }
126
127    fn convert_tools(tools: &[ToolDef]) -> Vec<Tool> {
128        tools
129            .iter()
130            .map(|t| {
131                Tool::new(&t.name)
132                    .with_description(&t.description)
133                    .with_schema(t.parameters.clone())
134            })
135            .collect()
136    }
137}
138
139#[async_trait]
140impl Provider for GenaiProvider {
141    async fn stream(
142        &self,
143        model: &str,
144        system_prompt: &str,
145        messages: &[AgentMessage],
146        tools: &[ToolDef],
147    ) -> anyhow::Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>> {
148        let full_model = self.full_model(model);
149        let chat_messages = Self::convert_messages(messages);
150        let genai_tools = Self::convert_tools(tools);
151
152        let mut req = ChatRequest::new(chat_messages).with_system(system_prompt);
153        if !genai_tools.is_empty() {
154            req = req.with_tools(genai_tools);
155        }
156
157        let mut options = ChatOptions::default()
158            .with_capture_usage(true)
159            .with_capture_content(true)
160            .with_capture_tool_calls(true);
161
162        if let Ok(guard) = self.reasoning_effort.read()
163            && let Some(ref effort) = *guard
164        {
165            options = options.with_reasoning_effort(effort.clone());
166        }
167
168        let genai_response = self
169            .client
170            .exec_chat_stream(&full_model, req, Some(&options))
171            .await?;
172
173        let mut genai_stream = genai_response.stream;
174
175        let stream = async_stream::stream! {
176            while let Some(result) = genai_stream.next().await {
177                match result {
178                    Ok(event) => {
179                        match event {
180                            genai::chat::ChatStreamEvent::Start => {},
181                            genai::chat::ChatStreamEvent::Chunk(chunk) => {
182                                yield StreamEvent::TextDelta { text: chunk.content };
183                            }
184                            genai::chat::ChatStreamEvent::ReasoningChunk(chunk) => {
185                                yield StreamEvent::ThinkingDelta { text: chunk.content };
186                            }
187                            genai::chat::ChatStreamEvent::ThoughtSignatureChunk(_) => {},
188                            genai::chat::ChatStreamEvent::ToolCallChunk(tool_chunk) => {
189                                let tc = &tool_chunk.tool_call;
190                                yield StreamEvent::ToolCall {
191                                    id: tc.call_id.clone(),
192                                    name: tc.fn_name.clone(),
193                                    arguments: serde_json::to_string(&tc.fn_arguments)
194                                        .unwrap_or_default(),
195                                };
196                            }
197                            genai::chat::ChatStreamEvent::End(end) => {
198                                let text = end.captured_first_text().unwrap_or("").to_string();
199                                let tool_calls: Vec<ToolCall> = end
200                                    .captured_tool_calls()
201                                    .into_iter()
202                                    .flatten()
203                                    .map(|tc| ToolCall {
204                                        id: tc.call_id.clone(),
205                                        name: tc.fn_name.clone(),
206                                        arguments: tc.fn_arguments.clone(),
207                                    })
208                                    .collect();
209
210                                let usage = crate::agent::types::Usage {
211                                    input_tokens: end.captured_usage.as_ref()
212                                        .and_then(|u| u.prompt_tokens),
213                                    output_tokens: end.captured_usage.as_ref()
214                                        .and_then(|u| u.completion_tokens),
215                                    cache_tokens: None,
216                                };
217
218                                let stop_reason = match &end.captured_stop_reason {
219                                    Some(genai::chat::StopReason::Completed(_)) => crate::agent::provider::StopReason::EndTurn,
220                                    Some(genai::chat::StopReason::ToolCall(_)) => crate::agent::provider::StopReason::ToolUse,
221                                    Some(genai::chat::StopReason::MaxTokens(_)) => crate::agent::provider::StopReason::MaxTokens,
222                                    _ => crate::agent::provider::StopReason::EndTurn,
223                                };
224
225                                yield StreamEvent::Done {
226                                    text,
227                                    usage,
228                                    stop_reason,
229                                    tool_calls,
230                                };
231                            }
232                        }
233                    }
234                    Err(e) => {
235                        yield StreamEvent::Error {
236                            message: format!("{:#}", e),
237                        };
238                    }
239                }
240            }
241        };
242
243        Ok(Box::pin(stream))
244    }
245
246    fn set_reasoning_effort(&self, level: Option<&str>) {
247        let effort = Self::thinking_level_to_effort(level);
248        if let Ok(mut guard) = self.reasoning_effort.write() {
249            *guard = effort;
250        }
251    }
252}