Skip to content

fix(llm): fail fast on HTTP 400 invalid-model (stop the auto-model retry hang) - #5043

Closed
abbyshekit wants to merge 2 commits into
mainfrom
fix/fail-fast-invalid-model
Closed

fix(llm): fail fast on HTTP 400 invalid-model (stop the auto-model retry hang)#5043
abbyshekit wants to merge 2 commits into
mainfrom
fix/fail-fast-invalid-model

Conversation

@abbyshekit

Copy link
Copy Markdown
Contributor

A 400 'model not found / not a valid model' (e.g. NEARAI_MODEL=auto sent to a
cloud-api.near.ai gateway that has no auto alias) fell through to the
retryable RequestFailed path, so it was retried 3x at the provider layer and
again at the loop layer — turning a one-line model-config mistake into a
multi-minute silent hang where the user's turn never produced a reply (the
'answers don't show up' report). Live repro: with model=auto every turn hung
120s+ with no reply; with a real model id (anthropic/claude-haiku-4-5 via
cloud-api.near.ai) the same turns answered in ~4s.

Add error::invalid_model_error (mirrors context_length_error) and map a 400
invalid-model in nearai_chat to the non-retryable LlmError::ModelNotAvailable,
so the run fails fast with a clear 'model not available' message (which the
WebChat v2 timeline already surfaces) instead of hanging. Guards against
misreading a context-length 400. Does NOT change what auto resolves to —
that's the product decision tracked separately.

cargo test -p ironclaw_llm: 875 passed, 0 failed (2 new).

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

@railway-app

railway-app Bot commented Jun 17, 2026

Copy link
Copy Markdown

This PR was not deployed automatically as @abbyshekit does not have access to the Railway project.

In order to get automatic PR deploys, please add @abbyshekit to your workspace on Railway.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: aec01a73-066d-4750-af32-3968ecd0eff2

📥 Commits

Reviewing files that changed from the base of the PR and between 40e0494 and 91aec53.

📒 Files selected for processing (2)
  • crates/ironclaw_llm/src/error.rs
  • crates/ironclaw_llm/src/nearai_chat.rs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved error handling when requesting unavailable models. The system now detects “model not found/invalid model” responses from the provider and reports them as non-retryable model availability issues instead of falling back to generic handling or retry logic.
  • Tests

    • Added unit tests covering detection of invalid model errors across different message formats, including case-preserving model name extraction and ignoring unrelated 400 responses.

Walkthrough

Adds a crate-visible invalid_model_error function in error.rs that detects HTTP 400 responses indicating an invalid or unknown model, parses the quoted model name from the provider body (falling back to "unknown"), and returns it. nearai_chat.rs's send_request_inner calls this detector and maps a hit to LlmError::ModelNotAvailable, bypassing generic error handling.

Changes

Invalid model detection and non-retryable error mapping

Layer / File(s) Summary
invalid_model_error detector and model name parser
crates/ironclaw_llm/src/error.rs
Adds pub(crate) fn invalid_model_error(status_code: u16, response_text: &str) -> Option<String> that gates on HTTP 400, keyword-matches the body for invalid/unknown/not-found model phrases, and extracts the quoted model name via a best-effort parser (fallback: "unknown"). Two unit tests cover the near.ai body pattern, case preservation, and negative cases.
send_request_inner wiring to ModelNotAvailable
crates/ironclaw_llm/src/nearai_chat.rs
Calls invalid_model_error on each error response; on Some(model), returns LlmError::ModelNotAvailable immediately, preventing fall-through to generic 5xx/other error paths.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related issues

Poem

A model named "auto" walked up to the door,
Got a 400 — "you're not here anymore."
The parser peeks past the quotes, finds the name,
ModelNotAvailable shoulders the blame.
No retry loop, no generic despair —
Just a clean non-retryable "model's not there." 🦀

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning Description includes summary of problem and fix, but lacks required checklist sections (Change Type, Linked Issue, Validation, Security Impact, Blast Radius, Review Track). Complete the PR template: mark Change Type (Bug fix), provide Linked Issue, confirm Validation steps run, address Security Impact and Blast Radius, specify Review track.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Title follows Conventional Commits style with type(llm) and scope, clearly describes the fix (fail fast on HTTP 400 invalid-model) and addresses the core issue (stop auto-model retry hang).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: new First-time contributor labels Jun 17, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces logic to detect and handle permanent client errors caused by invalid or unknown model requests (HTTP 400) from the NearAI provider, mapping them to LlmError::ModelNotAvailable to prevent infinite retry loops. The review feedback suggests refining the error detection substring matching to avoid false positives with model parameter errors, and improving the robustness of the model name extraction function to correctly handle escaped quotes and colon separators.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +107 to +124
pub(crate) fn invalid_model_error(status_code: u16, response_text: &str) -> Option<String> {
if status_code != 400 {
return None;
}
let lower = response_text.to_ascii_lowercase();
// Must be about the model, and say it is unusable. Guard against matching a
// context-length 400 (handled separately) by requiring model-name wording.
let mentions_model = lower.contains("model");
let is_invalid = lower.contains("not found")
|| lower.contains("not a valid model")
|| lower.contains("invalid model")
|| lower.contains("unknown model")
|| lower.contains("is not a valid model name or alias");
if !(mentions_model && is_invalid) {
return None;
}
Some(parse_invalid_model_name(response_text).unwrap_or_else(|| "unknown".to_string()))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Checking for "invalid model" or "unknown model" as substrings can lead to false positives when the error message is actually about a model parameter (e.g., "invalid model parameter: temperature must be between 0 and 2" or "unknown model parameter"). This would incorrectly map parameter validation errors to a non-retryable ModelNotAvailable error, preventing the user from seeing the actual parameter error. We should explicitly exclude these parameter-related phrases to ensure correct error propagation.

Suggested change
pub(crate) fn invalid_model_error(status_code: u16, response_text: &str) -> Option<String> {
if status_code != 400 {
return None;
}
let lower = response_text.to_ascii_lowercase();
// Must be about the model, and say it is unusable. Guard against matching a
// context-length 400 (handled separately) by requiring model-name wording.
let mentions_model = lower.contains("model");
let is_invalid = lower.contains("not found")
|| lower.contains("not a valid model")
|| lower.contains("invalid model")
|| lower.contains("unknown model")
|| lower.contains("is not a valid model name or alias");
if !(mentions_model && is_invalid) {
return None;
}
Some(parse_invalid_model_name(response_text).unwrap_or_else(|| "unknown".to_string()))
}
pub(crate) fn invalid_model_error(status_code: u16, response_text: &str) -> Option<String> {
if status_code != 400 {
return None;
}
let lower = response_text.to_ascii_lowercase();
// Must be about the model, and say it is unusable. Guard against matching a
// context-length 400 (handled separately) by requiring model-name wording.
let mentions_model = lower.contains("model");
let is_invalid = lower.contains("not found")
|| lower.contains("not a valid model")
|| (lower.contains("invalid model") && !lower.contains("invalid model parameter") && !lower.contains("invalid model option"))
|| (lower.contains("unknown model") && !lower.contains("unknown model parameter"))
|| lower.contains("is not a valid model name or alias");
if !(mentions_model && is_invalid) {
return None;
}
Some(parse_invalid_model_name(response_text).unwrap_or_else(|| "unknown".to_string()))
}
References
  1. When classifying errors by matching substrings, avoid overly generic patterns like 'request failed' that can cause false positives. Prefer more specific markers like HTTP status codes or explicit error messages.

Comment on lines +128 to +140
fn parse_invalid_model_name(text: &str) -> Option<String> {
let idx = text.to_ascii_lowercase().find("model")?;
let after = &text[idx + "model".len()..];
let mut chars = after.char_indices().skip_while(|(_, c)| c.is_whitespace());
let (start, quote) = chars.next()?;
if quote != '\'' && quote != '"' {
return None;
}
let rest = &after[start + quote.len_utf8()..];
let end = rest.find(quote)?;
let name = rest[..end].trim();
(!name.is_empty()).then(|| name.to_string())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of parse_invalid_model_name fails to extract the model name if it is double-quoted in the JSON message (which is escaped as \" in the raw response_text), because the backslash \ becomes the first non-whitespace character after "model". Additionally, it fails if there is a colon separator (e.g., "Model: 'auto'"). We can make this extraction much more robust by skipping colons and backslashes during the search for the quote character, and then trimming any trailing backslashes from the extracted name.

Suggested change
fn parse_invalid_model_name(text: &str) -> Option<String> {
let idx = text.to_ascii_lowercase().find("model")?;
let after = &text[idx + "model".len()..];
let mut chars = after.char_indices().skip_while(|(_, c)| c.is_whitespace());
let (start, quote) = chars.next()?;
if quote != '\'' && quote != '"' {
return None;
}
let rest = &after[start + quote.len_utf8()..];
let end = rest.find(quote)?;
let name = rest[..end].trim();
(!name.is_empty()).then(|| name.to_string())
}
fn parse_invalid_model_name(text: &str) -> Option<String> {
let idx = text.to_ascii_lowercase().find("model")?;
let after = &text[idx + "model".len()..];
let mut chars = after.char_indices().skip_while(|(_, c)| c.is_whitespace() || *c == ':' || *c == '\\');
let (start, quote) = chars.next()?;
if quote != '\'' && quote != '"' {
return None;
}
let rest = &after[start + quote.len_utf8()..];
let end = rest.find(quote)?;
let name = rest[..end].trim_end_matches('\\').trim();
(!name.is_empty()).then(|| name.to_string())
}

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_llm/src/error.rs`:
- Around line 129-138: The model-name parser starting at line 129 searches for
"model" in the entire raw JSON text without proper context, causing it to
extract incorrect characters when param:"model" appears before the actual error
message. Fix this by first extracting the error.message field from the JSON,
then perform the model name extraction (finding the "model" keyword, skipping
whitespace, and extracting the quoted string) only within that message content
instead of the entire text. This ensures the parser anchors to the actual error
message rather than field definitions.

In `@crates/ironclaw_llm/src/nearai_chat.rs`:
- Around line 412-422: Add a regression test for the new ModelNotAvailable error
mapping behavior. Create a new tokio test function in the nearai_chat test
module that mocks an HTTP 400 response from the `/v1/chat/completions` endpoint
indicating an invalid model, then calls the complete() function and asserts that
the returned error is LlmError::ModelNotAvailable with the correct provider and
model fields, rather than LlmError::RequestFailed. This test should drive the
complete() function through the new code path in the diff that calls
crate::error::invalid_model_error() and verifies the mapping works correctly at
the caller level.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 19044d38-9138-4d2b-87da-c1a909fffd42

📥 Commits

Reviewing files that changed from the base of the PR and between a93f9ab and 40e0494.

📒 Files selected for processing (2)
  • crates/ironclaw_llm/src/error.rs
  • crates/ironclaw_llm/src/nearai_chat.rs

Comment on lines +129 to +138
let idx = text.to_ascii_lowercase().find("model")?;
let after = &text[idx + "model".len()..];
let mut chars = after.char_indices().skip_while(|(_, c)| c.is_whitespace());
let (start, quote) = chars.next()?;
if quote != '\'' && quote != '"' {
return None;
}
let rest = &after[start + quote.len_utf8()..];
let end = rest.find(quote)?;
let name = rest[..end].trim();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Model-name parser can return junk when param:"model" appears before the message

Line 129 anchors on the first "model" anywhere in the raw JSON, so field-order changes can extract "," (or similar) instead of the actual model id. Anchor extraction to error.message first, then parse quoted model text from that string.

Proposed fix
 fn parse_invalid_model_name(text: &str) -> Option<String> {
-    let idx = text.to_ascii_lowercase().find("model")?;
-    let after = &text[idx + "model".len()..];
+    let message = serde_json::from_str::<serde_json::Value>(text)
+        .ok()
+        .and_then(|v| {
+            v.get("error")
+                .and_then(|e| e.get("message"))
+                .and_then(|m| m.as_str())
+        })
+        .unwrap_or(text);
+    let idx = message.to_ascii_lowercase().find("model")?;
+    let after = &message[idx + "model".len()..];
     let mut chars = after.char_indices().skip_while(|(_, c)| c.is_whitespace());
     let (start, quote) = chars.next()?;
     if quote != '\'' && quote != '"' {
         return None;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_llm/src/error.rs` around lines 129 - 138, The model-name
parser starting at line 129 searches for "model" in the entire raw JSON text
without proper context, causing it to extract incorrect characters when
param:"model" appears before the actual error message. Fix this by first
extracting the error.message field from the JSON, then perform the model name
extraction (finding the "model" keyword, skipping whitespace, and extracting the
quoted string) only within that message content instead of the entire text. This
ensures the parser anchors to the actual error message rather than field
definitions.

Comment on lines +412 to +422
// HTTP 400 "model not found / not a valid model" — a permanent client
// error (e.g. NEARAI_MODEL=auto against a gateway with no `auto`
// alias). Map to the non-retryable ModelNotAvailable so the run fails
// fast with a clear message instead of retry-looping into a
// multi-minute silent hang (the "answer never appears" report).
if let Some(model) = crate::error::invalid_model_error(status_code, &response_text) {
return Err(LlmError::ModelNotAvailable {
provider: "nearai_chat".to_string(),
model,
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add caller-level regression test for the new ModelNotAvailable mapping

This behavior change is in the request path, but there’s no nearai_chat regression test asserting that a 400 invalid-model response from /v1/chat/completions becomes LlmError::ModelNotAvailable (and not RequestFailed). Add a #[tokio::test] that drives complete() through this branch.

As per coding guidelines, “Every bug fix must include a regression test” and invariant “Test through the caller … not only the helper.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_llm/src/nearai_chat.rs` around lines 412 - 422, Add a
regression test for the new ModelNotAvailable error mapping behavior. Create a
new tokio test function in the nearai_chat test module that mocks an HTTP 400
response from the `/v1/chat/completions` endpoint indicating an invalid model,
then calls the complete() function and asserts that the returned error is
LlmError::ModelNotAvailable with the correct provider and model fields, rather
than LlmError::RequestFailed. This test should drive the complete() function
through the new code path in the diff that calls
crate::error::invalid_model_error() and verifies the mapping works correctly at
the caller level.

Source: Coding guidelines

…g (F8)

A 400 'model not found / not a valid model' (e.g. NEARAI_MODEL=auto sent to a
cloud-api.near.ai gateway that has no `auto` alias) fell through to the
retryable RequestFailed path, so it was retried 3x at the provider layer and
again at the loop layer — turning a one-line model-config mistake into a
multi-minute silent hang where the user's turn never produced a reply (the
'answers don't show up' report). Live repro: with model=auto every turn hung
120s+ with no reply; with a real model id (anthropic/claude-haiku-4-5 via
cloud-api.near.ai) the same turns answered in ~4s.

Add error::invalid_model_error (mirrors context_length_error) and map a 400
invalid-model in nearai_chat to the non-retryable LlmError::ModelNotAvailable,
so the run fails fast with a clear 'model not available' message (which the
WebChat v2 timeline already surfaces) instead of hanging. Guards against
misreading a context-length 400. Does NOT change what `auto` resolves to —
that's the product decision tracked separately.

cargo test -p ironclaw_llm: 875 passed, 0 failed (2 new).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@abbyshekit
abbyshekit force-pushed the fix/fail-fast-invalid-model branch from 40e0494 to 91aec53 Compare June 17, 2026 23:02

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — VERDICT: APPROVE WITH NITS (batch review pass)

Well-targeted fix: maps an "invalid/unknown model" HTTP 400 from NEAR AI to the non-retryable ModelNotAvailable, breaking the multi-minute retry/failover hang.

Relevance: still relevant — invalid_model_error/resolve_nearai_model are absent from current main (129ce21), not merged or superseded. Mergeable: yes, CI green.

Verified correct: today all non-context/non-5xx 400s → RequestFailed, which is_retryable() treats as retryable (retry.rs:45-59) → both retry and failover loop on a permanent client error = the reported hang. ModelNotAvailable is non-retryable (retry.rs:40, failover.rs:1132), so the remap fails fast. Ordering is right — context_length_error runs before the new branch, so a context-length 400 can't be misread as invalid-model. Tests are pure string tests (deterministic, no network); no unwrap/panic on the parse path.

Findings:

  1. Low — parse_invalid_model_name latches onto the first "model" token; with "param":"model" ordered before the message it can extract a stray comma as the model name (display-only cosmetic; the real near.ai body works). Anchor extraction to the invalidity phrase.
  2. Low — contains("model") + "not found" is body-substring-brittle; a non-model 400 mentioning both gets reclassified (harmless since 400s are non-retryable either way, but it swaps the informative body for a generic message).
  3. Info — "is not a valid model name or alias" is a dead superset of "not a valid model".
  4. Scope — the shared helper is only wired into nearai_chat.rs; codex/copilot/bedrock still map invalid-model 400 → retryable RequestFailed.

Land together with #5045 (same author): #5045 prevents the bad request (auto→concrete model), this PR is the fail-fast net for any future bad model. No file/text conflict.

🤖 batch PR-review pass via parallel-pr-review

@serrrfirat

Copy link
Copy Markdown
Collaborator

Closing in favor of #7939. This branch conflicts with current main and targets retired or substantially changed surfaces. We will salvage any still-relevant requirement through fresh, focused PR(s) tracked in #7939; behavior already superseded on main will not be reimplemented.

@serrrfirat serrrfirat closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: new First-time contributor risk: low Changes to docs, tests, or low-risk modules size: M 50-199 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants