mindfork 0.17.0

A terminal AI chat written in Rust: local models via llama.cpp, or OpenAI, Anthropic, Gemini, Grok and OpenRouter in the cloud, with persistent memory, notes, RAG and tools.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Speech synthesis (TTS): the synthesis contract + provider clients +
//! playback. The speech slot is **independent of the chat engine** (Anthropic
//! has no TTS at all), so the provider is configured separately — like the
//! dedicated embedding server (ADR 0002). Clients are **stateless**: built from
//! a config snapshot per call, no manager slot is needed.
//!
//! Layout (docs/research/tts.md §8):
//! - [`openai`] — `POST /v1/audio/speech`: the OpenAI cloud, any third-party
//!   OpenAI-compatible TTS server **and** the OpenRouter gateway (different
//!   base URL/key, and the response format asked for or negotiated);
//! - [`gemini`] — native `generateContent` with `responseModalities:["AUDIO"]`;
//! - [`playback`] — playback: an `rodio` source queue + cancellation.
//!
//! A local sidecar (managed mode) is **future work** (a spike: local engines
//! are NO-GO for Russian, or need their own frontend; docs/research/tts.md
//! §13). The primary path is the cloud (OpenAI); offline — `external` (your own
//! OpenAI-compatible server).

pub mod gemini;
pub mod openai;
pub mod playback;

#[cfg(test)]
mod gateway_live_tests;
#[cfg(test)]
mod gateway_tests;

use std::sync::Arc;

use anyhow::Result;
use tokio_util::sync::CancellationToken;

use crate::shared::config::{CloudProvider, TtsMode, TtsSettings};

pub use openai::FormatMemo;

/// What the gateway's speech client is given besides the slot's settings. Both
/// are the provider's rather than the slot's, and neither is in
/// [`TtsSettings`]: the switch is set once for every slot that speaks to the
/// gateway, and the memo is what the session has learned.
#[derive(Debug, Clone, Default)]
pub struct GatewaySpeech {
    /// Name the application to the gateway (`openrouter.attribution`, fork F5
    /// of docs/research/openrouter-mode.md).
    pub attribution: bool,
    /// The format each model was last answered in.
    pub formats: Arc<FormatMemo>,
}

/// A synthesized speech fragment.
///
/// Clouds return **raw PCM with no header** (OpenAI `response_format:"pcm"` —
/// 24 kHz s16le mono; Gemini `audio/L16;codec=pcm;rate=…`), so they need no
/// decoder at all — samples go straight into the playback buffer. Third-party
/// servers respond with a container (`wav` — the most portable), parsed by the
/// decoder.
#[derive(Debug, Clone, PartialEq)]
pub enum AudioClip {
    /// Raw signed 16-bit little-endian PCM with no header.
    Pcm {
        sample_rate: u32,
        channels: u16,
        bytes: Vec<u8>,
    },
    /// Audio in a container (wav/mp3) — with a header, parsed by the decoder.
    Encoded(Vec<u8>),
}

impl AudioClip {
    /// Whether the clip is empty (nothing to play).
    pub fn is_empty(&self) -> bool {
        match self {
            AudioClip::Pcm { bytes, .. } => bytes.len() < 2,
            AudioClip::Encoded(bytes) => bytes.is_empty(),
        }
    }
}

/// A speech-synthesis provider. Implementations are thin HTTP clients (see the
/// module).
#[async_trait::async_trait]
pub trait TtsEngine: Send + Sync {
    /// Synthesizes speech for a piece of text. Cancellable: on `cancel` the
    /// request is aborted (with an error), accumulated output is not used.
    async fn synthesize(&self, text: &str, cancel: &CancellationToken) -> Result<AudioClip>;

    /// The ceiling on a single request's text length (characters) — text is
    /// cut into chunks by it. A hard provider limit, not a preference.
    fn max_input_chars(&self) -> usize;
}

/// Why speech isn't configured. A structured error (not text): the message is
/// built by the UI in the interface language — axis B, a precedent from
/// `supervisor::ApiKeyError`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TtsSetupError {
    /// No model name set.
    Model,
    /// No API key (neither entered in settings nor in an env variable).
    ApiKey,
    /// No external server URL set.
    Url,
}

/// A pair of speech engines: the assistant's voice (always) and an optional
/// user voice.
pub type TtsEnginePair = (Box<dyn TtsEngine>, Option<Box<dyn TtsEngine>>);

/// Engines for multi-voice speech: `(assistant, opt. user)`. `stored_key` — the
/// stored key of whatever the active mode reads (a cloud provider's, shared with
/// chat, or the `external` slot's own — ADR 0008, docs/history/external-api-key.md):
/// already decrypted by the caller; if absent, the key is read from an env
/// variable. The second engine is built
/// **only** if the active mode has a separate "user voice" set and it differs
/// from the assistant's voice — then `/tts all`/`/tts N` read the user's turns
/// with it (spec §11.9). Otherwise `None` → everything in one voice.
///
/// `gateway` is read by the `openrouter` mode alone; both engines share its
/// memo, since both speak through one model.
pub fn engines_from_config(
    tts: &TtsSettings,
    stored_key: Option<String>,
    gateway: &GatewaySpeech,
) -> std::result::Result<TtsEnginePair, TtsSetupError> {
    let (assistant_voice, user_voice) = tts.active_voices();
    let assistant = build_engine(tts, stored_key.clone(), None, gateway)?;
    let user = match user_voice {
        Some(uv) if Some(uv) != assistant_voice => Some(build_engine(
            tts,
            stored_key,
            Some(uv.to_string()),
            gateway,
        )?),
        _ => None,
    };
    Ok((assistant, user))
}

/// Shared client constructor. `voice_override` (`Some`) replaces the voice from
/// settings — this is how the second engine for user turns is built, without
/// duplicating model/key/base resolution.
fn build_engine(
    tts: &TtsSettings,
    stored_key: Option<String>,
    voice_override: Option<String>,
    gateway: &GatewaySpeech,
) -> std::result::Result<Box<dyn TtsEngine>, TtsSetupError> {
    let speed = tts.speed;
    match tts.mode {
        TtsMode::OpenRouter => {
            let section = &tts.openrouter;
            let model = non_empty(section.model_name.clone()).ok_or(TtsSetupError::Model)?;
            let key = resolve_key(stored_key, section.api_key_env.as_deref())
                .ok_or(TtsSetupError::ApiKey)?;
            let base = non_empty(section.url.clone())
                .unwrap_or_else(|| CloudProvider::OpenRouter.chat_base_url().to_string());
            // No voice is not refused here: four of the gateway's models list
            // none and speak without one, and for the rest the gateway's own
            // refusal says what is missing.
            let voice = voice_override.or_else(|| non_empty(section.voice.clone()));
            Ok(Box::new(openai::OpenAiTts::gateway(
                base, key, model, voice, speed, gateway,
            )))
        }
        TtsMode::OpenAi | TtsMode::Gemini => {
            let cloud = tts.cloud().ok_or(TtsSetupError::Model)?;
            let model = non_empty(cloud.model_name.clone()).ok_or(TtsSetupError::Model)?;
            let key = resolve_key(stored_key, cloud.api_key_env.as_deref())
                .ok_or(TtsSetupError::ApiKey)?;
            let provider = tts.mode.cloud_provider().ok_or(TtsSetupError::Model)?;
            let base = non_empty(cloud.url.clone())
                .unwrap_or_else(|| provider.chat_base_url().to_string());
            let voice = voice_override.or_else(|| non_empty(cloud.voice.clone()));
            let instructions = non_empty(cloud.instructions.clone());
            Ok(match tts.mode {
                TtsMode::Gemini => Box::new(gemini::GeminiTts::new(
                    base,
                    key,
                    model,
                    voice,
                    instructions,
                )),
                // OpenAI (cloud): ask for raw PCM — no decoder needed.
                _ => Box::new(openai::OpenAiTts::cloud(
                    base,
                    key,
                    model,
                    voice,
                    instructions,
                    speed,
                )),
            })
        }
        TtsMode::External => {
            let url = non_empty(tts.external.url.clone()).ok_or(TtsSetupError::Url)?;
            Ok(Box::new(openai::OpenAiTts::external(
                url,
                // The same resolution as the cloud arm above, but **without** the
                // `ok_or`: here having no key at all is legitimate, since a local speech
                // server needs none (docs/history/external-api-key.md F3).
                resolve_key(stored_key, tts.external.api_key_env.as_deref()),
                non_empty(tts.external.model_name.clone()),
                voice_override.or_else(|| non_empty(tts.external.voice.clone())),
                speed,
            )))
        }
    }
}

/// The speech server's key: a key **stored for this slot** wins over the variable
/// named in settings, and a blank stored key counts as absent (ADR 0008 §3,
/// docs/history/external-api-key.md F2). `None` — neither is available; what that
/// means is the caller's to decide, and the two modes decide differently: a cloud
/// refuses ([`TtsSetupError::ApiKey`]), an external server simply sends no
/// authorization. The mirror of `app::supervisor::resolve_api_key`, kept separate
/// because that one lives above this layer and returns a *localizable* error.
fn resolve_key(stored: Option<String>, api_key_env: Option<&str>) -> Option<String> {
    stored
        .filter(|k| !k.trim().is_empty())
        .or_else(|| env_key(api_key_env))
}

/// An env variable's value by name (the fallback under a stored key, ADR 0008).
fn env_key(var: Option<&str>) -> Option<String> {
    let var = var?.trim();
    if var.is_empty() {
        return None;
    }
    std::env::var(var).ok().filter(|v| !v.is_empty())
}

/// A non-empty string, or `None` (an empty field in settings is stored as
/// `Some("")`).
fn non_empty(value: Option<String>) -> Option<String> {
    value
        .map(|v| v.trim().to_string())
        .filter(|v| !v.is_empty())
}

/// Doesn't swallow the provider's error body: the status + reason are logged
/// and land in the error text (truncated to 500 chars) — as in the engine
/// clients (ADR 0004).
pub(crate) async fn error_body(what: &str, resp: reqwest::Response) -> anyhow::Error {
    let status = resp.status();
    let body = resp.text().await.unwrap_or_default();
    refusal(what, status, &body)
}

/// [`error_body`] for an answer whose body has been read already.
pub(crate) fn refusal(what: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
    let detail: String = body.trim().chars().take(500).collect();
    tracing::warn!(%status, body = %detail, "{what} returned an error status");
    if detail.is_empty() {
        anyhow::anyhow!("{what}: status {status}")
    } else {
        anyhow::anyhow!("{what}: status {status}: {detail}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::shared::config::{SecretSlot, TtsCloudSettings, TtsExternalSettings};

    /// Nothing of the gateway's: what every mode but `openrouter` is built with.
    fn none() -> GatewaySpeech {
        GatewaySpeech::default()
    }

    #[test]
    fn cloud_requires_model_and_key() {
        let mut tts = TtsSettings {
            openai: TtsCloudSettings {
                model_name: None,
                ..Default::default()
            },
            ..Default::default()
        };
        assert_eq!(
            engines_from_config(&tts, Some("sk-x".into()), &none()).err(),
            Some(TtsSetupError::Model)
        );
        tts.openai.model_name = Some("gpt-4o-mini-tts".into());
        // No key at all, neither stored nor in env → a clear structured error.
        assert_eq!(
            engines_from_config(&tts, None, &none()).err(),
            Some(TtsSetupError::ApiKey)
        );
        // A stored key (ADR 0008) is enough — nothing needs re-entering.
        assert!(engines_from_config(&tts, Some("sk-x".into()), &none()).is_ok());
    }

    #[test]
    fn external_requires_url_only() {
        let tts = TtsSettings {
            mode: TtsMode::External,
            external: TtsExternalSettings::default(),
            ..Default::default()
        };
        assert_eq!(
            engines_from_config(&tts, None, &none()).err(),
            Some(TtsSetupError::Url)
        );
        let tts = TtsSettings {
            mode: TtsMode::External,
            external: TtsExternalSettings {
                url: Some("http://127.0.0.1:8880/v1".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        // A local server needs neither a key nor a model.
        assert!(engines_from_config(&tts, None, &none()).is_ok());
    }

    /// The speech key follows the same chain as the engine's: a key stored for this
    /// slot first, then the variable named in settings, and a blank stored key counts
    /// as absent rather than as an empty `Authorization`. `PATH` is read rather than
    /// set — `set_var` is `unsafe` in edition 2024 and races the whole test binary.
    #[test]
    fn stored_key_wins_over_the_named_variable() {
        let from_env = std::env::var("PATH").unwrap();
        assert_eq!(
            resolve_key(Some("sk-slot".into()), Some("PATH")).as_deref(),
            Some("sk-slot")
        );
        assert_eq!(resolve_key(None, Some("PATH")), Some(from_env));
        assert!(
            resolve_key(Some("  ".into()), Some("PATH")).is_some(),
            "a blank stored key falls through to the variable, not to nothing"
        );
        assert_eq!(resolve_key(Some("   ".into()), None), None);
        assert_eq!(resolve_key(None, None), None);
        assert_eq!(
            resolve_key(None, Some("MINDFORK_DEFINITELY_UNSET_VAR_42")),
            None
        );
    }

    /// An external speech server takes its **own** stored key (`ExternalSlot::Tts`),
    /// and having none stays legitimate — the two halves of
    /// docs/history/external-api-key.md for this slot.
    #[test]
    fn external_takes_a_stored_key_but_does_not_require_one() {
        let tts = TtsSettings {
            mode: TtsMode::External,
            external: TtsExternalSettings {
                url: Some("http://127.0.0.1:8880/v1".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        assert_eq!(
            tts.secret_key(),
            Some(crate::shared::secrets::SecretKey::External(
                crate::shared::secrets::ExternalSlot::Tts
            )),
            "the speech slot must not read a provider's key in external mode"
        );
        assert!(engines_from_config(&tts, Some("sk-voice".into()), &none()).is_ok());
        assert!(engines_from_config(&tts, None, &none()).is_ok());
    }

    #[test]
    fn blank_fields_count_as_unset() {
        let tts = TtsSettings {
            openai: TtsCloudSettings {
                model_name: Some("   ".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        assert_eq!(
            engines_from_config(&tts, Some("sk-x".into()), &none()).err(),
            Some(TtsSetupError::Model)
        );
    }

    #[test]
    fn second_engine_built_only_when_user_voice_set_and_differs() {
        let base = TtsSettings {
            openai: TtsCloudSettings {
                model_name: Some("gpt-4o-mini-tts".into()),
                voice: Some("onyx".into()),
                ..Default::default()
            },
            ..Default::default()
        };
        // No user voice → a single engine.
        let (_a, user) = engines_from_config(&base, Some("sk-x".into()), &none()).unwrap();
        assert!(user.is_none(), "no user_voice → no second engine is built");

        // Set and differs → the second one is built.
        let mut with_user = base.clone();
        with_user.openai.user_voice = Some("nova".into());
        let (_a, user) = engines_from_config(&with_user, Some("sk-x".into()), &none()).unwrap();
        assert!(user.is_some(), "a separate user_voice → a second engine");

        // Matches the assistant's voice → no second one needed.
        let mut same = base.clone();
        same.openai.user_voice = Some("onyx".into());
        let (_a, user) = engines_from_config(&same, Some("sk-x".into()), &none()).unwrap();
        assert!(user.is_none(), "matching voice — a single engine");
    }

    #[test]
    fn empty_clip_detected() {
        assert!(AudioClip::Encoded(Vec::new()).is_empty());
        assert!(
            AudioClip::Pcm {
                sample_rate: 24000,
                channels: 1,
                bytes: vec![0],
            }
            .is_empty()
        );
        assert!(!AudioClip::Encoded(vec![1, 2, 3]).is_empty());
    }
}