Add Mistral plot-beat generation (Tools menu)

New Tools ▸ "Generate plot beats (Mistral)…" command: pick a novel
proposal markdown file (characters + loose plot summary) and have the
Mistral chat API lay it out as plot beats in the classic three-act
structure. The result opens in a floating window where it can be edited,
saved as a new "<proposal> — beats.md" workspace file, or copied.

- src/mistral.rs: pure-Rust ureq/rustls POST to /v1/chat/completions
  (self-contained; no C bindings). System prompt fixes the three-act
  markdown shape; friendly API-error surfacing. Unit tests for content
  parsing, empty/blank responses, error-message extraction, and the
  missing-key guard.
- config: mistral_api_key / mistral_model / mistral_base_url with
  effective-value helpers and defaults (mistral-large-latest,
  https://api.mistral.ai).
- app: background generation + poll (editor stays responsive), a
  Settings ▸ Mistral… window (key/model/base URL), and the results
  window with save/copy.
- Docs: README "Plot beats (Mistral)" section and help cheatsheet note.

Verified: cargo build --release clean, 56 tests pass (+6 Mistral),
ldd still only libc/libgcc/libm. Clippy: no new warnings in the added
code (the two extra vs. the old 3-warning baseline are toolchain-surfaced
in pre-existing app.rs code).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDfaEsTgcn61n3wgP62DFF
This commit is contained in:
landon
2026-08-20 14:39:08 -05:00
parent 5bc2acad25
commit e5eadc08c1
6 changed files with 635 additions and 0 deletions
+25
View File
@@ -166,6 +166,31 @@ The bundled dictionaries live under `dictionaries/` and are derived from
[SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept [SCOWL](http://wordlist.sourceforge.net/) under a permissive license (kept
alongside them in each `license` file). alongside them in each `license` file).
## Plot beats (Mistral)
**Tools ▸ ✨ Generate plot beats (Mistral)…** turns a rough novel proposal into
a structured set of plot beats laid out in the classic **three-act structure**,
using the [Mistral](https://mistral.ai/) chat API.
1. Open **Settings ▸ Mistral…** once and paste your **API key** (from
`console.mistral.ai`). You can also set the **Model** (default
`mistral-large-latest`) and the **Base URL** (default `https://api.mistral.ai`,
handy if you route through a proxy). The settings are remembered.
2. Choose **Tools ▸ Generate plot beats…** and pick a markdown (or text) file
describing the novel — its **characters** and a **loose plot summary**. The
request runs in the background, so the editor stays responsive.
3. The result opens in a **floating window** with the beats grouped under
*Act I — Setup*, *Act II — Confrontation*, and *Act III — Resolution*, each a
numbered list. You can **edit** the text in place, then:
* **💾 Save as new file** — writes `<proposal> — beats.md` into the workspace
(auto-numbered if that name is taken) and opens it in the editor, or
* **⧉ Copy** — copies the beats to the clipboard.
The chosen proposal text is sent to Mistral to generate the beats; nothing else
in your workspace is transmitted. The API key is stored in the app's config file
in plain text. Because the request uses the pure-Rust `ureq`/rustls stack, the
binary stays self-contained.
## Grammar & spelling (LanguageTool) ## Grammar & spelling (LanguageTool)
The editor can *additionally* check the current file against a The editor can *additionally* check the current file against a
+295
View File
@@ -235,6 +235,17 @@ pub struct App {
/// Index (into the currently displayed matches) of the word a right-click /// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any. /// suggestion menu is open for, if any.
spell_menu: Option<usize>, spell_menu: Option<usize>,
/// Whether the Mistral settings window is open.
show_mistral_settings: bool,
/// In-flight background plot-beat generation, if any.
beats_rx: Option<std::sync::mpsc::Receiver<Result<String, String>>>,
/// One-line status for the plot-beat generator.
beats_status: String,
/// The generated (and user-editable) plot beats; `Some` opens the results
/// window. Holds an empty string while generating or on error.
beats_output: Option<String>,
/// File stem of the proposal the beats came from, for the default save name.
beats_source_stem: Option<String>,
} }
impl App { impl App {
@@ -293,6 +304,11 @@ impl App {
spell_rx: None, spell_rx: None,
spell_status: String::new(), spell_status: String::new(),
spell_menu: None, spell_menu: None,
show_mistral_settings: false,
beats_rx: None,
beats_status: String::new(),
beats_output: None,
beats_source_stem: None,
}; };
app.load_spell_dict(); app.load_spell_dict();
app.open_workspace(); app.open_workspace();
@@ -827,6 +843,259 @@ impl App {
} }
} }
/// Pick a novel-proposal markdown file and generate three-act plot beats
/// from it with the Mistral API, in the background. Opens the settings window
/// instead if no API key is configured yet.
fn generate_plot_beats(&mut self, ctx: &egui::Context) {
if self.beats_rx.is_some() {
return; // a generation is already running
}
if self.config.mistral_api_key.trim().is_empty() {
self.show_mistral_settings = true;
self.beats_status = "Set your Mistral API key first (Settings ▸ Mistral).".to_string();
self.beats_output = Some(String::new());
return;
}
let mut dialog = rfd::FileDialog::new()
.set_title("Choose a novel proposal (markdown)")
.add_filter("Markdown / text", &["md", "markdown", "txt"]);
if let Some(dir) = self.workspace().to_str() {
dialog = dialog.set_directory(dir);
}
let Some(path) = dialog.pick_file() else {
return; // cancelled
};
let proposal = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.beats_status = format!("Could not read {}: {e}", path.display());
self.beats_output = Some(String::new());
return;
}
};
if proposal.trim().is_empty() {
self.beats_status = "That file is empty — nothing to work from.".to_string();
self.beats_output = Some(String::new());
return;
}
self.beats_source_stem = path
.file_stem()
.and_then(|s| s.to_str())
.map(str::to_string);
let api_key = self.config.mistral_api_key.clone();
let model = self.config.mistral_effective_model();
let base = self.config.mistral_effective_base_url();
let (tx, rx) = std::sync::mpsc::channel();
self.beats_rx = Some(rx);
self.beats_status = "Generating plot beats…".to_string();
self.beats_output = Some(String::new());
let ctx = ctx.clone();
std::thread::spawn(move || {
let result = crate::mistral::generate_beats(&api_key, &model, &base, &proposal);
let _ = tx.send(result);
ctx.request_repaint();
});
}
/// Pick up a finished plot-beat generation and show it (or its error).
fn poll_beats(&mut self) {
let received = self.beats_rx.as_ref().and_then(|rx| rx.try_recv().ok());
if let Some(result) = received {
self.beats_rx = None;
match result {
Ok(beats) => {
self.beats_status = "Done — review, then save or copy.".to_string();
self.beats_output = Some(beats);
}
Err(e) => {
self.beats_status = format!("{e}");
// Keep the window open so the error stays visible.
self.beats_output.get_or_insert_with(String::new);
}
}
}
}
/// Write the generated beats to a new `<stem> — beats.md` file in the
/// workspace (dodging name collisions), add it to the list, and open it.
fn save_beats_as_file(&mut self, beats: &str) {
let stem = self
.beats_source_stem
.clone()
.unwrap_or_else(|| "plot".to_string());
let mut name = format!("{stem} — beats.md");
let mut n = 2;
while self.path_for(&name).exists() {
name = format!("{stem} — beats-{n}.md");
n += 1;
}
let path = self.path_for(&name);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let contents = format!("# Plot Beats — {stem}\n\n{}\n", beats.trim());
match std::fs::write(&path, contents) {
Ok(_) => {
if !self.files.contains(&name) {
self.files.push(name.clone());
self.persist_order();
}
if let Some(idx) = self.files.iter().position(|f| f == &name) {
self.selected = None; // force the buffer to reload
self.select(idx);
}
self.status = format!("Saved {name}");
self.beats_output = None; // close the window; the file is now open
self.beats_status.clear();
}
Err(e) => self.beats_status = format!("Save failed: {e}"),
}
}
/// Floating window presenting the generated plot beats, with save / copy.
fn beats_window(&mut self, ctx: &egui::Context) {
let mut open = true;
let mut close = false;
let mut save = false;
let running = self.beats_rx.is_some();
// Edit a local copy so the button row can borrow `self` mutably; persist
// any edits back into `beats_output` afterwards.
let mut text = self.beats_output.clone().unwrap_or_default();
egui::Window::new("✨ Plot beats (Mistral)")
.open(&mut open)
.resizable(true)
.default_width(560.0)
.default_height(520.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
if running {
ui.spinner();
}
if !self.beats_status.is_empty() {
ui.label(egui::RichText::new(&self.beats_status).weak());
}
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut text)
.desired_width(f32::INFINITY)
.desired_rows(20)
.font(egui::TextStyle::Monospace),
);
});
ui.separator();
ui.horizontal(|ui| {
let have = !text.trim().is_empty();
if ui
.add_enabled(have, egui::Button::new("💾 Save as new file"))
.clicked()
{
save = true;
}
if ui.add_enabled(have, egui::Button::new("⧉ Copy")).clicked() {
ui.output_mut(|o| o.copied_text = text.clone());
self.beats_status = "Copied to clipboard.".to_string();
}
if ui.button("Close").clicked() {
close = true;
}
});
});
// Persist edits made in the text box (unless we're about to close/save).
if self.beats_output.is_some() {
self.beats_output = Some(text.clone());
}
if save {
self.save_beats_as_file(&text);
} else if close || !open {
self.beats_output = None;
self.beats_status.clear();
}
}
/// Floating window for editing the Mistral API connection settings.
fn mistral_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_mistral_settings;
let mut close_clicked = false;
egui::Window::new("Mistral settings")
.open(&mut open)
.resizable(false)
.collapsible(false)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("mistral_settings_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("API key:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_api_key)
.password(true)
.hint_text("from console.mistral.ai")
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Model:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_model)
.hint_text(crate::mistral::DEFAULT_MODEL)
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Base URL:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.mistral_base_url)
.hint_text(crate::mistral::DEFAULT_BASE_URL)
.desired_width(260.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!(
"Endpoint: {}/v1/chat/completions",
self.config.mistral_effective_base_url()
))
.weak()
.monospace(),
);
ui.label(
egui::RichText::new(
"The key is stored in this app's config file in plain text. \
The proposal you choose is sent to Mistral to generate the beats.",
)
.small()
.weak(),
);
ui.separator();
if ui.button("Close").clicked() {
close_clicked = true;
}
if save_now {
self.config.save();
}
});
let now_open = open && !close_clicked;
if self.show_mistral_settings && !now_open {
self.config.save();
}
self.show_mistral_settings = now_open;
}
/// Poll for a finished background check and store its results. /// Poll for a finished background check and store its results.
fn poll_lt(&mut self) { fn poll_lt(&mut self) {
let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok()); let received = self.lt_rx.as_ref().and_then(|rx| rx.try_recv().ok());
@@ -1564,6 +1833,19 @@ impl App {
self.open_find(true); self.open_find(true);
} }
}); });
ui.menu_button("Tools", |ui| {
let busy = self.beats_rx.is_some();
if ui
.add_enabled(
!busy,
egui::Button::new("✨ Generate plot beats (Mistral)…"),
)
.clicked()
{
ui.close_menu();
self.generate_plot_beats(ctx);
}
});
ui.menu_button("View", |ui| { ui.menu_button("View", |ui| {
if ui if ui
.checkbox(&mut self.config.show_preview, "Preview pane") .checkbox(&mut self.config.show_preview, "Preview pane")
@@ -1579,6 +1861,10 @@ impl App {
ui.close_menu(); ui.close_menu();
self.show_settings = true; self.show_settings = true;
} }
if ui.button("Mistral…").clicked() {
ui.close_menu();
self.show_mistral_settings = true;
}
}); });
ui.menu_button("Help", |ui| { ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() { if ui.button("📝 Markdown cheatsheet").clicked() {
@@ -2388,6 +2674,7 @@ impl eframe::App for App {
self.poll_lt(); self.poll_lt();
self.poll_settings_test(); self.poll_settings_test();
self.poll_spell(); self.poll_spell();
self.poll_beats();
self.maybe_start_spell_check(ctx); self.maybe_start_spell_check(ctx);
self.menu_bar(ctx); self.menu_bar(ctx);
@@ -2427,6 +2714,14 @@ impl eframe::App for App {
self.settings_window(ctx); self.settings_window(ctx);
} }
if self.show_mistral_settings {
self.mistral_settings_window(ctx);
}
if self.beats_output.is_some() {
self.beats_window(ctx);
}
if self.show_cheatsheet { if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet); crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
} }
+43
View File
@@ -53,6 +53,26 @@ pub struct Config {
/// a [`crate::spell::DictEntry::id`]. /// a [`crate::spell::DictEntry::id`].
#[serde(default = "default_spell_language")] #[serde(default = "default_spell_language")]
pub spell_language: String, pub spell_language: String,
/// Mistral API key for plot-beat generation. Stored in plain text; empty
/// disables the feature until set.
#[serde(default)]
pub mistral_api_key: String,
/// Mistral model id used for plot-beat generation (blank = the built-in default).
#[serde(default = "default_mistral_model")]
pub mistral_model: String,
/// Mistral API base URL (blank = the built-in default). Override for a proxy.
#[serde(default = "default_mistral_base_url")]
pub mistral_base_url: String,
}
/// Default Mistral model.
pub fn default_mistral_model() -> String {
crate::mistral::DEFAULT_MODEL.to_string()
}
/// Default Mistral API base URL.
pub fn default_mistral_base_url() -> String {
crate::mistral::DEFAULT_BASE_URL.to_string()
} }
/// Live spell checking is on by default. /// Live spell checking is on by default.
@@ -109,6 +129,9 @@ impl Default for Config {
languagetool_language: default_languagetool_language(), languagetool_language: default_languagetool_language(),
spell_check: default_spell_check(), spell_check: default_spell_check(),
spell_language: default_spell_language(), spell_language: default_spell_language(),
mistral_api_key: String::new(),
mistral_model: default_mistral_model(),
mistral_base_url: default_mistral_base_url(),
} }
} }
} }
@@ -142,6 +165,26 @@ impl Config {
) )
} }
/// The Mistral model to use, falling back to the built-in default when blank.
pub fn mistral_effective_model(&self) -> String {
let m = self.mistral_model.trim();
if m.is_empty() {
crate::mistral::DEFAULT_MODEL.to_string()
} else {
m.to_string()
}
}
/// The Mistral base URL to use, falling back to the built-in default when blank.
pub fn mistral_effective_base_url(&self) -> String {
let b = self.mistral_base_url.trim().trim_end_matches('/');
if b.is_empty() {
crate::mistral::DEFAULT_BASE_URL.to_string()
} else {
b.to_string()
}
}
pub fn load() -> Config { pub fn load() -> Config {
let path = config_path(); let path = config_path();
match std::fs::read_to_string(&path) { match std::fs::read_to_string(&path) {
+15
View File
@@ -166,6 +166,21 @@ fn cheatsheet_body(ui: &mut egui::Ui) {
(grammar in blue, spelling in red) take over until you edit again, then the \ (grammar in blue, spelling in red) take over until you edit again, then the \
offline spell check resumes.", offline spell check resumes.",
); );
section(ui, "Plot beats (Mistral)");
body(
ui,
"Tools ▸ Generate plot beats… asks the Mistral API to turn a novel \
proposal (characters + a loose plot summary) into three-act plot beats. \
Pick the proposal file, and the beats open in a window to save as a new \
workspace file or copy.",
);
note(
ui,
"Set your API key first in Settings ▸ Mistral (the model and base URL are \
configurable there too). The proposal text is sent to Mistral; the key is \
stored in the app's config file in plain text.",
);
} }
// --- small rendering helpers -------------------------------------------------- // --- small rendering helpers --------------------------------------------------
+1
View File
@@ -7,6 +7,7 @@ mod config;
mod gitsync; mod gitsync;
mod help; mod help;
mod langtool; mod langtool;
mod mistral;
mod odt; mod odt;
mod order; mod order;
mod preprocess; mod preprocess;
+256
View File
@@ -0,0 +1,256 @@
//! Plot-beat generation via the Mistral chat-completions API.
//!
//! Given a novel *proposal* (its characters and a loose plot summary), this asks
//! a Mistral model to lay the story out as plot beats organised by the classic
//! three-act structure, returned as Markdown.
//!
//! The request is a plain `application/json` POST to `/v1/chat/completions`
//! (OpenAI-compatible), sent with `ureq` — which links a static rustls TLS
//! backend, so the binary stays self-contained (no C bindings). Both the model
//! and the base URL are caller-supplied so a proxy or a different model can be
//! used without code changes.
use serde::{Deserialize, Serialize};
use std::time::Duration;
/// Default Mistral API base URL (the hosted service).
pub const DEFAULT_BASE_URL: &str = "https://api.mistral.ai";
/// Default model: a large model, well suited to long-form structured reasoning.
pub const DEFAULT_MODEL: &str = "mistral-large-latest";
/// Instruction given to the model. It fixes the output shape (three acts, one
/// numbered beat list per act) so the result drops straight into a manuscript.
const SYSTEM_PROMPT: &str = "\
You are a developmental fiction editor who structures novels using the classic \
three-act structure. You will be given a novel proposal — its characters and a \
loose plot summary — and must lay the story out as a sequence of concrete plot \
beats.
Output requirements:
- Return GitHub-flavoured Markdown only. No preamble, commentary, or closing \
remarks — begin with the first heading.
- Organise the beats under exactly three headings: `## Act I — Setup`, \
`## Act II — Confrontation`, and `## Act III — Resolution`.
- Under each heading, give a numbered list of plot beats. Each beat is one or two \
sentences describing what concretely happens, naming characters from the \
proposal where relevant.
- Place the standard turning points in the right acts: the hook / ordinary world, \
the inciting incident, and the first plot point (end of Act I); rising action, \
the midpoint reversal, and the second plot point / low point (Act II); the \
climax, the resolution, and a brief denouement (Act III).
- Stay faithful to the supplied premise, characters, and tone. Invent only what \
is needed to connect the beats into a coherent, causal arc.
- Aim for roughly four to eight beats per act.";
// ---- Wire format -----------------------------------------------------------
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: Vec<ChatMessage<'a>>,
temperature: f32,
}
#[derive(Serialize)]
struct ChatMessage<'a> {
role: &'a str,
content: &'a str,
}
#[derive(Deserialize)]
struct ChatResponse {
#[serde(default)]
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: RespMessage,
}
#[derive(Deserialize)]
struct RespMessage {
#[serde(default)]
content: String,
}
/// Generate three-act plot beats for `proposal` using the Mistral API.
///
/// `api_key` is your Mistral API key (sent as a bearer token); `model` is the
/// model id (e.g. `mistral-large-latest`); `base_url` is the API root
/// (`https://api.mistral.ai`). Returns the model's Markdown beats, or a
/// human-readable error message.
pub fn generate_beats(
api_key: &str,
model: &str,
base_url: &str,
proposal: &str,
) -> Result<String, String> {
let key = api_key.trim();
if key.is_empty() {
return Err("No Mistral API key set (open Settings ▸ Mistral)".to_string());
}
let base = base_url.trim().trim_end_matches('/');
let base = if base.is_empty() { DEFAULT_BASE_URL } else { base };
let model = {
let m = model.trim();
if m.is_empty() { DEFAULT_MODEL } else { m }
};
let url = format!("{base}/v1/chat/completions");
// Frame the proposal so the model reads it as source material, not as new
// instructions.
let user = format!(
"Here is the novel proposal (characters and a loose plot summary). \
Produce the plot beats.\n\n---\n\n{}",
proposal.trim()
);
let request = ChatRequest {
model,
messages: vec![
ChatMessage { role: "system", content: SYSTEM_PROMPT },
ChatMessage { role: "user", content: &user },
],
temperature: 0.6,
};
let body =
serde_json::to_string(&request).map_err(|e| format!("Could not build request: {e}"))?;
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(180))
.build();
let resp = agent
.post(&url)
.set("Authorization", &format!("Bearer {key}"))
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.send_string(&body)
.map_err(|e| friendly_error(base, e))?;
let text = resp
.into_string()
.map_err(|e| format!("Could not read Mistral reply: {e}"))?;
parse_content(&text)
}
/// Extract the assistant message content from a chat-completions JSON reply.
fn parse_content(body: &str) -> Result<String, String> {
let parsed: ChatResponse =
serde_json::from_str(body).map_err(|e| format!("Unexpected reply from Mistral: {e}"))?;
let content = parsed
.choices
.into_iter()
.next()
.map(|c| c.message.content)
.unwrap_or_default();
let trimmed = content.trim();
if trimmed.is_empty() {
Err("Mistral returned an empty response.".to_string())
} else {
Ok(trimmed.to_string())
}
}
/// Turn a `ureq` error into a message, surfacing the API's own error text on an
/// HTTP status error where possible.
fn friendly_error(base: &str, err: ureq::Error) -> String {
match err {
ureq::Error::Status(code, resp) => {
let detail = resp
.into_string()
.ok()
.and_then(|b| extract_api_message(&b))
.unwrap_or_default();
let hint = match code {
401 => " — check your API key",
429 => " — rate limited or out of quota",
_ => "",
};
if detail.is_empty() {
format!("Mistral API returned HTTP {code}{hint}")
} else {
format!("Mistral API error (HTTP {code}){hint}: {detail}")
}
}
ureq::Error::Transport(t) => {
format!("Could not reach Mistral at {base} ({t})")
}
}
}
/// Pull a human-readable message out of a Mistral/OpenAI-style error body, which
/// may be `{"message": …}`, `{"error": {"message": …}}`, or `{"detail": …}`.
fn extract_api_message(body: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let msg = v
.get("error")
.and_then(|e| e.get("message"))
.or_else(|| v.get("message"))
.or_else(|| v.get("detail"));
match msg {
Some(serde_json::Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_key_is_rejected_without_network() {
let err = generate_beats(" ", DEFAULT_MODEL, DEFAULT_BASE_URL, "a premise").unwrap_err();
assert!(err.contains("API key"), "unexpected: {err}");
}
#[test]
fn parses_assistant_content() {
let body = r###"{
"id": "cmpl-1",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "## Act I — Setup\n1. A beat."},
"finish_reason": "stop"
}
]
}"###;
let out = parse_content(body).unwrap();
assert!(out.starts_with("## Act I — Setup"));
assert!(out.contains("1. A beat."));
}
#[test]
fn empty_choices_is_an_error() {
let err = parse_content(r#"{"choices": []}"#).unwrap_err();
assert!(err.contains("empty"), "unexpected: {err}");
}
#[test]
fn blank_content_is_an_error() {
let body = r#"{"choices":[{"message":{"role":"assistant","content":" "}}]}"#;
assert!(parse_content(body).is_err());
}
#[test]
fn extracts_nested_error_message() {
let body = r#"{"error": {"message": "Invalid API key", "type": "auth"}}"#;
assert_eq!(extract_api_message(body).as_deref(), Some("Invalid API key"));
}
#[test]
fn extracts_flat_and_detail_messages() {
assert_eq!(
extract_api_message(r#"{"message": "boom"}"#).as_deref(),
Some("boom")
);
assert_eq!(
extract_api_message(r#"{"detail": "nope"}"#).as_deref(),
Some("nope")
);
assert_eq!(extract_api_message(r#"{"unrelated": 1}"#), None);
}
}