Separate online and airplane AI profiles.

This commit is contained in:
2026-07-20 11:52:00 +02:00
parent 3739da0984
commit 2d6fcafc7e
21 changed files with 1147 additions and 538 deletions

View File

@@ -53,12 +53,28 @@ pub struct StoredAiEndpointConfig {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AiSettings {
pub offline_mode: bool,
pub default_model: Option<String>,
pub system_prompt: String,
pub online: AiModeSettings,
pub airplane: AiModeSettings,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AiModeSettings {
pub endpoint: StoredAiEndpointConfig,
pub title_model: Option<String>,
pub image_model: Option<String>,
pub system_prompt: String,
pub online_endpoint: StoredAiEndpointConfig,
pub airplane_endpoint: StoredAiEndpointConfig,
pub chat_supports_tools: Option<bool>,
pub image_supports_vision: Option<bool>,
}
impl AiSettings {
pub fn active(&self) -> &AiModeSettings {
if self.offline_mode {
&self.airplane
} else {
&self.online
}
}
}
impl Default for StoredAiEndpointConfig {
@@ -78,6 +94,7 @@ pub struct AiModelInfo {
pub name: String,
pub context_window: Option<u64>,
pub max_output_tokens: Option<u64>,
pub supports_tools: bool,
pub supports_vision: bool,
}
@@ -163,21 +180,28 @@ pub enum OneShotResponse {
}
pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult<AiSettings> {
let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?;
let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?;
let default_model = get_optional_setting(conn, "ai.default_model")?;
let title_model = get_optional_setting(conn, "ai.title_model")?;
let image_model = get_optional_setting(conn, "ai.image_model")?;
let legacy_chat_model = get_optional_setting(conn, "ai.default_model")?;
let legacy_title_model = get_optional_setting(conn, "ai.title_model")?;
let legacy_image_model = get_optional_setting(conn, "ai.image_model")?;
let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default();
Ok(AiSettings {
offline_mode,
default_model,
title_model,
image_model,
system_prompt,
online_endpoint,
airplane_endpoint,
online: load_mode_settings(
conn,
AiEndpointKind::Online,
legacy_chat_model.as_deref(),
legacy_title_model.as_deref(),
legacy_image_model.as_deref(),
)?,
airplane: load_mode_settings(
conn,
AiEndpointKind::Airplane,
legacy_chat_model.as_deref(),
legacy_title_model.as_deref(),
legacy_image_model.as_deref(),
)?,
})
}
@@ -196,20 +220,32 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe
endpoint.model.trim(),
checked_at,
)?;
if endpoint.kind == AiEndpointKind::Online {
if let Some(api_key) = &endpoint.api_key {
save_online_api_key_at(conn, api_key, checked_at)?;
}
set_setting(conn, "ai.default_model", "", checked_at)?;
if let Some(api_key) = &endpoint.api_key {
save_endpoint_api_key_at(conn, endpoint.kind, api_key, checked_at)?;
}
Ok(())
}
pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> {
save_online_api_key_at(conn, api_key, now_unix_ms())
save_endpoint_api_key(conn, AiEndpointKind::Online, api_key)
}
fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> {
let entry = endpoint_keyring_entry(AiEndpointKind::Online)?;
pub fn save_endpoint_api_key(
conn: &Connection,
kind: AiEndpointKind,
api_key: &str,
) -> EngineResult<()> {
save_endpoint_api_key_at(conn, kind, api_key, now_unix_ms())
}
fn save_endpoint_api_key_at(
conn: &Connection,
kind: AiEndpointKind,
api_key: &str,
updated_at: i64,
) -> EngineResult<()> {
let entry = endpoint_keyring_entry(kind)?;
let configured = !api_key.trim().is_empty();
if configured {
entry.set_password(api_key.trim()).map_err(keyring_error)?;
@@ -221,7 +257,7 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
}
set_setting(
conn,
&endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"),
&endpoint_setting_key(kind, "api_key_configured"),
if configured { "true" } else { "false" },
updated_at,
)
@@ -229,19 +265,46 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) ->
pub fn save_model_preferences(
conn: &Connection,
default_model: Option<&str>,
kind: AiEndpointKind,
title_model: Option<&str>,
image_model: Option<&str>,
system_prompt: &str,
chat_supports_tools: Option<bool>,
image_supports_vision: Option<bool>,
) -> EngineResult<()> {
let updated_at = now_unix_ms();
set_optional_setting(conn, "ai.default_model", default_model, updated_at)?;
set_optional_setting(conn, "ai.title_model", title_model, updated_at)?;
set_optional_setting(conn, "ai.image_model", image_model, updated_at)?;
set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?;
set_optional_setting(
conn,
&endpoint_setting_key(kind, "title_model"),
title_model,
updated_at,
)?;
set_optional_setting(
conn,
&endpoint_setting_key(kind, "image_model"),
image_model,
updated_at,
)?;
set_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "chat_supports_tools"),
chat_supports_tools,
updated_at,
)?;
set_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "image_supports_vision"),
image_supports_vision,
updated_at,
)?;
set_setting(conn, "ai.title_model", "", updated_at)?;
set_setting(conn, "ai.image_model", "", updated_at)?;
Ok(())
}
pub fn save_system_prompt(conn: &Connection, system_prompt: &str) -> EngineResult<()> {
set_setting(conn, "ai.system_prompt", system_prompt, now_unix_ms())
}
pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<AiEndpointConfig> {
let kind = if offline_mode {
AiEndpointKind::Airplane
@@ -255,13 +318,19 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult<Ai
kind.as_str()
)));
}
let api_key = if kind == AiEndpointKind::Online {
if kind == AiEndpointKind::Online && !stored.api_key_configured {
return Err(EngineError::Validation(
"AI unavailable - configure online endpoint in Settings".to_string(),
));
}
let api_key = if stored.api_key_configured {
let entry = endpoint_keyring_entry(kind)?;
let password = entry.get_password().map_err(keyring_error)?;
if password.trim().is_empty() {
return Err(EngineError::Validation(
"AI unavailable - configure online endpoint in Settings".to_string(),
));
return Err(EngineError::Validation(format!(
"AI unavailable - configure {} endpoint in Settings",
kind.as_str()
)));
}
Some(password)
} else {
@@ -287,7 +356,7 @@ pub fn load_endpoint_api_key(kind: AiEndpointKind) -> EngineResult<Option<String
}
pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<AiModelInfo>> {
validate_endpoint_config(endpoint)?;
validate_endpoint_access(endpoint)?;
let client = build_http_client()?;
let request = client.get(models_url(&endpoint.url));
let response = with_auth(request, endpoint).send()?.error_for_status()?;
@@ -325,11 +394,17 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
.any(|value| value.as_str() == Some("vision"))
})
.unwrap_or(false);
let supports_tools = model
.get("supports_tools")
.or_else(|| model.get("supportsTools"))
.and_then(Value::as_bool)
.unwrap_or(false);
result.push(AiModelInfo {
id,
name,
context_window,
max_output_tokens,
supports_tools,
supports_vision,
});
}
@@ -337,8 +412,25 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult<Vec<Ai
Ok(result)
}
pub fn test_endpoint(endpoint: &AiEndpointConfig) -> EngineResult<()> {
let _ = refresh_model_catalog(endpoint)?;
pub fn test_chat(endpoint: &AiEndpointConfig, model: &str) -> EngineResult<()> {
validate_endpoint_access(endpoint)?;
if model.trim().is_empty() {
return Err(EngineError::Validation("model is required".to_string()));
}
let payload = json!({
"model": model.trim(),
"messages": [{"role": "user", "content": "Reply with OK."}],
"max_tokens": 8,
"stream": false,
});
with_auth(
build_http_client()?
.post(chat_completions_url(&endpoint.url))
.json(&payload),
endpoint,
)
.send()?
.error_for_status()?;
Ok(())
}
@@ -433,17 +525,50 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult<Stored
})
}
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
fn load_mode_settings(
conn: &Connection,
kind: AiEndpointKind,
legacy_chat_model: Option<&str>,
legacy_title_model: Option<&str>,
legacy_image_model: Option<&str>,
) -> EngineResult<AiModeSettings> {
let mut endpoint = load_endpoint(conn, kind)?;
if endpoint.model.trim().is_empty() {
endpoint.model = legacy_chat_model.unwrap_or_default().to_string();
}
Ok(AiModeSettings {
endpoint,
title_model: get_optional_setting(conn, &endpoint_setting_key(kind, "title_model"))?
.or_else(|| legacy_title_model.map(str::to_string)),
image_model: get_optional_setting(conn, &endpoint_setting_key(kind, "image_model"))?
.or_else(|| legacy_image_model.map(str::to_string)),
chat_supports_tools: get_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "chat_supports_tools"),
)?,
image_supports_vision: get_optional_bool_setting(
conn,
&endpoint_setting_key(kind, "image_supports_vision"),
)?,
})
}
fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> {
validate_endpoint_access(endpoint)?;
if endpoint.model.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint model is required".to_string(),
));
}
Ok(())
}
fn validate_endpoint_access(endpoint: &AiEndpointConfig) -> EngineResult<()> {
if endpoint.url.trim().is_empty() {
return Err(EngineError::Validation(
"endpoint url is required".to_string(),
));
}
if endpoint.kind == AiEndpointKind::Online
&& endpoint
.api_key
@@ -463,17 +588,23 @@ fn select_model(
endpoint: &AiEndpointConfig,
operation: &OneShotOperation,
) -> EngineResult<String> {
let active = settings.active();
let selected = match operation {
OneShotOperation::AnalyzeImage => settings.image_model.as_ref(),
OneShotOperation::AnalyzeImage => {
if active.image_supports_vision == Some(false) {
return Err(EngineError::Validation(
"AI unavailable - selected image model is not configured for vision"
.to_string(),
));
}
active.image_model.as_ref()
}
OneShotOperation::AnalyzeTaxonomy
| OneShotOperation::MapImportTaxonomy
| OneShotOperation::AnalyzePost
| OneShotOperation::DetectLanguage
| OneShotOperation::TranslatePost { .. }
| OneShotOperation::TranslateMedia { .. } => settings
.title_model
.as_ref()
.or(settings.default_model.as_ref()),
| OneShotOperation::TranslateMedia { .. } => active.title_model.as_ref(),
}
.filter(|model| !model.trim().is_empty())
.cloned()
@@ -746,6 +877,22 @@ fn set_optional_setting(
set_setting(conn, key, value.unwrap_or(""), updated_at)
}
fn set_optional_bool_setting(
conn: &Connection,
key: &str,
value: Option<bool>,
updated_at: i64,
) -> EngineResult<()> {
set_setting(
conn,
key,
value
.map(|value| if value { "true" } else { "false" })
.unwrap_or(""),
updated_at,
)
}
fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
match setting::get_setting_by_key(conn, key) {
Ok(setting) if setting.value.trim().is_empty() => Ok(None),
@@ -755,6 +902,10 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult<Option<Str
}
}
fn get_optional_bool_setting(conn: &Connection, key: &str) -> EngineResult<Option<bool>> {
Ok(get_optional_setting(conn, key)?.map(|value| value == "true"))
}
fn models_url(base_url: &str) -> String {
join_openai_path(base_url, "models")
}
@@ -808,9 +959,9 @@ mod tests {
let db = setup();
let settings = load_ai_settings(db.conn(), false).unwrap();
assert!(!settings.offline_mode);
assert!(settings.online_endpoint.url.is_empty());
assert!(settings.airplane_endpoint.url.is_empty());
assert!(settings.default_model.is_none());
assert!(settings.online.endpoint.url.is_empty());
assert!(settings.airplane.endpoint.url.is_empty());
assert!(settings.online.title_model.is_none());
}
#[test]
@@ -859,24 +1010,125 @@ mod tests {
let server = spawn_test_server(
|request| {
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
assert!(
request.contains("authorization: Bearer dummy")
|| request.contains("Authorization: Bearer dummy")
);
http_ok(
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"supports_tools":true,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#,
)
},
1,
);
let models = refresh_model_catalog(&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
kind: AiEndpointKind::Online,
url: server,
model: "gpt-4.1-mini".to_string(),
api_key: None,
model: String::new(),
api_key: Some("dummy".to_string()),
})
.unwrap();
assert_eq!(models.len(), 2);
assert_eq!(models[0].name, "GPT 4.1 mini");
assert!(models[0].supports_tools);
assert!(models[1].supports_vision);
}
#[test]
fn model_preferences_are_independent_per_endpoint() {
let db = setup();
save_model_preferences(
db.conn(),
AiEndpointKind::Online,
Some("online-title"),
Some("online-image"),
Some(true),
Some(true),
)
.unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Airplane,
Some("local-title"),
Some("local-image"),
Some(false),
Some(true),
)
.unwrap();
let settings = load_ai_settings(db.conn(), false).unwrap();
assert_eq!(settings.online.title_model.as_deref(), Some("online-title"));
assert_eq!(
settings.airplane.title_model.as_deref(),
Some("local-title")
);
assert_eq!(settings.online.chat_supports_tools, Some(true));
assert_eq!(settings.airplane.chat_supports_tools, Some(false));
assert_eq!(settings.online.image_supports_vision, Some(true));
assert_eq!(settings.airplane.image_supports_vision, Some(true));
}
#[test]
fn test_chat_sends_the_selected_model() {
let server = spawn_test_server(
|request| {
assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1"));
assert!(request.contains(r#""model":"qwen-9b""#));
assert!(
request.contains("authorization: Bearer dummy")
|| request.contains("Authorization: Bearer dummy")
);
http_ok(r#"{"choices":[{"message":{"content":"OK"}}]}"#)
},
1,
);
test_chat(
&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
url: server,
model: String::new(),
api_key: Some("dummy".to_string()),
},
"qwen-9b",
)
.unwrap();
}
#[test]
fn explicit_vision_override_blocks_image_requests() {
let db = setup();
save_endpoint(
db.conn(),
&AiEndpointConfig {
kind: AiEndpointKind::Airplane,
url: "http://localhost:11434/v1".to_string(),
model: "qwen".to_string(),
api_key: None,
},
)
.unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Airplane,
None,
Some("qwen-vl"),
Some(false),
Some(false),
)
.unwrap();
let error = run_one_shot(
db.conn(),
true,
&OneShotRequest {
operation: OneShotOperation::AnalyzeImage,
content: json!({"image_data_url": "data:image/jpeg;base64,abc123"}),
},
)
.unwrap_err();
assert!(error.to_string().contains("not configured for vision"));
}
#[test]
#[ignore = "touches system keychain; run explicitly when validating keychain integration"]
fn run_one_shot_uses_active_endpoint_and_parses_response() {
@@ -909,7 +1161,15 @@ mod tests {
},
)
.unwrap();
save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap();
save_model_preferences(
db.conn(),
AiEndpointKind::Online,
Some("gpt-4.1-mini"),
None,
None,
None,
)
.unwrap();
let (response, usage) = run_one_shot(
db.conn(),

View File

@@ -84,6 +84,7 @@ pub struct ChatSendOptions {
pub max_output_tokens: u64,
pub max_tool_rounds: usize,
pub enable_tools: bool,
pub model_supports_tools: Option<bool>,
pub event_handler: Option<Arc<dyn Fn(ChatEvent) + Send + Sync>>,
}
@@ -96,6 +97,7 @@ impl Default for ChatSendOptions {
max_output_tokens: DEFAULT_OUTPUT_TOKENS,
max_tool_rounds: MAX_TOOL_ROUNDS,
enable_tools: true,
model_supports_tools: None,
event_handler: None,
}
}
@@ -467,7 +469,7 @@ pub fn send_chat_message(
offline_mode: bool,
conversation_id: &str,
content: &str,
options: ChatSendOptions,
mut options: ChatSendOptions,
) -> EngineResult<ChatTurnResult> {
let content = content.trim();
if content.is_empty() {
@@ -480,6 +482,11 @@ pub fn send_chat_message(
Some(endpoint) => validate_runtime_endpoint(endpoint)?,
None => ai::active_endpoint(conn, offline_mode)?,
};
if options.model_supports_tools.is_none() {
options.model_supports_tools = ai::load_ai_settings(conn, offline_mode)?
.active()
.chat_supports_tools;
}
let model = options
.model
.clone()
@@ -568,7 +575,11 @@ fn run_turns(
) -> EngineResult<ChatTurnResult> {
let mut total_usage = TokenUsage::default();
let max_rounds = options.max_tool_rounds.min(MAX_TOOL_ROUNDS);
let supports_tools = options.enable_tools && chat_tools::model_supports_tools(conn, model)?;
let supports_tools = options.enable_tools
&& match options.model_supports_tools {
Some(supports_tools) => supports_tools,
None => chat_tools::model_supports_tools(conn, model)?,
};
let catalog_model = list_models(conn)?
.into_iter()
.find(|candidate| candidate.id == model);
@@ -841,7 +852,28 @@ fn request_completion(
if let Some(api_key) = endpoint.api_key.as_deref() {
request = request.bearer_auth(api_key);
}
let mut response = request.send()?.error_for_status()?;
let mut response = request.send()?;
let status = response.status();
if !status.is_success() {
let body = response.text().unwrap_or_default();
let detail = serde_json::from_str::<Value>(&body)
.ok()
.and_then(|value| {
value
.pointer("/error/message")
.or_else(|| value.get("error"))
.or_else(|| value.get("message"))
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| body.trim().to_string());
let detail = (!detail.is_empty())
.then_some(format!(": {detail}"))
.unwrap_or_default();
return Err(EngineError::Parse(format!(
"AI provider returned {status}{detail}"
)));
}
let is_stream = response
.headers()
.get(reqwest::header::CONTENT_TYPE)

View File

@@ -7,7 +7,9 @@ use crate::util::now_unix_ms;
pub const UI_LANGUAGE_KEY: &str = "ui.language";
pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key";
pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key";
const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured";
const AIRPLANE_API_KEY_CONFIGURED: &str = "ai.endpoint.airplane.api_key_configured";
const DEFAULTS: &[(&str, &str)] = &[
("editor.default_mode", "markdown"),
("editor.diff_view_style", "inline"),
@@ -15,9 +17,18 @@ const DEFAULTS: &[(&str, &str)] = &[
("editor.hide_unchanged_regions", "false"),
("ai.endpoint.online.url", ""),
("ai.endpoint.online.model", ""),
("ai.endpoint.online.title_model", ""),
("ai.endpoint.online.image_model", ""),
("ai.endpoint.online.chat_supports_tools", ""),
("ai.endpoint.online.image_supports_vision", ""),
(ONLINE_API_KEY, ""),
("ai.endpoint.airplane.url", ""),
("ai.endpoint.airplane.model", ""),
("ai.endpoint.airplane.title_model", ""),
("ai.endpoint.airplane.image_model", ""),
("ai.endpoint.airplane.chat_supports_tools", ""),
("ai.endpoint.airplane.image_supports_vision", ""),
(AIRPLANE_API_KEY, ""),
("ai.default_model", ""),
("ai.title_model", ""),
("ai.image_model", ""),
@@ -37,9 +48,14 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
}
pub fn get_effective(conn: &Connection, key: &str) -> EngineResult<Option<String>> {
if key == ONLINE_API_KEY {
if key == ONLINE_API_KEY || key == AIRPLANE_API_KEY {
let configured_key = if key == ONLINE_API_KEY {
ONLINE_API_KEY_CONFIGURED
} else {
AIRPLANE_API_KEY_CONFIGURED
};
return Ok(Some(
get(conn, ONLINE_API_KEY_CONFIGURED)?
get(conn, configured_key)?
.filter(|value| value == "true")
.map_or_else(String::new, |_| "configured".to_string()),
));
@@ -69,6 +85,8 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
if !value.key.starts_with("app.")
&& value.key != ONLINE_API_KEY
&& value.key != ONLINE_API_KEY_CONFIGURED
&& value.key != AIRPLANE_API_KEY
&& value.key != AIRPLANE_API_KEY_CONFIGURED
{
values.insert(value.key, value.value);
}
@@ -76,6 +94,9 @@ pub fn list_effective(conn: &Connection) -> EngineResult<BTreeMap<String, String
if get(conn, ONLINE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
values.insert(ONLINE_API_KEY.to_string(), "configured".to_string());
}
if get(conn, AIRPLANE_API_KEY_CONFIGURED)?.as_deref() == Some("true") {
values.insert(AIRPLANE_API_KEY.to_string(), "configured".to_string());
}
Ok(values)
}

View File

@@ -233,6 +233,33 @@ fn malformed_stream_and_provider_error_keep_reopenable_user_turn() {
}
}
#[test]
fn non_success_response_preserves_provider_error_detail() {
let (_root, db, project_id, data_dir) = setup();
let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap();
let (url, server) = serve(vec![MockResponse::status(
400,
"application/json",
"{\"error\":{\"message\":\"tokenizer.chat_template is not set\"}}",
)]);
let error = chat::send_chat_message(
db.conn(),
&data_dir,
&project_id,
false,
&conversation.id,
"Hello",
options(url, |_| {}),
)
.unwrap_err()
.to_string();
server.join().unwrap();
assert!(error.contains("400"));
assert!(error.contains("tokenizer.chat_template is not set"));
}
#[test]
fn cancellation_persists_only_received_content_and_never_runs_later_work() {
let (_root, db, project_id, data_dir) = setup();