fix(analysis): use json_schema response_format + surface vision error bodies

The worker hardcoded response_format={type:json_object}, which LM Studio
rejects with 400 ('must be json_schema or text'); it also leaves
reasoning models (Gemma) with an empty `content`. Fix:

- Default to response_format=json_schema with the real analysis schema
  (prompt::output_json_schema), which LM Studio accepts and which forces
  clean JSON into message.content. Configurable via ANALYSIS_RESPONSE_FORMAT
  = json_schema (default) | json_object | none (config::ResponseFormat).
- build_request_body is now a pure, unit-tested function.
- Vision errors now include the server's response body (status + text)
  instead of a bare status, so a 400's reason is visible in logs.

Tests: response-format body shapes (none/json_object/json_schema), schema
shape + round-trip with the DTO, config parsing/defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-13 20:14:38 +02:00
parent 7e675b72cc
commit 268e8cc6c2
6 changed files with 224 additions and 23 deletions

View File

@@ -66,6 +66,32 @@ impl Default for UploadConfig {
}
}
/// How the worker asks the model to constrain its output. OpenAI-compatible
/// servers disagree here: LM Studio accepts only `json_schema` or `text`
/// (NOT `json_object`); vanilla OpenAI/vLLM accept `json_object` too. The
/// default `JsonSchema` is the most portable and the most reliable — it
/// also keeps "thinking" models (e.g. Gemma) from emitting an empty
/// `content` with the answer buried in `reasoning_content`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResponseFormat {
JsonSchema,
JsonObject,
/// Send no `response_format` — rely on the prompt + the parser's
/// fence/brace extraction. Needed for servers that reject the field.
None,
}
impl ResponseFormat {
fn from_str(s: &str) -> ResponseFormat {
match s.trim().to_lowercase().as_str() {
"json_object" => ResponseFormat::JsonObject,
"none" | "text" | "off" | "" => ResponseFormat::None,
// Default (incl. "json_schema" and anything unrecognized).
_ => ResponseFormat::JsonSchema,
}
}
}
/// AI content-analysis worker configuration: the enable gate, the local
/// OpenAI-compatible vision endpoint, and the worker / request knobs.
#[derive(Clone, Debug)]
@@ -93,6 +119,9 @@ pub struct AnalysisConfig {
/// Hard cap on a page image's stored size; larger pages are skipped
/// (`ANALYSIS_MAX_IMAGE_BYTES`).
pub max_image_bytes: usize,
/// Output-constraint mode (`ANALYSIS_RESPONSE_FORMAT`):
/// `json_schema` (default) | `json_object` | `none`.
pub response_format: ResponseFormat,
}
impl Default for AnalysisConfig {
@@ -108,6 +137,7 @@ impl Default for AnalysisConfig {
max_tokens: 900,
max_image_dim: 1024,
max_image_bytes: 8 * 1024 * 1024,
response_format: ResponseFormat::JsonSchema,
}
}
}
@@ -134,6 +164,9 @@ impl AnalysisConfig {
max_tokens: env_u64("ANALYSIS_MAX_TOKENS", d.max_tokens as u64) as u32,
max_image_dim: env_u64("ANALYSIS_MAX_IMAGE_DIM", d.max_image_dim as u64) as u32,
max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes),
response_format: std::env::var("ANALYSIS_RESPONSE_FORMAT")
.map(|s| ResponseFormat::from_str(&s))
.unwrap_or(d.response_format),
}
}
}
@@ -542,6 +575,7 @@ mod tests {
"ANALYSIS_API_KEY",
"ANALYSIS_MAX_TOKENS",
"ANALYSIS_MAX_IMAGE_DIM",
"ANALYSIS_RESPONSE_FORMAT",
] {
std::env::remove_var(k);
}
@@ -550,6 +584,23 @@ mod tests {
assert_eq!(cfg.workers, 1);
assert_eq!(cfg.max_image_dim, 1024);
assert!(cfg.api_key.is_none());
assert_eq!(cfg.response_format, ResponseFormat::JsonSchema);
}
#[test]
fn analysis_response_format_parses_modes() {
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
for (raw, want) in [
("json_object", ResponseFormat::JsonObject),
("none", ResponseFormat::None),
("text", ResponseFormat::None),
("json_schema", ResponseFormat::JsonSchema),
("anything-else", ResponseFormat::JsonSchema),
] {
std::env::set_var("ANALYSIS_RESPONSE_FORMAT", raw);
assert_eq!(AnalysisConfig::from_env().response_format, want, "raw={raw}");
}
std::env::remove_var("ANALYSIS_RESPONSE_FORMAT");
}
#[test]