Skip to content

fix: sanitize internal fields from API messages - #138

Closed
cutepawss wants to merge 1 commit into
NousResearch:mainfrom
cutepawss:fix/sanitize-api-messages
Closed

fix: sanitize internal fields from API messages#138
cutepawss wants to merge 1 commit into
NousResearch:mainfrom
cutepawss:fix/sanitize-api-messages

Conversation

@cutepawss

Copy link
Copy Markdown
Contributor

Fixes #134

Problem

When using Mistral (or any strict OpenAI-compatible provider) as a direct API endpoint, the second message always fails with:

Error code: 422 - {'detail': [{'type': 'extra_forbidden',
  'loc': ['body', 'messages', 2, 'assistant', 'finish_reason'],
  'msg': 'Extra inputs are not permitted', 'input': 'stop'}]}

The first message works fine because there's no assistant message in history yet. On the second turn, the previous assistant response — carrying internal fields like finish_reason — gets sent back to the API, which rejects it.

Root Cause

_build_assistant_message() adds finish_reason and reasoning to every assistant message dict for trajectory saving and session logging. The API preparation code stripped reasoning but forgot finish_reason. Additionally, _handle_max_iterations() sent messages with no sanitization at all.

This is a broader design issue: the code used a fragile blacklist (manually popping individual fields), which breaks whenever a new internal field is added.

Fix

Added a whitelist-based _sanitize_for_api() helper that keeps only standard OpenAI-compatible fields per message role:

_API_FIELDS_BY_ROLE = {
    "system":    {"role", "content"},
    "user":      {"role", "content", "name"},
    "assistant": {"role", "content", "tool_calls", "name", "refusal",
                  "reasoning_content", "reasoning_details"},
    "tool":      {"role", "content", "tool_call_id", "name"},
}

Applied consistently across all 3 API call sites:

  1. Main conversation loop — replaced inline blacklist
  2. Memory flush — replaced inline blacklist
  3. Max iterations summary — was completely unsanitized (also fixed)

reasoning_content and reasoning_details are kept in the whitelist for providers that support multi-turn reasoning (OpenRouter, Moonshot AI). The original messages list is unchanged, so trajectory saving, session DB logging, and display continue to work.

Testing

Added 8 tests in tests/agent/test_sanitize_api.py:

Test What it verifies
test_finish_reason_stripped The actual bug — finish_reason removed
test_reasoning_becomes_reasoning_content reasoningreasoning_content conversion
test_reasoning_content_not_added_when_empty No spurious field when reasoning is None
test_flush_sentinel_stripped Internal _flush_sentinel on user msgs removed
test_standard_assistant_fields_preserved tool_calls, reasoning_details pass through
test_tool_message_preserves_tool_call_id Tool msgs keep tool_call_id, drop extras
test_system_message_minimal System msgs only keep role + content
test_unknown_role_defaults_to_role_content Unknown roles get safe defaults
$ python3 -m pytest tests/agent/test_sanitize_api.py -v
8 passed in 3.13s

$ python3 -m pytest tests/ -v
626 passed, 1 failed (pre-existing, unrelated), 9 deselected

Mistral and other strict API providers reject extra fields (like
finish_reason, reasoning, _flush_sentinel) on chat messages with
422 Unprocessable Entity.

Root cause: _build_assistant_message() adds internal bookkeeping
fields (finish_reason, reasoning) to message dicts for trajectory
saving and logging. When these messages are sent back to the API
as conversation history, strict providers reject the unknown fields.

Fix: Add a whitelist-based _sanitize_for_api() helper that keeps
only standard OpenAI-compatible fields per message role. This
replaces the previous fragile blacklist approach (which only
removed 'reasoning' but forgot 'finish_reason') and is applied
consistently across all 3 API call sites:

1. Main conversation loop
2. Memory flush
3. Max iterations summary (was completely unsanitized)

The whitelist includes reasoning_content and reasoning_details for
providers that support multi-turn reasoning (OpenRouter, Moonshot AI).
Original messages list is unchanged — trajectory saving, session DB,
and logging continue to work as before.

@Bartok9 Bartok9 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.

LGTM! The whitelist-based sanitization is the right approach.

Key benefits:

  • Forward-compatible (new internal fields won't accidentally leak)
  • Handles all message roles properly
  • Fixed the edge case in _handle_max_iterations()

The _API_FIELDS_BY_ROLE dict is clean and easy to extend if needed.

Tested locally with Mistral - the 422 errors are resolved. 🎉

@Bartok9 Bartok9 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.

LGTM! The whitelist-based sanitization is the right approach.

Key benefits:

  • Forward-compatible (new internal fields won't accidentally leak)
  • Handles all message roles properly
  • Fixed the edge case in _handle_max_iterations()

Tested logic - the 422 errors should be resolved.

@teknium1

Copy link
Copy Markdown
Contributor

@cutepawss are we aware of they accept extra_headers kwarg? Thats needed to control a lot of models' reasoning effort, whether they think at all, etc. I'm worried this isn't strict OAI spec but strictly mistral spec?

@cutepawss

Copy link
Copy Markdown
Contributor Author

@teknium1
Good question! This fix only sanitizes message-level fields (the dicts inside the messages array), not API call-level kwargs.

extra_headers, extra_body, reasoning config, etc. are passed as kwargs to chat.completions.create() — they're completely untouched by _sanitize_for_api(). For example, this continues to work exactly as before:

client.chat.completions.create(
    model="...",
    messages=api_messages,  # ← only this is sanitized
    extra_body={"reasoning": {"effort": "high"}},  # ← untouched
    extra_headers={"X-Custom": "value"},            # ← untouched
)

What we're stripping are internal bookkeeping fields like finish_reason that _build_assistant_message() adds to message dicts for trajectory saving and session logging. These are not valid fields on chat messages in any provider's spec — finish_reason belongs on the choice object in the API response, not on messages sent back to the API. Mistral just happens to be the first provider that strictly validates this, while others silently ignore the extra field.

The whitelist is based on the OpenAI Chat Completion message spec, plus known provider extensions (reasoning_content for Moonshot AI/Novita, reasoning_details for OpenRouter multi-turn reasoning). It's not modeled after Mistral's validation rules specifically.

@teknium1

teknium1 commented Mar 1, 2026

Copy link
Copy Markdown
Contributor

Okay but messages with OpenRouter require a reasoning_content or reasoning field to carry over reasoning content for instance. Scrubbing that would break interleaved reasoning in many models

@cutepawss

Copy link
Copy Markdown
Contributor Author

@teknium1 Thanks for flagging this I want to make sure I'm not breaking anything here.

From what I can see, _sanitize_for_api() handles this by copying reasoningreasoning_content before stripping:

if role == "assistant" and msg.get("reasoning"):
    api_msg["reasoning_content"] = msg["reasoning"]

So reasoning_content and reasoning_details both pass through the whitelist. This should match the current behavior in main (lines 1957-1965), just consolidated into one function.

This is also covered by the tests in the PR — test_reasoning_becomes_reasoning_content and test_standard_assistant_fields_preserved verify that reasoning is preserved as reasoning_content and reasoning_details passes through untouched.

That said; is there a provider or flow where reasoning needs to go back to the API as-is, without being converted to reasoning_content?
I may be missing a case there.

@cutepawss cutepawss closed this Mar 2, 2026
teddyjfpender added a commit to teddyjfpender/superforecasting-agent that referenced this pull request Jun 24, 2026
…advance readiness offline

`forecast readiness` was read-only: closing benchmark-evidence gaps meant running five
`forecast backtest` commands by hand, then re-checking. Now one command does it
offline.

- `forecast readiness --run-safe-benchmarks` runs the OFFLINE builtin suite (the 5
  shipped datasets — mini/synthetic/heldout + manifold + kalshi public) via a
  deterministic generated source (default --probability-source forecast-engine; naive
  / baseline-ensemble also allowed), persists a backtest run each, then re-evaluates
  readiness with unchanged thresholds and reports the gaps that CLOSED vs remain.
  --dry-run lists what would run; agent-protocol is rejected (needs an LLM runner).
- The default forecast-engine source is deliberate: a 'dataset' source would NOT close
  positive_best_baseline_edge_runs. Both external families (manifold + kalshi) are in
  the suite so external_source_families can close. Verified on a fresh ledger: the
  suite closes datasets / external families / leakage / positive-edge, leaving only
  the genuinely-live gaps (live_scored_forecasts, agent_protocol_scored_cases).
- Mirrored on the tool: evidence_readiness accepts run_safe_benchmarks (deferred cli
  import; OFFLINE sources only) so the agent can self-improve readiness through the
  tool rather than a shell script.

Tests: offline suite runs (5 runs, leakage-free, both external families), closes only
the offline gaps + leaves the live ones, agent-protocol rejected. Readiness/backtest
(126) + forecast tool (51) regression green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
teddyjfpender added a commit to teddyjfpender/superforecasting-agent that referenced this pull request Jun 24, 2026
…high + 10 med + lows)

A multi-agent review of NousResearch#150/NousResearch#138/NousResearch#137/NousResearch#139 confirmed 21 findings. Fixed:

HIGH (NousResearch#139): forecast.theses ran inline on the gateway dispatch thread while doing
per-thesis N+1 work — it can stall interrupt/approval RPCs on a large book. Added it to
_LONG_HANDLERS (thread pool) + a routing regression test. Also: one shared ForecastLedger
per call (was two), error code 5008 (was a colliding 5021), _num excludes bool, and the
CLI dashboard now renders factors too (parity with the RPC/tool; matches its docstring).

NousResearch#150 (vote-share intervals): intervals silently dropped on the fraction-scale path
(the PMF branch never attached them) — now attached on both branches. Interval lookup
now tolerates case/whitespace divergence from the scorer/hook key normalization. And the
intervals (always percentage-points) are rescaled to the payload's scale, so a
fraction-scale share renders `0.70 [0.50-0.85]` not `0.70 [50-85]`.

NousResearch#138 (readiness benchmarks): evaluating with the default --last window or a --dataset
filter could hide the freshly-run suite (a closed gap looked reopened / zero closed) —
the improve path now evaluates over ALL runs, unfiltered. Dropped `naive` (can't beat
baselines, so it can't close the edge gap). The tool mirror wraps the run in try/except.

NousResearch#137 (cycle --agent): selection now filters on alert.scope_type == "question" (a
domain/topic/portfolio alert's scope_ref is not a question; this also keeps a
question-scoped domain_error_profile_applies trigger). --max-questions now counts
processed (expensive) LLM runs, not just commits, so it actually caps. --max-iterations
0 is honored (was coerced to 12).

Deferred (noted): a per-candidate central-within-interval COMMIT gate (a new backend
invariant mirroring central_within) — warrants its own pass. New tests cover every fix
above. Full tests/forecasting + tests/tools + gateway regression green (6834 passed);
TUI type-check + 99 chart/workspace tests green; bundle rebuilt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Meraniya pushed a commit to Meraniya/hermes-agent that referenced this pull request Aug 6, 2026
…NousResearch#137) (NousResearch#138)

* feat(deploy): persistent gateway hosting + env-passthrough log hardening

Re-lands the reviewed-good content of NousResearch#137 on a branch cut from current main.
NousResearch#137 could not merge: it was mergeable_state dirty, and it sat on
claude/slack-session-94aae0 — the branch of closed NousResearch#106, which NousResearch#130 said
should not be continued.

Two things ship here.

tools/env_passthrough.py — CodeQL clear-text-logging fixes. The refusal path
logged the caller-supplied variable name; skill frontmatter is a taint source
under CodeQL's model, and _is_hermes_provider_credential's own name matches
the sensitive-data heuristic, so anything derived from it is treated as
secret. Replaced with counts and static strings. The config-read failure now
logs the exception type rather than str(e), because a YAML parse error quotes
the offending line, which may hold a secret.

deploy/, docs/DEPLOYMENT.md, website/docs/guides/persistent-hosting.md,
Dockerfile, docker-compose.yml, README.md — running the gateway 24/7 with no
long-lived credentials on the host. Docker Compose, a hardened systemd unit,
and container platforms, all bootstrapping from the 1Password secret source
that already exists on main. The image gains the onepassword extra so a
headless deploy doesn't do a first-boot install into the venv. Only
placeholder tokens (ops_...your-token...) appear anywhere.

Dropped from NousResearch#137: .claude/settings.json, which reverted
@modelcontextprotocol/server-memory from 0.6.2 to 0.6.3. That version does not
exist on npm — the published line jumps 0.6.2 to 2025.4.25 — so the revert
re-breaks the memory MCP server and undoes NousResearch#134. It was also the sole merge
conflict with main, so dropping the defect and clearing the conflict are the
same edit.

hermes_cli/config.py is not touched, so the GHSA-mv8x-fg99-32mf
_sanitize_env_lines regression NousResearch#130 warned about is not in play.

Verified against main rather than assumed: the onepassword extra
(pyproject.toml), every secrets.onepassword key the sample config sets
(agent/secret_sources/onepassword.py), `hermes secrets onepassword setup
--vault/--item`, and `hermes gateway run` used by the unit's ExecStart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT

* docs(website): register the persistent-hosting guide in the sidebar

website/sidebars.ts enumerates the Guides category by hand — it is not an
autogenerated sidebar, so the sidebar_position: 18 in the new guide's
frontmatter is inert. Without this line the page builds and is reachable by
direct URL, but appears nowhere in site navigation, while README.md and
docs/DEPLOYMENT.md both link its published URL. docusaurus.config.ts sets
onBrokenLinks: 'warn', so nothing fails — it just quietly isn't there.

Placed after guides/team-telegram-assistant: both are about deploying the
messaging gateway, so that is where a reader looking for gateway hosting
would already be.

Not in NousResearch#137; found while reviewing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT

* test(env-passthrough): pin that registration never logs a variable name

The clear-text-logging fix in the previous commit had nothing guarding it.
Nothing in the suite asserted that a refused variable's name stays out of the
log, so a future edit could interpolate it back and every test would still
pass — which is roughly how it got there the first time.

Five tests: no name from either the blocked or the allowed set appears in any
record; the refused/registered counts are correct and the GHSA pointer
survives; no warning when nothing is refused; no record at all for empty
input; and the config-read handler logs the exception type rather than str(e),
using a recognisable secret in the raised message so a leak is unambiguous.

Confirmed these fail against main's version of the module — three of the five
do, and the captured log in the failure output shows the secret verbatim.
A regression test that passes against the code it is meant to catch is worth
nothing, so that check mattered more than the passing run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT

* docs(system-log): record the NousResearch#137 salvage

Per docs/system-log/README.md. New file for the UTC day; no prior entry for
2026-08-02 existed on main or locally, so nothing was overwritten.

Records what was carried, what was dropped and why, what was added beyond
NousResearch#137, what was verified, and — separately — what could not be verified in a
container with no Docker daemon and no website node_modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT

---------

Co-authored-by: Claude <noreply@anthropic.com>
Meraniya pushed a commit to Meraniya/hermes-agent that referenced this pull request Aug 6, 2026
…arch#139)

website/sidebars.ts lists the Guides category by hand. Four pages had never
been added to it: google-gemini, local-ollama-setup, minimax-oauth, and
pipe-script-output. Each one builds and resolves by direct URL but appears
nowhere in navigation. onBrokenLinks: 'warn' means this never failed a build,
which is how four of them accumulated without anyone noticing.

Placed by subject rather than appended to the end: local-ollama-setup beside
local-llm-on-mac, pipe-script-output beside cron-script-only, and the two
provider guides inside the existing aws-bedrock / azure-foundry /
xai-grok-oauth block.

website/docs/guides/ holds 29 .md files and the sidebar now lists 29 unique
guide ids, so the two sets match exactly — no orphans, no duplicates.

Follow-up to NousResearch#138, which fixed the same failure mode for one new guide.


Claude-Session: https://claude.ai/code/session_01Jy1tjok1XKgpTUK69b8dKT

Co-authored-by: Claude <noreply@anthropic.com>
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.

Error code: 422 when calling mistral api

3 participants