Skip to content

fix(models): make structured-output calls resilient to weak OpenRouter models#919

Merged
harshithmullapudi merged 2 commits into
mainfrom
fix/openrouter-structured-output-tolerant-parsing
Jun 26, 2026
Merged

fix(models): make structured-output calls resilient to weak OpenRouter models#919
harshithmullapudi merged 2 commits into
mainfrom
fix/openrouter-structured-output-tolerant-parsing

Conversation

@AggManik

Copy link
Copy Markdown
Contributor

Background

Follow-up to #918. That PR fixed contact summaries silently using the wrong model slot (Chat instead of Memory Ingestion). But even with the right model slot selected, if that slot is configured with an OpenRouter model that isn't great at strict structured output (e.g. a small/reasoning-tuned model like openrouter/xiaomi/mimo-v2.5-pro), the same class of bug can resurface: partial or blank structured responses that silently pass validation because most schema fields default to "".

This branch includes #918's commit so it has no merge conflicts with it — it's meant to land after/alongside that PR.

Bug

makeStructuredModelCall (apps/webapp/app/lib/model.server.ts) has two ways of getting JSON out of a model:

  1. Strict path — relies on the model's native structured-output / tool-calling enforcement (used by default).
  2. Tolerant path — explicitly writes the JSON schema into the prompt, parses leniently, and runs a repair pass if parsing/validation fails. Previously only used for Ollama and self-hosted OpenAI-compatible proxies.

needsTolerantParsing() decided which path to use by checking only the global default chat provider (Ollama) and an OpenAI-proxy config flag — it never looked at which provider the actual model for this call resolves to, and it had no special handling for OpenRouter at all. So any OpenRouter model, regardless of how good or bad it is at structured output, went through the strict path, which assumes a level of native JSON-schema/tool-calling reliability that OpenRouter can't guarantee (it routes to hundreds of different third-party models).

Result: a less capable OpenRouter model can fail structured output outright, or return a half-filled JSON object that still validates successfully (since most fields default to ""), producing a silently incomplete result instead of a visible failure.

Fix

  • needsTolerantParsing now takes the resolved model string for the current call and checks its actual provider (via the existing getProvider() helper), not just the global default.
  • Added isOpenRouter to the tolerant-parsing condition, so any call resolving to an openrouter/... model now goes through the same schema-hinted prompt + lenient parse + repair-pass flow Ollama already gets, instead of assuming native structured-output support it may not have.

This makes structured-output calls resilient to whichever model a workspace has configured via OpenRouter, rather than requiring the operator to hand-pick a model known to support strict structured output well.

Testing

  • pnpm exec vitest run app/services/contacts/__tests__/contact-summary.test.ts app/services/contacts/__tests__/reconcile.test.ts — 13/13 passing (these mock makeStructuredModelCall directly, unaffected by the change, included to confirm no regression).
  • pnpm exec tsc --noEmit in apps/webapp — no new type errors (7 pre-existing failures in unrelated files, none touching model.server.ts).

AggManik added 2 commits June 24, 2026 16:58
generateContactSummary builds a structured CRM profile (email, company,
location, headline, narrative summary) from memory episodes, but its
makeStructuredModelCall call omitted the useCase argument, which defaults
to "chat". So instead of using the workspace's configured Memory Ingestion
model, built for reliable structured extraction, it silently used the Chat
model, which is often a cheaper/smaller model picked for conversational
replies and not for strict structured-output tasks. Every other extraction
job in the codebase, label-assignment, knowledgeGraph extraction,
aspect-resolution, already passes useCase "memory" for this reason.

With a weak chat model this produced blank or partially filled contact
profiles, e.g. only a one-line headline with no email, company, location,
or summary, since most ContactSummarySchema fields default to an empty
string and silently pass validation even when the model fails to produce
them.

Also gives the call its own prompt-cache key, "contact-summary", instead
of sharing the generic "structured-medium" bucket with unrelated calls.
…arsing

makeStructuredModelCall has two paths for getting JSON out of a model: a
strict path that relies on native structured-output/tool-calling
enforcement, and a tolerant path (used for Ollama and self-hosted
OpenAI-compatible proxies) that explicitly writes out the JSON schema in
the prompt, parses leniently, and runs a repair pass if parsing or
validation fails.

needsTolerantParsing only checked for Ollama and the OpenAI-proxy case,
so any other provider, including OpenRouter, took the strict path by
default. That's an unsafe assumption for OpenRouter specifically: it
routes to a huge range of third-party models with wildly inconsistent
tool-calling/JSON-schema support. A smaller or reasoning-tuned OpenRouter
model can fail structured output entirely, or return partial JSON that
still passes validation because most schema fields default to "", masking
the failure as a silent partial response instead of a visible error.

Now resolves the actual provider of the model being called (not just the
global default chat provider) and routes OpenRouter through the same
tolerant-parsing + repair path Ollama already gets, so a less reliable
OpenRouter model degrades gracefully instead of returning incomplete
structured output.

Builds on top of the contact-summary useCase fix (separate PR) so a
contact-summary call that resolves to an OpenRouter model is now
protected on both fronts: the right model slot is used, and if that
slot is configured with an OpenRouter model that's unreliable at strict
JSON, the call still produces a complete, schema-valid object instead of
a partially blank one.
@harshithmullapudi harshithmullapudi merged commit a02ca48 into main Jun 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants