Skip to content
This repository was archived by the owner on Aug 11, 2026. It is now read-only.

fix(inference): forward request fields by default, pin only what protects the grant - #554

Merged
Peyton-Spencer merged 2 commits into
mainfrom
peyton/inference-schema-normalize
Jul 28, 2026
Merged

fix(inference): forward request fields by default, pin only what protects the grant#554
Peyton-Spencer merged 2 commits into
mainfrom
peyton/inference-schema-normalize

Conversation

@Peyton-Spencer

@Peyton-Spencer Peyton-Spencer commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

The principle

The default is FORWARD. Forwarding needs no justification; every pin and every drop below carries a concrete, stated reason. This is a retrieval-agent competition, not a prompt-shape competition — a harness's own sampling and observability choices are its business.

The rule for a drop: stripping a field is acceptable only when it changes neither what the model produces nor what the harness can observe. Silently discarding a knob a miner deliberately set is the original bug with extra steps — they ask for a JSON schema, receive prose, their parser fails, and nothing anywhere names the cause.

Why

The v7 gate refused any field outside a flat allowlist, at inference.py:90243 lines before begin_inference_request. No inference_requests row was written, so the rejections were invisible in telemetry, and the error named no key.

Cooking (rank 15 on v1) added one line sending reasoning: {"effort": "medium"} — the value benchmark_reasoning already stamps on every v7 request. ~81 of ~480 chat calls died per run. v3 is byte-identical on that line and still failing. _locked_upstream_payload already overwrote reasoning unconditionally; the only thing standing between Cooking and a working run was one allowlist entry.

Empirical provider verification

Established against the pinned v7 model (openai/gpt-oss-20b) through the exact aggregate_throughput preferences this lane pins, not from documentation. Three probe passes; the first two were confounded (throughput routing picked a different provider per call; max_tokens=40 was consumed by reasoning tokens, since exclude: true withholds reasoning from the response but still generates and bills it) and were re-run provider-pinned with room to generate.

Two findings drove the design:

  1. OpenRouter excludes an endpoint that lacks a requested parameter rather than routing to it and failing. Pinning order:["amazon-bedrock"] (the only live provider advertising no response_format) returns 404 No endpoints found with the field and succeeds without it — same order, same fallbacks, one variable. With fallbacks on it served from CoreWeave and conformed. Under the v7 aggregate route (allow_fallbacks: true) the request simply lands on one of the eight supporting providers, so forwarding response_format cannot manufacture a 400. No require_parameters needed.
  2. reasoning_effort hard-fails. OpenRouter answers 400 "reasoning_effort" and "reasoning.effort" are both provided with conflicting values whenever it disagrees with the pinned block. Forwarding it would break every harness setting it to anything but medium. It moves from drop to pin for that reason.
Field / variant Provider verdict (pinned v7 model) Fate
response_format json_schema + pinned reasoning CONFORMS on Google, Fireworks, Novita, DeepInfra, CoreWeave forward
response_format json_schema without reasoning CONFORMS on all five — no reasoning interaction exists forward
response_format json_schema, aggregate route ×4 CONFORMS 4/4 (served Fireworks) forward
response_format json_schema on Bedrock (unsupporting) endpoint excluded (404 pinned / routed away w/ fallbacks) — never a 400 forward
response_format json_object IS-JSON on Fireworks, Novita, DeepInfra; ignored on Google — advisory forward (prefer json_schema)
structured_outputs accepted forward
logprobs RETURNED on Novita, CoreWeave; ignored on DeepInfra, Groq forward
top_logprobs returned where logprobs is — but see size limit refuse (capability limit)
logit_bias, frequency_penalty, presence_penalty, seed, stop, top_k, repetition_penalty, min_p accepted forward
prediction, verbosity, parallel_tool_calls accepted forward
service_tier: priority accepted — buys a faster/costlier tier pin
user, metadata, safety_identifier, store, prompt_cache_key accepted drop / pin
reasoning_effort (agrees with pinned) accepted pin
reasoning_effort (conflicts) REJECT 400 — conflicting values pin (removed)

Probes were throwaway (single-digit max_tokens, one call per case), read the key from gcloud in-process, and are not landed in either repo. If you want this as a permanent contract test, say so and I'll add it — it would need a live key in CI, which is why I didn't land it unilaterally.

The partition

Four fates, disjoint and covering, asserted by test_every_field_has_exactly_one_decided_fate. Under a forward-by-default posture the risk inverts — no longer "a normal field is refused" but "a lever that should have been pinned is forwarded by omission" — so the test asserts the anti-cheat set by name.

Pin — the anti-cheat boundary (does not loosen)

Field Reason
model grant pins it (_locked_grant_model); disagreement logged as an evasion signal
max_tokens, max_completion_tokens clamped down; disagreeing aliases resolve to min(), so neither can raise the other
n forced to 1 — extra completions are extra billed generations
best_of removed — buys N server-side generations and bills all
reasoning replaced with the pinned contract
reasoning_effort removed — hard-400s against the pinned value (verified); removal is both the availability and the anti-cheat action
include_reasoning removed — legacy sibling that could override the pinned exclude: true
service_tier removed — priority buys faster compute the ticket did not grant
usage removed — the platform derives trusted cost from this block; a caller must not reshape its own metering input
prompt_cache_key removed — controls provider-side cache bucketing; two agents could collide or one could target another's bucket (cross-miner interference)

Drop — zero effect on the completion and on observation

user, metadata, safety_identifier — caller-controlled strings that travel to a third party under the pinned data_collection: "deny" / zdr: true posture and identify the agent behind the request. None affects the completion, so dropping cannot change a result — which is exactly what makes stripping inert here and not elsewhere. store contradicts that same pinned retention posture. stream_options is inert without streaming.

This is the one placement I'd call product-shaped rather than purely technical. The privacy/deanonymisation argument is real but not overwhelming, and the counter-argument — a miner's own identity is theirs to disclose — is coherent. Flagging for the operator rather than treating it as settled.

Forward — everything else

messages, tools, tool_choice, parallel_tool_calls, temperature, top_p, top_k, min_p, top_a, seed, stop, frequency_penalty, presence_penalty, repetition_penalty, logit_bias, logprobs, response_format, structured_outputs, prediction, verbosity.

logit_bias moved to forward: the earlier tokenizer-mismatch worry doesn't survive contact, since the grant pins gpt-oss-20b and the exchange response tells the harness which model it is talking to. It biases selection and buys nothing.

logprobs is the pattern case: it does not alter generation at all, so there was never a determinism or comparability argument against it — only the allowlist's silence. It is now plumbed through _public_provider_response, because forwarding a request field whose response is stripped is a silent no-op: the caller sets the flag, pays for the larger provider response, and receives nothing. Providers that don't support it return null, which is the honest answer.

Refuse — named, with the reason

Route identity and egress (models, provider, route, preset, transforms, plugins, web_search_options); deprecated tool spellings (functions, function_call — no provider advertises them, so forwarding would leave a harness silently toolless; the message names tools/tool_choice); non-text modalities (audio, modalities — this lane's response contract is text-only and a non-text modality would surface as a 502 blamed on the provider); unknown keys (fail-closed on purpose, since OpenRouter's surface is additive).

Plus two capability limits, which the message says are limits rather than policy:

  • stream — this lane genuinely cannot stream. It is buffered end to end: the proxy reads upstream.content whole, _public_provider_response rebuilds a single chat.completion, accounting settles once against the final usage block, and the broker reads and rewrites the whole body. Streaming would need changes in the platform proxy, the broker, and the accounting. Not normalised to false — a caller that asked for SSE and silently got a single JSON body would fail parsing it. Worth reporting as a possible future ask.
  • top_logprobs — measured, not assumed: ~80 bytes per token per alternative. At the 8192-token ceiling, logprobs alone extrapolates to 0.81 MiB (fits the 2 MiB response_body_bytes cap), top_logprobs=3 to 2.65 MiB, top_logprobs=20 to 13.3 MiB. A breach raises 502 provider response is too large, which is route_observable — so it cools the shared provider route and is attributed to the provider, meaning a caller's own choice would be charged to infrastructure and mint a retry grant. No non-zero value is safe at the ceiling, so clamping cannot rescue it. Caps untouched as instructed; raising response_body_bytes (embeddings already run at 16 MiB) would unlock it — operator's call.

Anti-cheat

No accepted field may let a miner obtain compute, a model, or a reasoning effort its ticket did not grant.

Preserved by construction. Every pinned field is overwritten or removed in _locked_upstream_payload; the partition test asserts each lever is in _PINNED_REQUEST_FIELDS and not in _FORWARDED_REQUEST_FIELDS, by name. Structured outputs specifically: response_format selects a decoding constraint, not a model, a provider, or an effort level. It cannot raise max_tokens (clamped), cannot add generations (n pinned to 1), and cannot alter reasoning — verified empirically, the pinned {"effort":"medium","exclude":true} block travels alongside it unchanged and the schema still conforms. Constrained decoding does change latency, which the efficiency bonus observes; that is a miner's own trade-off to make, not a grant escape.

exclude: True verified against OpenRouter docs: the model still reasons and is still billed; only the content is withheld. Valid effort includes max/xhigh above medium (pinned down) and "none", which disables reasoning entirely (pinned up) — so pinning also stops an agent opting out of v7's mandatory-reasoning contract, a direction the old rejection never covered.

Legibility

Refusals name every offending key, sorted for stability, each with its reason. Paired with ditto-assistant/dittobench-api#125, which stops the broker overwriting this string with "inference request denied", so it reaches the harness's stderr.

Does this fix Cooking v3 with no miner action?

Yes. Verified: v2/v3 tool_agent.rs are byte-identical; the entire vendor/ditto-harness/ tree is byte-identical between v1 (rank 15, working) and v3; the only request-shape delta is reasoning and tool_choice, and tool_choice was already allowlisted with both emitted shapes already passing. Every v3 branch — plus the vendored models/mod.rs:250 path gated on DITTOBENCH_REASONING_EFFORT — was run through the new gate: all accepted, all pinned to medium. No resubmission needed.

Tests

  • integration, real Postgres + real v7 route mintreasoning: {"effort":"high"} is accepted (200), reaches the provider pinned to medium, and is charged as that call (settled row + grant counters). The old gate never wrote a row, which is why this was invisible.
  • integrationresponse_format json_schema reaches the provider byte-identical alongside logprobs/logit_bias/frequency_penalty/seed, the pinned reasoning block rides along unchanged, and the conforming answer returns unmangled with logprobs intact.
  • integration — a refusal names the offending keys and never reaches the provider.
  • unit — the four-way partition asserted by name; grant-protecting fields pinned; identity fields dropped; structured outputs/logprobs forwarded intact; logprobs returned (and null when absent); both capability limits explain themselves; over-ask clamping and alias min().

make lint, make lint-copy, make typecheck clean; full suite green (2489 passed).

CI on this PR is blocked by a pre-existing breakage on main, not by this change. main has two divergent Alembic heads, so every migration run fails and the whole DB test tier errors out — CI is red on main itself at deead2d. This PR touches zero files under alembic/. Fix split out as #555; this goes green once that lands. The 2489-test run above was with #555's merge revision applied locally, which is the only way to run the DB tier at all right now.

Open for the operator

  1. user / metadata / safety_identifier — dropped on a privacy argument that is genuinely product-shaped. Easy to move to forward.
  2. top_logprobs — blocked only by the 2 MiB response_body_bytes cap, which I left untouched as instructed.
  3. Streaming — a real capability gap, not a policy choice.
  4. Landing the probe as a contract test — would need a live OpenRouter key in CI.

Related

  • fix(relay): forward the platform's rejection detail to the harness dittobench-api#125 — broker half. Independent; either alone helps, both give the field name end to end.
  • Does not touch validator dispatch, slot claiming, lease code, the heartbeat schema, validator_lease_audit, the fleet-health dashboard, or the scores read path. No overlap with in-flight widen-failure-detail (validator models/tests only). Size caps unchanged.

🤖 Generated with Claude Code

@Peyton-Spencer Peyton-Spencer changed the title peyton/inference schema normalize fix(inference): normalize harmless request fields instead of 400ing the run Jul 28, 2026
@Peyton-Spencer Peyton-Spencer changed the title fix(inference): normalize harmless request fields instead of 400ing the run fix(inference): forward request fields by default, pin only what protects the grant Jul 28, 2026
@Peyton-Spencer
Peyton-Spencer force-pushed the peyton/inference-schema-normalize branch 2 times, most recently from 2e42255 to 5b14869 Compare July 28, 2026 17:12
@Peyton-Spencer
Peyton-Spencer changed the base branch from main to peyton/merge-divergent-alembic-heads July 28, 2026 17:12
@Peyton-Spencer

Copy link
Copy Markdown
Contributor Author

Stacked on #555 (the divergent-Alembic-heads fix on main) so CI can run at all — this PR touches no migrations. Merge order: #555 first, then this. GitHub retargets this to main automatically once #555 lands.

Base automatically changed from peyton/merge-divergent-alembic-heads to main July 28, 2026 17:25
Peyton-Spencer and others added 2 commits July 28, 2026 13:34
…he run

The v7 chat gate refused any top-level field outside a flat allowlist, and
did so 43 lines before begin_inference_request -- so no inference_requests
row was written and the rejections were invisible in telemetry. The error
body did not name the offending key either.

`Cooking` (rank 15 on v1) hit this by adding one line that sent
`reasoning: {"effort": "medium"}`. The platform already stamps
`{"effort": "medium", "exclude": True}` on every v7 request, so the miner
was refused for redundantly requesting exactly what it was being given.
~81 of ~480 chat calls died per run; v3 shipped byte-identical and still
failed.

Split the allowlist into three sets by what a field can actually do:

- pinned    (model, max_tokens/max_completion_tokens, n, reasoning):
            accepted, then overwritten with the ticket's own value.
- dropped   (best_of, reasoning_effort, service_tier, user, metadata,
            store, stream_options, response_format, logprobs,
            top_logprobs, logit_bias): accepted, then stripped before the
            provider call, each for a stated reason.
- forwarded (messages, temperature, top_p, seed, stop, tools, tool_choice,
            parallel_tool_calls, frequency_penalty, presence_penalty).

Unknown keys stay fail-closed -- OpenRouter's surface is additive and a
field invented later is exactly the one that might buy compute -- but the
refusal now names them. max_tokens over-asks and disagreeing aliases now
clamp downward rather than kill the run.

Anti-cheat is unchanged and now testable as a partition: nothing is
accepted without being pinned, dropped, or deliberately forwarded, so no
request can obtain compute, a model, or a reasoning effort its ticket did
not grant. `effort: "high"` is served as medium; so is `effort: "none"`,
which would otherwise let an agent opt out of v7's mandatory reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flips the posture: forwarding needs no justification, every pin and drop
carries a concrete reason. This is a retrieval-agent competition, not a
prompt-shape competition -- a harness's sampling and observability choices
are its own.

The rule for a drop: stripping is acceptable only when it changes neither
what the model produces nor what the harness can observe. Silently
discarding a knob a miner set is the Cooking bug with extra steps -- they
ask for a JSON schema, get prose, their parser fails, nothing names the
cause.

Provider support was established empirically against the pinned v7 model
rather than from docs. Two findings drove the placements:

- OpenRouter EXCLUDES an endpoint lacking a requested parameter rather
  than routing to it and failing. Pinning order:[amazon-bedrock] (no
  response_format) returns 404 with the field and succeeds without it,
  and under the v7 aggregate route it lands on one of eight supporting
  providers. So forwarding response_format cannot manufacture a 400.
- reasoning_effort HARD-FAILS: OpenRouter 400s when it disagrees with the
  pinned reasoning.effort. It moves from drop to pin for that reason.

Moved to forwarded: response_format (+structured_outputs), logprobs,
logit_bias, top_k, min_p, top_a, repetition_penalty, prediction,
verbosity. json_schema was verified to conform on all five supporting
providers WITH the pinned reasoning block, so the feared reasoning/
structured-output interaction does not exist for this model.

logprobs is now plumbed through _public_provider_response; forwarding a
request field whose response is stripped would be a silent no-op.

top_logprobs is refused as a measured capability limit: ~80 bytes per
token per alternative puts top_logprobs=3 at ~2.65 MiB and =20 at
~13.3 MiB against the 2 MiB response cap, and a breach raises a 502 that
is attributed to the PROVIDER and cools the shared route -- so a caller's
own choice would be charged to infrastructure. No non-zero value is safe
at the 8192-token ceiling. logprobs alone is ~0.81 MiB and fits.

Pin now also covers include_reasoning, service_tier, usage and
prompt_cache_key. Drop is down to user/metadata/safety_identifier/store/
stream_options -- all zero-effect on the completion. Refusals are named
with reasons, including the two capability limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Peyton-Spencer
Peyton-Spencer force-pushed the peyton/inference-schema-normalize branch from 5b14869 to efe28a8 Compare July 28, 2026 17:34
@Peyton-Spencer
Peyton-Spencer marked this pull request as ready for review July 28, 2026 17:39
@Peyton-Spencer
Peyton-Spencer merged commit 7f190bc into main Jul 28, 2026
7 checks passed
@Peyton-Spencer
Peyton-Spencer deleted the peyton/inference-schema-normalize branch July 28, 2026 17:40
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant