diff --git a/src-tauri/src/commands/gemini.rs b/src-tauri/src/commands/gemini.rs new file mode 100644 index 0000000..2e6886e --- /dev/null +++ b/src-tauri/src/commands/gemini.rs @@ -0,0 +1,284 @@ +//! Gemini HTTP transport — runs the Generative Language API call from Rust +//! instead of the WebKit webview's `fetch`. The webview drops large/slow +//! requests with a generic "Load failed" error; `reqwest` does not. Retries +//! transient failures (network errors, HTTP 429, 5xx) with backoff. +//! +//! Reuses the shared request primitives from [`super::simplicate`] +//! (`PreparedRequest`, `HttpSender`, `HttpResponse`, `ReqwestSender`). + +use serde::Deserialize; + +use super::simplicate::{HttpSender, PreparedRequest}; + +/// Arguments for a Gemini `generateContent` call. The URL already carries the +/// API key as a query parameter (built on the frontend from the bundled env), +/// so no auth headers are needed. +#[derive(Deserialize)] +pub struct GeminiRequestArgs { + pub url: String, + pub body: String, +} + +/// Maximum number of retries *after* the initial attempt. +const MAX_RETRIES: u32 = 3; + +/// Build the outgoing Gemini request: always a JSON POST, no auth headers. +pub fn build_gemini_request(args: &GeminiRequestArgs) -> PreparedRequest { + PreparedRequest { + method: "POST".to_string(), + url: args.url.clone(), + headers: vec![("Content-Type".to_string(), "application/json".to_string())], + body: Some(args.body.clone()), + } +} + +/// Injectable delay so the retry/backoff loop is unit-testable without waiting. +#[allow(async_fn_in_trait)] +pub trait Sleeper { + async fn sleep(&self, secs: u64); +} + +/// Real sleeper backed by tokio. +pub struct TokioSleeper; + +impl Sleeper for TokioSleeper { + async fn sleep(&self, secs: u64) { + tokio::time::sleep(std::time::Duration::from_secs(secs)).await; + } +} + +/// Exponential backoff: 2s, 4s, 8s for attempts 0, 1, 2. +fn backoff_secs(attempt: u32) -> u64 { + 2u64.saturating_mul(2u64.saturating_pow(attempt)) +} + +/// Extract the integer seconds from a Gemini 429 body's +/// `"retryDelay":"s"` field, if present. +fn parse_retry_delay(body: &str) -> Option { + let key = "\"retryDelay\":\""; + let start = body.find(key)? + key.len(); + let rest = &body[start..]; + let end = rest.find('s')?; + rest[..end].parse::().ok() +} + +/// Transient statuses worth retrying. +fn is_retryable_status(status: u16) -> bool { + status == 429 || (500..600).contains(&status) +} + +/// Orchestrate a Gemini request, retrying transient failures (network errors, +/// 429, 5xx) with backoff. Pure of any concrete transport/clock so it can be +/// driven by fakes in tests. +pub async fn run_gemini_request( + sender: &S, + sleeper: &T, + args: &GeminiRequestArgs, +) -> Result { + let req = build_gemini_request(args); + let mut attempt: u32 = 0; + + loop { + match sender.send(&req).await { + Ok(resp) if (200..300).contains(&resp.status) => return Ok(resp.body), + Ok(resp) => { + if is_retryable_status(resp.status) && attempt < MAX_RETRIES { + let delay = if resp.status == 429 { + parse_retry_delay(&resp.body).unwrap_or_else(|| backoff_secs(attempt)) + } else { + backoff_secs(attempt) + }; + sleeper.sleep(delay).await; + attempt += 1; + continue; + } + return Err(format!("Gemini API error: {} — {}", resp.status, resp.body)); + } + Err(e) => { + if attempt < MAX_RETRIES { + sleeper.sleep(backoff_secs(attempt)).await; + attempt += 1; + continue; + } + return Err(format!("Request failed: {}", e)); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::simplicate::HttpResponse; + use std::cell::RefCell; + + fn args() -> GeminiRequestArgs { + GeminiRequestArgs { + url: "https://gemini.test/v1/models/x:generateContent?key=K".to_string(), + body: "{\"contents\":[]}".to_string(), + } + } + + #[test] + fn build_request_is_json_post_without_auth() { + let req = build_gemini_request(&args()); + assert_eq!(req.method, "POST"); + assert_eq!( + req.url, + "https://gemini.test/v1/models/x:generateContent?key=K" + ); + assert_eq!(req.body.as_deref(), Some("{\"contents\":[]}")); + assert_eq!( + req.headers, + vec![("Content-Type".to_string(), "application/json".to_string())] + ); + } + + #[test] + fn parse_retry_delay_reads_seconds() { + assert_eq!(parse_retry_delay("{\"retryDelay\":\"7s\"}"), Some(7)); + assert_eq!(parse_retry_delay("no delay here"), None); + assert_eq!(parse_retry_delay("{\"retryDelay\":\"xs\"}"), None); + } + + #[test] + fn backoff_grows_exponentially() { + assert_eq!(backoff_secs(0), 2); + assert_eq!(backoff_secs(1), 4); + assert_eq!(backoff_secs(2), 8); + } + + /// Sender that yields queued outcomes in order and counts its calls. + struct QueueSender { + outcomes: RefCell>>, + calls: RefCell, + } + impl QueueSender { + fn new(outcomes: Vec>) -> Self { + Self { + outcomes: RefCell::new(outcomes), + calls: RefCell::new(0), + } + } + } + impl HttpSender for QueueSender { + async fn send(&self, _req: &PreparedRequest) -> Result { + *self.calls.borrow_mut() += 1; + self.outcomes.borrow_mut().remove(0) + } + } + + /// Sleeper that records requested delays without actually waiting. + struct RecordingSleeper { + delays: RefCell>, + } + impl RecordingSleeper { + fn new() -> Self { + Self { + delays: RefCell::new(vec![]), + } + } + } + impl Sleeper for RecordingSleeper { + async fn sleep(&self, secs: u64) { + self.delays.borrow_mut().push(secs); + } + } + + fn ok(body: &str) -> Result { + Ok(HttpResponse { + status: 200, + body: body.to_string(), + }) + } + fn status(code: u16, body: &str) -> Result { + Ok(HttpResponse { + status: code, + body: body.to_string(), + }) + } + + #[tokio::test] + async fn returns_body_on_first_success() { + let sender = QueueSender::new(vec![ok("{\"candidates\":[]}")]); + let sleeper = RecordingSleeper::new(); + let out = run_gemini_request(&sender, &sleeper, &args()).await.unwrap(); + assert_eq!(out, "{\"candidates\":[]}"); + assert_eq!(*sender.calls.borrow(), 1); + assert!(sleeper.delays.borrow().is_empty()); + } + + #[tokio::test] + async fn retries_on_429_then_succeeds_honoring_retry_delay() { + let sender = QueueSender::new(vec![status(429, "{\"retryDelay\":\"3s\"}"), ok("[]")]); + let sleeper = RecordingSleeper::new(); + let out = run_gemini_request(&sender, &sleeper, &args()).await.unwrap(); + assert_eq!(out, "[]"); + assert_eq!(*sender.calls.borrow(), 2); + assert_eq!(*sleeper.delays.borrow(), vec![3]); + } + + #[tokio::test] + async fn retries_on_5xx_with_exponential_backoff_then_succeeds() { + let sender = QueueSender::new(vec![status(500, "boom"), status(503, "again"), ok("[]")]); + let sleeper = RecordingSleeper::new(); + let out = run_gemini_request(&sender, &sleeper, &args()).await.unwrap(); + assert_eq!(out, "[]"); + assert_eq!(*sleeper.delays.borrow(), vec![2, 4]); + } + + #[tokio::test] + async fn exhausts_retries_on_persistent_429() { + let sender = QueueSender::new(vec![ + status(429, "x"), + status(429, "x"), + status(429, "x"), + status(429, "x"), + ]); + let sleeper = RecordingSleeper::new(); + let err = run_gemini_request(&sender, &sleeper, &args()) + .await + .unwrap_err(); + assert_eq!(err, "Gemini API error: 429 — x"); + assert_eq!(*sender.calls.borrow(), 4); // initial + 3 retries + assert_eq!(sleeper.delays.borrow().len(), 3); + } + + #[tokio::test] + async fn does_not_retry_client_errors() { + let sender = QueueSender::new(vec![status(400, "bad request")]); + let sleeper = RecordingSleeper::new(); + let err = run_gemini_request(&sender, &sleeper, &args()) + .await + .unwrap_err(); + assert_eq!(err, "Gemini API error: 400 — bad request"); + assert_eq!(*sender.calls.borrow(), 1); + assert!(sleeper.delays.borrow().is_empty()); + } + + #[tokio::test] + async fn retries_network_errors_then_gives_up() { + let sender = QueueSender::new(vec![ + Err("connection reset".to_string()), + Err("connection reset".to_string()), + Err("connection reset".to_string()), + Err("connection reset".to_string()), + ]); + let sleeper = RecordingSleeper::new(); + let err = run_gemini_request(&sender, &sleeper, &args()) + .await + .unwrap_err(); + assert_eq!(err, "Request failed: connection reset"); + assert_eq!(*sender.calls.borrow(), 4); + assert_eq!(*sleeper.delays.borrow(), vec![2, 4, 8]); + } + + #[tokio::test] + async fn recovers_after_transient_network_error() { + let sender = QueueSender::new(vec![Err("reset".to_string()), ok("[]")]); + let sleeper = RecordingSleeper::new(); + let out = run_gemini_request(&sender, &sleeper, &args()).await.unwrap(); + assert_eq!(out, "[]"); + assert_eq!(*sleeper.delays.borrow(), vec![2]); + } +} diff --git a/src-tauri/src/commands/glue.rs b/src-tauri/src/commands/glue.rs index 9ad6cdb..639b2cd 100644 --- a/src-tauri/src/commands/glue.rs +++ b/src-tauri/src/commands/glue.rs @@ -16,6 +16,7 @@ use tokio::net::TcpListener; use tokio::time::Duration; use super::auth; +use super::gemini::{run_gemini_request, GeminiRequestArgs, TokioSleeper}; use super::keychain::{CmdOutput, CommandRunner}; use super::simplicate::{run_request, ReqwestSender, SimplicateRequestArgs}; use super::storage; @@ -63,6 +64,11 @@ pub async fn simplicate_request(args: SimplicateRequestArgs) -> Result Result { + run_gemini_request(&ReqwestSender::new(), &TokioSleeper, &args).await +} + #[tauri::command] pub fn ensure_app_data_dir(app: AppHandle) -> Result<(), String> { let resolved = app.path().app_data_dir().map_err(|e| e.to_string()); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 06fb5d3..731901a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod gemini; pub mod glue; pub mod keychain; pub mod simplicate; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 86885d3..fec5a50 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,8 @@ mod commands; use commands::glue::{ - delete_secret, ensure_app_data_dir, get_secret, set_secret, simplicate_request, - start_google_oauth, + delete_secret, ensure_app_data_dir, gemini_request, get_secret, set_secret, + simplicate_request, start_google_oauth, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -17,6 +17,7 @@ pub fn run() { delete_secret, start_google_oauth, simplicate_request, + gemini_request, ensure_app_data_dir, ]) .run(tauri::generate_context!()) diff --git a/src/infrastructure/gemini/GeminiRepository.test.ts b/src/infrastructure/gemini/GeminiRepository.test.ts index c35cc9e..7e082fd 100644 --- a/src/infrastructure/gemini/GeminiRepository.test.ts +++ b/src/infrastructure/gemini/GeminiRepository.test.ts @@ -6,6 +6,11 @@ import type { Project, Service, DayItem } from '../../domain/repositories/ICopil import type { DayContext } from '../../domain/entities/DayContext' import type { HourEntry } from '../../domain/entities/HourEntry' +// Gemini calls go through the Rust `gemini_request` command via Tauri's invoke. +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })) +import { invoke } from '@tauri-apps/api/core' +const invokeMock = vi.mocked(invoke) + // Avoid pulling in Tauri fs / ?raw imports; capture the rendered prompt for assertions. let lastPrompt = '' vi.mock('./promptStore', () => ({ @@ -16,8 +21,9 @@ vi.mock('./promptStore', () => ({ }), })) -function geminiOk(text: string) { - return { ok: true, json: async () => ({ candidates: [{ content: { parts: [{ text }] } }] }) } +/** The raw response body the Rust command resolves with (the Gemini JSON). */ +function geminiOk(text: string): string { + return JSON.stringify({ candidates: [{ content: { parts: [{ text }] } }] }) } function block(overrides: Partial = {}): HistoryBlock { @@ -41,22 +47,18 @@ const services: Service[] = [ ] describe('GeminiRepository', () => { - let fetchMock: ReturnType - beforeEach(() => { - fetchMock = vi.fn() - vi.stubGlobal('fetch', fetchMock) + invokeMock.mockReset() lastPrompt = '' }) afterEach(() => { - vi.unstubAllGlobals() vi.restoreAllMocks() }) describe('classify', () => { it('matches LLM results onto blocks and applies fallbacks', async () => { - fetchMock.mockResolvedValueOnce( + invokeMock.mockResolvedValueOnce( geminiOk( '```json\n' + JSON.stringify([ @@ -108,7 +110,7 @@ describe('GeminiRepository', () => { }) it('includes calendar context and overlapping meetings in the prompt', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('[]')) + invokeMock.mockResolvedValueOnce(geminiOk('[]')) const events: CalendarEvent[] = [ { id: 'c1', @@ -147,19 +149,19 @@ describe('GeminiRepository', () => { }) it('throws on invalid JSON', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('not json')) + invokeMock.mockResolvedValueOnce(geminiOk('not json')) const repo = new GeminiRepository() await expect(repo.classify([block()], projects, services)).rejects.toThrow('Gemini returned invalid JSON') }) it('throws when result is not an array', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('{"foo":1}')) + invokeMock.mockResolvedValueOnce(geminiOk('{"foo":1}')) const repo = new GeminiRepository() await expect(repo.classify([block()], projects, services)).rejects.toThrow('not an array') }) it('renders a calendar event without attendees and tolerates empty blocks', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('[]')) + invokeMock.mockResolvedValueOnce(geminiOk('[]')) const events: CalendarEvent[] = [ { id: 'c1', @@ -179,61 +181,56 @@ describe('GeminiRepository', () => { expect(lastPrompt).not.toContain('Solo focus') // Now with a dated block so the event matches the day and the no-attendee branch runs. - fetchMock.mockResolvedValueOnce(geminiOk('[]')) + invokeMock.mockResolvedValueOnce(geminiOk('[]')) await repo.classify([block()], projects, services, events) expect(lastPrompt).toContain('Solo focus') expect(lastPrompt).not.toContain('Solo focus (') }) it('handles empty candidates by defaulting to []', async () => { - fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ candidates: [] }) }) + invokeMock.mockResolvedValueOnce(JSON.stringify({ candidates: [] })) const repo = new GeminiRepository() const result = await repo.classify([block()], projects, services) expect(result[0]!.blockName).toBe('github.com/foo') }) }) - describe('callGemini error handling', () => { - it('throws a quota message on a non-retryable 429 (attempt cap reached)', async () => { - vi.useFakeTimers() - // Always 429 with a retryDelay so backoff is short; exhaust the 3 retries. - fetchMock.mockResolvedValue({ - status: 429, - ok: false, - text: async () => '{"retryDelay":"1s"}', - }) + // Retry/backoff now lives in the Rust `gemini_request` command; the TS layer + // only maps the rejection it surfaces onto a user-facing message. + describe('callGemini error mapping', () => { + it('maps a 429 error to a friendly quota message', async () => { + invokeMock.mockRejectedValueOnce(new Error('Gemini API error: 429 — {"retryDelay":"1s"}')) const repo = new GeminiRepository() - const promise = repo.classify([block()], projects, services) - // Attach the rejection handler before advancing timers so the rejection - // is never unhandled. - const assertion = expect(promise).rejects.toThrow('Gemini quota uitgeput') - // Advance through the retry sleeps. - await vi.runAllTimersAsync() - await assertion - // initial + 3 retries = 4 calls - expect(fetchMock).toHaveBeenCalledTimes(4) - vi.useRealTimers() + await expect(repo.classify([block()], projects, services)).rejects.toThrow('Gemini quota uitgeput') }) - it('retries on 429 then succeeds, using exponential backoff when no retryDelay', async () => { - vi.useFakeTimers() - fetchMock - .mockResolvedValueOnce({ status: 429, ok: false, text: async () => 'rate limited' }) - .mockResolvedValueOnce(geminiOk('[]')) + it('maps a quota-worded error to the same friendly message', async () => { + invokeMock.mockRejectedValueOnce(new Error('RESOURCE_EXHAUSTED: quota exceeded')) const repo = new GeminiRepository() - const promise = repo.classify([block()], projects, services) - await vi.runAllTimersAsync() - const result = await promise - expect(result).toHaveLength(1) - expect(fetchMock).toHaveBeenCalledTimes(2) - vi.useRealTimers() + await expect(repo.classify([block()], projects, services)).rejects.toThrow('Gemini quota uitgeput') + }) + + it('maps a network failure to an unreachable message', async () => { + invokeMock.mockRejectedValueOnce(new Error('Request failed: connection reset')) + const repo = new GeminiRepository() + await expect(repo.classify([block()], projects, services)).rejects.toThrow( + 'Gemini niet bereikbaar (netwerkfout na meerdere pogingen). Request failed: connection reset', + ) }) - it('throws a generic error on other non-ok statuses', async () => { - fetchMock.mockResolvedValueOnce({ status: 500, ok: false, text: async () => 'boom' }) + it('passes through an already-formatted Gemini API error', async () => { + invokeMock.mockRejectedValueOnce(new Error('Gemini API error: 500 — boom')) const repo = new GeminiRepository() await expect(repo.classify([block()], projects, services)).rejects.toThrow('Gemini API error: 500 — boom') }) + + it('wraps a bare/unknown rejection as a Gemini API error', async () => { + invokeMock.mockRejectedValueOnce('weird transport failure') + const repo = new GeminiRepository() + await expect(repo.classify([block()], projects, services)).rejects.toThrow( + 'Gemini API error: weird transport failure', + ) + }) }) describe('classifyDay', () => { @@ -247,7 +244,7 @@ describe('GeminiRepository', () => { } it('returns a bare array response directly', async () => { - fetchMock.mockResolvedValueOnce( + invokeMock.mockResolvedValueOnce( geminiOk( JSON.stringify([ { index: 0, blockName: 'B', summary: 'S', projectId: 'p1', serviceId: 's1', note: 'n', confidence: 3 }, @@ -315,7 +312,7 @@ describe('GeminiRepository', () => { ] const cacheHints = { 'github.com/foo': { projectName: 'Proj', serviceName: 'Dev' } } - fetchMock.mockResolvedValueOnce( + invokeMock.mockResolvedValueOnce( geminiOk( JSON.stringify({ blocks: [ @@ -375,7 +372,7 @@ describe('GeminiRepository', () => { }) it('omits optional sections when data is empty', async () => { - fetchMock.mockResolvedValueOnce(geminiOk(JSON.stringify({ blocks: [] }))) + invokeMock.mockResolvedValueOnce(geminiOk(JSON.stringify({ blocks: [] }))) const repo = new GeminiRepository() const result = await repo.classifyDay('2026-05-20', [], projects, services, {}, undefined, [], []) expect(result).toEqual([]) @@ -424,7 +421,7 @@ describe('GeminiRepository', () => { }, ] - fetchMock.mockResolvedValueOnce(geminiOk(JSON.stringify({ blocks: [] }))) + invokeMock.mockResolvedValueOnce(geminiOk(JSON.stringify({ blocks: [] }))) const repo = new GeminiRepository() await repo.classifyDay('2026-05-20', items, projects, services, {}, context, undefined, existing) @@ -438,7 +435,7 @@ describe('GeminiRepository', () => { }) it('throws on invalid JSON', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('garbage')) + invokeMock.mockResolvedValueOnce(geminiOk('garbage')) const repo = new GeminiRepository() await expect(repo.classifyDay('2026-05-20', [], projects, services, {})).rejects.toThrow( 'Gemini returned invalid JSON for classifyDay', @@ -446,7 +443,7 @@ describe('GeminiRepository', () => { }) it('throws when blocks is not an array', async () => { - fetchMock.mockResolvedValueOnce(geminiOk('{"blocks":"nope"}')) + invokeMock.mockResolvedValueOnce(geminiOk('{"blocks":"nope"}')) const repo = new GeminiRepository() await expect(repo.classifyDay('2026-05-20', [], projects, services, {})).rejects.toThrow( 'unexpected format', @@ -454,7 +451,7 @@ describe('GeminiRepository', () => { }) it('defaults patternBlocks to empty when absent', async () => { - fetchMock.mockResolvedValueOnce( + invokeMock.mockResolvedValueOnce( geminiOk(JSON.stringify({ blocks: [{ index: 0, blockName: 'B', summary: '', projectId: null, serviceId: null, note: '', confidence: 1 }] })), ) const repo = new GeminiRepository() diff --git a/src/infrastructure/gemini/GeminiRepository.ts b/src/infrastructure/gemini/GeminiRepository.ts index 5c1620f..feb5973 100644 --- a/src/infrastructure/gemini/GeminiRepository.ts +++ b/src/infrastructure/gemini/GeminiRepository.ts @@ -1,3 +1,4 @@ +import { invoke } from '@tauri-apps/api/core' import { toConfidenceScore } from '../../domain/usecases/toConfidenceScore' import type { ICopilotRepository, Project, Service, DayItem, DayClassificationResult, PatternBlock } from '../../domain/repositories/ICopilotRepository' import type { HistoryBlock } from '../../domain/entities/HistoryBlock' @@ -37,33 +38,36 @@ interface LLMBlockResult { confidence: number } -async function callGemini(prompt: string, attempt = 0): Promise { - const response = await fetch(GEMINI_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contents: [{ parts: [{ text: prompt }] }], - generationConfig: { temperature: 0.1 }, - }), - }) - - if (response.status === 429 && attempt < 3) { - const text = await response.text() - const retryDelay = /"retryDelay":"(\d+)s"/.exec(text)?.[1] - const delaySecs = retryDelay ? parseInt(retryDelay, 10) : Math.pow(2, attempt + 1) * 5 - await new Promise(resolve => setTimeout(resolve, delaySecs * 1000)) - return callGemini(prompt, attempt + 1) - } - - if (!response.ok) { - const text = await response.text() - if (response.status === 429) { - throw new Error(`Gemini quota uitgeput. Upgrade naar een betaald API-plan op https://ai.google.dev/ of probeer het later opnieuw.`) +async function callGemini(prompt: string): Promise { + // Routed through the Rust backend (`gemini_request`) rather than the webview's + // `fetch`: the WebKit webview drops large/slow requests with a generic "Load + // failed" error. The Rust side also retries transient failures (network + // errors, 429, 5xx) with backoff and returns the raw response body, or + // rejects with a "Gemini API error: — " / "Request failed: …" + // message once retries are exhausted. + let body: string + try { + body = await invoke('gemini_request', { + args: { + url: GEMINI_URL, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { temperature: 0.1 }, + }), + }, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('429') || /quota/i.test(msg)) { + throw new Error('Gemini quota uitgeput. Upgrade naar een betaald API-plan op https://ai.google.dev/ of probeer het later opnieuw.') + } + if (msg.startsWith('Request failed')) { + throw new Error(`Gemini niet bereikbaar (netwerkfout na meerdere pogingen). ${msg}`) } - throw new Error(`Gemini API error: ${response.status} — ${text}`) + throw new Error(msg.startsWith('Gemini') ? msg : `Gemini API error: ${msg}`) } - const data = await response.json() as GeminiResponse + const data = JSON.parse(body) as GeminiResponse const raw = data.candidates[0]?.content.parts[0]?.text ?? '[]' return raw.replace(/^```(?:json)?\s*/m, '').replace(/\s*```\s*$/m, '').trim() }