返回 CodeWhale
context.rs
根目录 / crates / tui / src / core / engine / context.rs
1 //! Context budgeting and prompt-shaping helpers for the engine.
2 //!
3 //! These functions are shared by the streaming turn loop, capacity flow, and
4 //! engine session maintenance code. Keeping them here prevents the top-level
5 //! engine module from accumulating unrelated context-policy details.
6
7 use crate::config::ApiProvider;
8 use crate::context_budget::ContextBudget;
9 #[cfg(test)]
10 pub(super) use crate::route_budget::effective_max_output_tokens;
11 pub(super) use crate::route_budget::effective_max_output_tokens_for_route;
12 use crate::tools::spec::ToolResult;
13 use codewhale_config::route::RouteLimits;
14 use codewhale_models::SystemPrompt;
15 use serde_json::Value;
16 /// Allow a few emergency recovery attempts before failing the turn.
17 pub(super) const MAX_CONTEXT_RECOVERY_ATTEMPTS: u8 = 2;
18 /// Hard cap for any tool output inserted into model context.
19 const TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS: usize = 12_000;
20 /// Soft cap for known noisy tools inserted into model context.
21 const TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS: usize = 2_000;
22 /// Snippet length kept when compacting tool output for model context.
23 const TOOL_RESULT_CONTEXT_SNIPPET_CHARS: usize = 900;
24 /// Hard cap for tool output inserted into a large-context model.
25 const LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS: usize = 48_000;
26 /// Soft cap for known noisy tools inserted into a large-context model.
27 const LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS: usize = 8_000;
28 /// Snippet length kept when compacting large-context noisy output.
29 const LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS: usize = 4_000;
30 /// Context window size at which tool output limits can be relaxed.
31 const LARGE_CONTEXT_WINDOW_TOKENS: u32 = 500_000;
32 /// Max chars to keep from metadata-provided output summaries.
33 const TOOL_RESULT_METADATA_SUMMARY_CHARS: usize = 320;
34
35 #[cfg(test)]
36 pub(super) use crate::compaction::COMPACTION_SUMMARY_MARKER;
37
38 #[derive(Debug, Clone, Copy)]
39 struct ToolResultContextLimits {
40 hard_limit_chars: usize,
41 noisy_soft_limit_chars: usize,
42 snippet_chars: usize,
43 }
44
45 pub(super) fn summarize_text(text: &str, limit: usize) -> String {
46 if text.chars().count() <= limit {
47 return text.to_string();
48 }
49 let take = limit.saturating_sub(3);
50 let mut out: String = text.chars().take(take).collect();
51 out.push_str("...");
52 out
53 }
54
55 fn summarize_text_head_tail(text: &str, limit: usize) -> String {
56 let total = text.chars().count();
57 if total <= limit {
58 return text.to_string();
59 }
60 if limit <= 20 {
61 return summarize_text(text, limit);
62 }
63
64 let marker = "\n\n[... output truncated for context ...]\n\n";
65 let marker_len = marker.chars().count();
66 if limit <= marker_len + 20 {
67 return summarize_text(text, limit);
68 }
69
70 let remaining = limit - marker_len;
71 let head_len = remaining.saturating_mul(2) / 3;
72 let tail_len = remaining.saturating_sub(head_len);
73 let head: String = text.chars().take(head_len).collect();
74 let tail_vec: Vec<char> = text.chars().rev().take(tail_len).collect();
75 let tail: String = tail_vec.into_iter().rev().collect();
76 format!("{head}{marker}{tail}")
77 }
78
79 fn tool_result_is_noisy(tool_name: &str) -> bool {
80 matches!(
81 tool_name,
82 "exec_shell"
83 | "exec_shell_wait"
84 | "exec_shell_interact"
85 | "exec_shell_cancel"
86 | "task_shell_start"
87 | "task_shell_wait"
88 | "run_tests"
89 | "run_verifiers"
90 | "task_gate_run"
91 | "multi_tool_use.parallel"
92 | "Web"
93 | "web_search"
94 | "web.run"
95 | "fetch_url"
96 )
97 }
98
99 fn tool_result_metadata_summary(metadata: Option<&serde_json::Value>) -> Option<String> {
100 let obj = metadata?.as_object()?;
101 for key in ["summary", "stdout_summary", "stderr_summary", "message"] {
102 if let Some(text) = obj.get(key).and_then(serde_json::Value::as_str) {
103 let trimmed = text.trim();
104 if !trimmed.is_empty() {
105 return Some(summarize_text(trimmed, TOOL_RESULT_METADATA_SUMMARY_CHARS));
106 }
107 }
108 }
109 None
110 }
111
112 fn summarize_subagent_status(status: &serde_json::Value) -> String {
113 if let Some(raw) = status.as_str() {
114 return raw.to_string();
115 }
116 if let Some(obj) = status.as_object()
117 && let Some((kind, value)) = obj.iter().next()
118 {
119 if let Some(reason) = value.as_str().filter(|s| !s.trim().is_empty()) {
120 return format!("{kind}({})", summarize_text(reason.trim(), 120));
121 }
122 return kind.to_string();
123 }
124 status.to_string()
125 }
126
127 fn summarize_subagent_snapshot(snapshot: &serde_json::Value, index: usize) -> String {
128 if let Some(inner) = snapshot.get("snapshot") {
129 return summarize_subagent_snapshot(inner, index);
130 }
131
132 let Some(obj) = snapshot.as_object() else {
133 return format!(
134 "- item {index}: {}",
135 summarize_text(&snapshot.to_string(), 240)
136 );
137 };
138
139 let agent_id = obj
140 .get("agent_id")
141 .and_then(serde_json::Value::as_str)
142 .unwrap_or("unknown");
143 let agent_type = obj
144 .get("agent_type")
145 .and_then(serde_json::Value::as_str)
146 .unwrap_or("agent");
147 let status = obj
148 .get("status")
149 .map(summarize_subagent_status)
150 .unwrap_or_else(|| "unknown".to_string());
151 let objective = obj
152 .get("assignment")
153 .and_then(|assignment| assignment.get("objective"))
154 .and_then(serde_json::Value::as_str)
155 .map(str::trim)
156 .filter(|s| !s.is_empty())
157 .map(|s| summarize_text(s, 220));
158 let result = obj
159 .get("result")
160 .and_then(serde_json::Value::as_str)
161 .map(str::trim)
162 .filter(|s| !s.is_empty())
163 .map(|s| summarize_text(s, 1_600));
164 let steps = obj.get("steps_taken").and_then(serde_json::Value::as_u64);
165 let duration_ms = obj.get("duration_ms").and_then(serde_json::Value::as_u64);
166
167 let mut lines = vec![format!("- {agent_id} ({agent_type}) status={status}")];
168 if let Some(objective) = objective {
169 lines.push(format!(" objective: {objective}"));
170 }
171 match result {
172 Some(result) => lines.push(format!(" result: {result}")),
173 None => lines.push(" result: not available yet".to_string()),
174 }
175 if steps.is_some() || duration_ms.is_some() {
176 let steps = steps
177 .map(|n| n.to_string())
178 .unwrap_or_else(|| "?".to_string());
179 let duration_ms = duration_ms
180 .map(|n| n.to_string())
181 .unwrap_or_else(|| "?".to_string());
182 lines.push(format!(" stats: steps={steps}, duration_ms={duration_ms}"));
183 }
184 lines.join("\n")
185 }
186
187 /// A payload is a sub-agent snapshot when it carries the identity/status shape
188 /// this summarizer knows how to render (`agent_id`/`agent_type`, optionally
189 /// wrapped in a `snapshot` field).
190 fn looks_like_subagent_snapshot(value: &serde_json::Value) -> bool {
191 let value = value.get("snapshot").unwrap_or(value);
192 value
193 .as_object()
194 .is_some_and(|obj| obj.contains_key("agent_id") || obj.contains_key("agent_type"))
195 }
196
197 fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> {
198 if tool_name != "agent" {
199 return None;
200 }
201
202 let parsed: serde_json::Value = serde_json::from_str(raw).ok()?;
203 let snapshots: Vec<&serde_json::Value> = match &parsed {
204 serde_json::Value::Array(items) => items.iter().collect(),
205 serde_json::Value::Object(_) => vec![&parsed],
206 _ => return None,
207 };
208
209 // Coordination envelopes (`wait`, `status`, `claim`, ...) carry typed
210 // fields the parent needs verbatim: `settled`, `still_running`,
211 // `timed_out`, `waited_ms`, `note`. Projecting them through the snapshot
212 // renderer replaced every one with `unknown (agent) status=unknown` and
213 // dropped the real payload. Summarize only snapshot-shaped results; let
214 // anything else fall through to the generic bounded path.
215 if snapshots.is_empty()
216 || !snapshots
217 .iter()
218 .all(|value| looks_like_subagent_snapshot(value))
219 {
220 return None;
221 }
222
223 let mut out = String::from("[sub-agent result summarized for parent context]\n");
224 out.push_str(
225 "Child results are self-reports; verify side effects with `File` actions like `read` or `list` before claiming success.\n",
226 );
227 out.push_str("Use `handle_read` on `transcript_handle` for bounded transcript slices when the returned summary is not enough.\n");
228 for (idx, snapshot) in snapshots.iter().enumerate() {
229 if idx >= 8 {
230 out.push_str(&format!(
231 "- ... {} more sub-agent result(s) omitted from context summary\n",
232 snapshots.len().saturating_sub(idx)
233 ));
234 break;
235 }
236 out.push_str(&summarize_subagent_snapshot(snapshot, idx + 1));
237 out.push('\n');
238 }
239 Some(out.trim_end().to_string())
240 }
241
242 fn json_text<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
243 value
244 .get(key)
245 .and_then(Value::as_str)
246 .map(str::trim)
247 .filter(|s| !s.is_empty())
248 }
249
250 fn json_number_text(value: &Value, key: &str) -> Option<String> {
251 value
252 .get(key)
253 .and_then(|value| {
254 value
255 .as_i64()
256 .map(|n| n.to_string())
257 .or_else(|| value.as_u64().map(|n| n.to_string()))
258 })
259 .or_else(|| {
260 value
261 .get(key)
262 .and_then(Value::as_str)
263 .map(str::trim)
264 .filter(|s| !s.is_empty())
265 .map(ToString::to_string)
266 })
267 }
268
269 fn compact_run_tests_result_for_context(raw: &str) -> Option<String> {
270 let parsed: Value = serde_json::from_str(raw).ok()?;
271 let success = parsed.get("success")?.as_bool()?;
272 let exit_code = json_number_text(&parsed, "exit_code").unwrap_or_else(|| "?".to_string());
273 let command = json_text(&parsed, "command").unwrap_or("(unknown command)");
274 let stdout = json_text(&parsed, "stdout");
275 let stderr = json_text(&parsed, "stderr");
276 let stream_limit = if success { 500 } else { 1_000 };
277
278 let mut lines = vec![
279 "[run_tests result summarized for context]".to_string(),
280 format!(
281 "status: {}, exit_code: {exit_code}",
282 if success { "passed" } else { "failed" }
283 ),
284 format!("command: {}", summarize_text(command, 300)),
285 ];
286 if let Some(stderr) = stderr {
287 lines.push(format!(
288 "stderr: {}",
289 summarize_text_head_tail(stderr, stream_limit)
290 ));
291 }
292 if let Some(stdout) = stdout {
293 lines.push(format!(
294 "stdout: {}",
295 summarize_text_head_tail(stdout, stream_limit)
296 ));
297 }
298 Some(lines.join("\n"))
299 }
300
301 fn run_verifier_status_rank(status: Option<&str>) -> u8 {
302 match status.unwrap_or_default() {
303 "failed" | "timeout" => 0,
304 "skipped" => 1,
305 "passed" => 2,
306 _ => 3,
307 }
308 }
309
310 fn compact_run_verifiers_result_for_context(raw: &str) -> Option<String> {
311 let parsed: Value = serde_json::from_str(raw).ok()?;
312 let gates = parsed.get("gates")?.as_array()?;
313 let summary = json_text(&parsed, "summary")
314 .map(ToString::to_string)
315 .unwrap_or_else(|| {
316 let passed = json_number_text(&parsed, "passed").unwrap_or_else(|| "?".to_string());
317 let failed = json_number_text(&parsed, "failed").unwrap_or_else(|| "?".to_string());
318 let skipped = json_number_text(&parsed, "skipped").unwrap_or_else(|| "?".to_string());
319 format!("{passed} passed, {failed} failed, {skipped} skipped")
320 });
321
322 let mut ordered: Vec<&Value> = gates.iter().collect();
323 ordered.sort_by(|a, b| {
324 run_verifier_status_rank(json_text(a, "status"))
325 .cmp(&run_verifier_status_rank(json_text(b, "status")))
326 .then_with(|| json_text(a, "name").cmp(&json_text(b, "name")))
327 });
328
329 let mut lines = vec![
330 "[run_verifiers result summarized for context]".to_string(),
331 format!("summary: {summary}"),
332 ];
333 let profile = json_text(&parsed, "profile");
334 let level = json_text(&parsed, "level");
335 if profile.is_some() || level.is_some() {
336 lines.push(format!(
337 "selection: profile={}, level={}",
338 profile.unwrap_or("?"),
339 level.unwrap_or("?")
340 ));
341 }
342
343 for (idx, gate) in ordered.iter().enumerate() {
344 if idx >= 12 {
345 lines.push(format!(
346 "- ... {} more gate(s) omitted from context summary",
347 ordered.len().saturating_sub(idx)
348 ));
349 break;
350 }
351
352 let name = json_text(gate, "name").unwrap_or("gate");
353 let ecosystem = json_text(gate, "ecosystem").unwrap_or("unknown");
354 let status = json_text(gate, "status").unwrap_or("unknown");
355 let exit = json_number_text(gate, "exit_code")
356 .map(|code| format!(" exit={code}"))
357 .unwrap_or_default();
358 lines.push(format!("- {name} ({ecosystem}): {status}{exit}"));
359
360 if status != "passed" {
361 if let Some(command) = json_text(gate, "command") {
362 lines.push(format!(" command: {}", summarize_text(command, 240)));
363 }
364 if let Some(detail) = json_text(gate, "skipped_reason")
365 .or_else(|| json_text(gate, "stderr"))
366 .or_else(|| json_text(gate, "stdout"))
367 {
368 lines.push(format!(
369 " detail: {}",
370 summarize_text_head_tail(detail, 600)
371 ));
372 }
373 }
374 }
375
376 Some(lines.join("\n"))
377 }
378
379 fn compact_task_gate_run_result_for_context(raw: &str) -> Option<String> {
380 let parsed: Value = serde_json::from_str(raw).ok()?;
381 let gate = parsed.get("gate")?;
382 let gate_name = json_text(gate, "gate").unwrap_or("gate");
383 let status = json_text(gate, "status").unwrap_or("unknown");
384 let command = json_text(gate, "command").unwrap_or("(unknown command)");
385 let summary = json_text(gate, "summary")
386 .or_else(|| json_text(&parsed, "stderr_summary"))
387 .or_else(|| json_text(&parsed, "stdout_summary"));
388 let exit = json_number_text(gate, "exit_code")
389 .map(|code| format!(", exit_code: {code}"))
390 .unwrap_or_default();
391
392 let mut lines = vec![
393 "[task_gate_run result summarized for context]".to_string(),
394 format!("gate: {gate_name}, status: {status}{exit}"),
395 format!("command: {}", summarize_text(command, 300)),
396 ];
397 if let Some(summary) = summary {
398 lines.push(format!("summary: {}", summarize_text(summary, 800)));
399 }
400 if let Some(log_path) = json_text(gate, "log_path") {
401 lines.push(format!("log_path: {log_path}"));
402 }
403 Some(lines.join("\n"))
404 }
405
406 fn compact_structured_tool_result_for_context(tool_name: &str, raw: &str) -> Option<String> {
407 match tool_name {
408 "run_tests" => compact_run_tests_result_for_context(raw),
409 "run_verifiers" => compact_run_verifiers_result_for_context(raw),
410 // `tasks` is the unified durable-task tool (piagent phase B); its
411 // gate_run action emits the same gate payload as the legacy
412 // `task_gate_run` alias. The compactor returns None unless the
413 // content actually parses as a gate result, so non-gate `tasks`
414 // results fall through to the generic limits unchanged.
415 "task_gate_run" | "tasks" => compact_task_gate_run_result_for_context(raw),
416 _ => None,
417 }
418 }
419
420 fn tool_result_context_limits_for_window(context_window: u32) -> ToolResultContextLimits {
421 let is_large_context = context_window >= LARGE_CONTEXT_WINDOW_TOKENS;
422
423 let mut limits = if is_large_context {
424 ToolResultContextLimits {
425 hard_limit_chars: LARGE_CONTEXT_TOOL_RESULT_HARD_LIMIT_CHARS,
426 noisy_soft_limit_chars: LARGE_CONTEXT_TOOL_RESULT_SOFT_LIMIT_CHARS,
427 snippet_chars: LARGE_CONTEXT_TOOL_RESULT_SNIPPET_CHARS,
428 }
429 } else {
430 ToolResultContextLimits {
431 hard_limit_chars: TOOL_RESULT_CONTEXT_HARD_LIMIT_CHARS,
432 noisy_soft_limit_chars: TOOL_RESULT_CONTEXT_SOFT_LIMIT_CHARS,
433 snippet_chars: TOOL_RESULT_CONTEXT_SNIPPET_CHARS,
434 }
435 };
436 if let Some(bytes) =
437 crate::tools::large_output_router::WorkshopConfig::active_tool_result_max_bytes()
438 {
439 // Opt-in long-context profiles may raise the model-visible budget.
440 // Never lower the compile-time floor; cap at 2 MiB (#5367).
441 let raised = bytes.clamp(limits.hard_limit_chars, 2 * 1024 * 1024);
442 limits.hard_limit_chars = raised;
443 limits.snippet_chars = (raised / 3).max(limits.snippet_chars);
444 limits.noisy_soft_limit_chars = limits.noisy_soft_limit_chars.max(raised / 6);
445 }
446 limits
447 }
448
449 #[cfg(test)]
450 pub(crate) fn compact_tool_result_for_context(
451 model: &str,
452 tool_name: &str,
453 output: &ToolResult,
454 ) -> String {
455 compact_tool_result_for_route(ApiProvider::Deepseek, model, None, tool_name, output)
456 }
457
458 pub(crate) fn compact_tool_result_for_route(
459 provider: ApiProvider,
460 model: &str,
461 route_limits: Option<RouteLimits>,
462 tool_name: &str,
463 output: &ToolResult,
464 ) -> String {
465 let raw = output.content.trim();
466 if raw.is_empty() {
467 return String::new();
468 }
469
470 // A result already bounded by the adaptive evidence envelope is an
471 // honest, context-sized preview whose footer names the artifact path and
472 // a recovery instruction. Re-compacting it would strip that recovery
473 // contract and double-truncate the output, so pass it through unchanged.
474 if output
475 .metadata
476 .as_ref()
477 .and_then(|metadata| metadata.get("evidence_available"))
478 .and_then(serde_json::Value::as_bool)
479 .unwrap_or(false)
480 {
481 return raw.to_string();
482 }
483
484 // The `read` primitive already bounds itself to an explicit per-call byte
485 // budget and, when that budget truncates the file, ends with a footer
486 // naming the exact offset to continue from. Compacting it a second time
487 // would drop content the caller deliberately budgeted for *and* delete the
488 // continuation contract, leaving the model with a head/tail snippet and no
489 // way to page. A result that stayed inside its declared budget therefore
490 // passes through; one that somehow exceeded it still falls through to the
491 // ordinary limits below.
492 if output
493 .metadata
494 .as_ref()
495 .and_then(|metadata| metadata.get("read_budget_bytes"))
496 .and_then(serde_json::Value::as_u64)
497 .is_some_and(|budget| raw.len() as u64 <= budget)
498 {
499 return raw.to_string();
500 }
501
502 if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) {
503 return summary;
504 }
505
506 if let Some(summary) = compact_structured_tool_result_for_context(tool_name, raw) {
507 return summary;
508 }
509
510 let context_window =
511 crate::route_budget::route_context_window_tokens(provider, model, route_limits);
512 let limits = tool_result_context_limits_for_window(context_window);
513 let raw_chars = raw.chars().count();
514 let should_compact = raw_chars > limits.hard_limit_chars
515 || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars);
516 if !should_compact {
517 return raw.to_string();
518 }
519
520 let snippet = summarize_text_head_tail(raw, limits.snippet_chars);
521 let omitted = raw_chars.saturating_sub(snippet.chars().count());
522 let summary = tool_result_metadata_summary(output.metadata.as_ref());
523
524 if let Some(summary) = summary {
525 format!(
526 "[{tool_name} output compacted to protect context]\nSummary: {summary}\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
527 )
528 } else {
529 format!(
530 "[{tool_name} output compacted to protect context]\nSnippet: {snippet}\n(Original: {raw_chars} chars, omitted: {omitted} chars.)"
531 )
532 }
533 }
534
535 pub(super) fn extract_compaction_summary_prompt(
536 prompt: Option<SystemPrompt>,
537 ) -> Option<SystemPrompt> {
538 crate::compaction::extract_compaction_summary(prompt.as_ref())
539 }
540
541 /// Internal input-side token budget for a provider/model route:
542 /// `window - reserved_output - headroom`. Used by the preflight check,
543 /// emergency recovery, and capacity trimming to decide when to compact.
544 /// Unknown model ids fall back to the provider's conservative default instead
545 /// of disabling preflight; custom long-context deployments can still advertise
546 /// their window with a `-256k`/`-1024k` model suffix.
547 ///
548 /// The reserved-output term is the route-effective request cap: exactly what
549 /// the API can receive after explicit overrides, compatibility/route ceilings,
550 /// and the route window are intersected. A second hidden reasoning reserve
551 /// would make preflight disagree with the wire request and can cause premature
552 /// compaction on otherwise valid large-window inputs.
553 #[cfg(test)]
554 pub(super) fn context_input_budget_for_provider(
555 provider: ApiProvider,
556 model: &str,
557 ) -> Option<usize> {
558 context_input_budget_for_route(provider, model, None, 0)
559 }
560
561 /// Public so external callers (e.g. a host/bridge deriving its own compaction
562 /// trigger line) can reuse the *exact* same internal input-budget math — window
563 /// minus the route-effective output reservation
564 /// (`route_output_reservation`) minus headroom —
565 /// instead of re-deriving those constants and silently drifting from the engine.
566 /// Pass `input_tokens = 0` to get the full emergency input budget for the route.
567 pub fn context_input_budget_for_route(
568 provider: ApiProvider,
569 model: &str,
570 route_limits: Option<RouteLimits>,
571 input_tokens: usize,
572 ) -> Option<usize> {
573 route_context_budget_for_route(provider, model, route_limits, input_tokens)
574 .and_then(|budget| usize::try_from(budget.available_input_tokens).ok())
575 }
576
577 #[cfg(test)]
578 pub(super) fn route_context_budget_for_provider(
579 provider: ApiProvider,
580 model: &str,
581 input_tokens: usize,
582 ) -> Option<ContextBudget> {
583 route_context_budget_for_route(provider, model, None, input_tokens)
584 }
585
586 pub(super) fn route_context_budget_for_route(
587 provider: ApiProvider,
588 model: &str,
589 route_limits: Option<RouteLimits>,
590 input_tokens: usize,
591 ) -> Option<ContextBudget> {
592 crate::route_budget::route_context_budget(provider, model, route_limits, input_tokens)
593 }
594
595 pub(super) fn is_context_length_error_message(message: &str) -> bool {
596 // Only genuine context-length rejections may drive the bounded
597 // context-recovery retry. The broader `InvalidInput` bucket also holds
598 // wrong-model rejections ("Model not exist."), malformed requests, and
599 // truncated-output terminations, where re-sending a compacted history
600 // cannot help and would hide the real error.
601 let lower = message.to_lowercase();
602 lower.contains("model output truncated")
603 || lower.contains("model response incomplete")
604 || lower.contains("maximum context length")
605 || lower.contains("context length")
606 || lower.contains("context_length")
607 || lower.contains("prompt is too long")
608 || lower.contains("context window")
609 || (lower.contains("requested") && lower.contains("tokens") && lower.contains("maximum"))
610 }
611
612 pub(super) fn is_image_input_rejection_message(message: &str) -> bool {
613 let lower = message.to_lowercase();
614 let image_signal = lower.contains("image_url")
615 || lower.contains("content.type")
616 || lower.contains("content type")
617 || lower.contains("does not support image")
618 || lower.contains("image input")
619 || lower.contains("unsupported modality")
620 || lower
621 .split(|character: char| !character.is_alphanumeric())
622 .any(|term| term == "vision");
623 let rejection_signal = lower.contains("400")
624 || lower.contains("invalid")
625 || lower.contains("unsupported")
626 || lower.contains("not support");
627 image_signal && rejection_signal
628 }
629
630 #[cfg(test)]
631 mod tests {
632 use super::is_image_input_rejection_message;
633
634 #[test]
635 fn image_rejection_classifier_matches_provider_400s() {
636 assert!(is_image_input_rejection_message(
637 r#"request (400): {"error":{"code":"1214","message":"messages.content.type 参数非法, 取值范围 ['text']"}}"#
638 ));
639 assert!(is_image_input_rejection_message(
640 "Invalid content type. image_url is only supported by certain models."
641 ));
642 assert!(!is_image_input_rejection_message("Model not exist."));
643 assert!(!is_image_input_rejection_message("invalid revision id"));
644 assert!(!is_image_input_rejection_message(
645 "This model's maximum context length is 131072 tokens."
646 ));
647 }
648 }
649
649 lines RUST