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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user