Repository navigation
fix(server): let a package's declared sampling stand when the caller is silent - #1236
justinchuby wants to merge 1 commit into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## fix/reasoning-turn-budget-and-privacy #1236 +/- ##
========================================================================
Coverage ? 79.90%
========================================================================
Files ? 359
Lines ? 157905
Branches ? 157905
========================================================================
Hits ? 126174
Misses ? 27119
Partials ? 4612
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
🔴 Benchmark Regression DetectedComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
…is silent `temperature` and `top_p` carried the OpenAI schema's documented 1.0 defaults, so serde could not tell a caller who chose 1.0 from a caller who said nothing. Both were then forwarded to the engine as explicit overrides, and a package that declares its own sampling regime — this model declares top_p 0.95 — had it silently replaced by the full distribution. An agent client sends neither field. OpenCode's requests carry no sampling controls at all, so every turn it drove was sampled from the whole tail rather than from the nucleus the package asked for, which is how a long agent turn degenerates. Both fields are now optional, and an absent one is passed to `resolve_sampling_defaults` as "no opinion" so the declared value wins, exactly as `top_k` already did. Validation likewise judges only what the caller actually sent. `temperature: 0` still means greedy, and an explicit value still beats the package. The same defect and the same fix apply to legacy `/v1/completions`, whose doc comment had recorded the mistaken premise outright: that the schema "has no unspecified state". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
ea24844 to
cba7254
Compare
…is silent (replaces #1236) (#1250) Replacement for #1236, rebased onto current `main` after #1197/#1224 landed as squashes (the stacked base diverged). Same fix, plus one added guard test, re-validated on top of the merged stack. ## The defect `temperature` and `top_p` carried the OpenAI schema's documented `1.0` defaults via `#[serde(default = ...)]`, so the server could not tell a caller who *chose* 1.0 from a caller who said nothing. Both were then handed to `resolve_sampling_defaults` as explicit overrides, so a model that declares its own sampling regime lost it on every request. An absent field is "no opinion", not the documented default. ## The fix Both fields become `Option<f32>`; an absent one is passed as `None`, so the package's declared value wins — the same treatment `top_k` already had. An explicit value (including `temperature: 0`, greedy) still wins. Validation (`validate_sampling_range`) now judges only what the caller actually sent. The same fix applies to legacy `/v1/completions`. ## Verification (mine, on this branch atop merged #1197 + #1224) - **211 lib + 31 http + 11 bundle tests pass**; `cargo clippy -p onnx-genai-server --all-targets -- -D warnings` clean. - **Falsified the core claim** (revert hunk → RED → restore → GREEN): reverting `chat_sampling_overrides` to forward `Some(1.0)` for an absent field made `a_silent_request_keeps_the_declared_temperature_and_top_p` see `Some(1.0)` instead of `None`, and `silent_chat_request_honors_model_do_sample` override the declared `0.6` with `1.0`; restored, the declared value survives. - **Absent-vs-default trap, added test:** `an_explicit_default_valued_temperature_still_overrides_the_package` asserts a caller that explicitly sends `temperature: 1.0` (equal to the old schema default) still overrides a package declaring `0.6`. This documents that absence (`None` → package wins) and an explicitly-sent default (`Some(1.0)` → caller wins) are now distinct — the exact distinction this PR exists to make. - **End to end** (`qwen05b-q4`, native): a silent request answers normally; legacy `/v1/completions` at `temperature: 0` is bit-identical across two runs, so the greedy path is untouched; an explicit `temperature: 1.0` is accepted and a negative one rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
|
Superseded by #1250, which is this commit rebased onto current main after #1197/#1224 landed as squashes (the stacked base diverged). #1250 adds one guard test (an explicit temperature of 1.0, equal to the old schema default, still overrides a package's declared 0.6 — the absent-vs-default trap), was validated (211 lib + 31 http + 11 bundle tests, clippy clean, silent-override claim falsified revert->RED->restore->GREEN, e2e on qwen05b-q4 incl. temperature:0 bit-identical), and merged as 20aa31a. Closing in favour of it. |
…aces #1248) (#1254) Replacement for #1248, rebased onto current `main` after #1197/#1224/#1236 landed as squashes (the stacked base diverged). The original functional commit plus two added tests, re-validated on top of the merged stack. ## Symptom A model driven with flat tool names (`bash`, `edit`, `glob`, `grep`, `read`, `write`) emits a namespaced call such as `glob.glob`, which the client rejects: it can only dispatch a name it offered. ## Cause (as claimed by the PR, and what I could and could not confirm) The caller offers flat tool *definitions* over the API. The instruction text the model reads is rendered by the **package's own chat template**, which the PR says assumes namespaced names, takes `name.split('.')[0]`, and renders `# Valid recipients: "self", "glob.*", ..., "user".` — so the model follows a grammar that is wrong for a flat offer and writes `glob.glob`. **I could not inspect that template with my own eyes.** It ships inside the model package (Muse Glimmer / harmony-style), which is not in this repository or in my local model set; searching the repo and local packages for the `split('.')` / "Valid recipients" grammar found nothing. What I *can* confirm from the server side corroborates the addressing scheme: the ATEM parser recognises `to=functions.<name>` channels and `<atem:invoke name="...">` blocks (`extract_atem_tool_calls`, `ATEM_USER_CHANNEL = "to=user<|message|>"`), so a namespaced recipient like `functions.glob` producing a call named `glob.glob` is consistent with the diagnosis. **Template fix vs dispatch fix — my conclusion:** resolving at dispatch is the right layer, and this is a considered choice, not merely the first available. The template belongs to the model author and is rendered in the jinja sandbox; the server cannot rewrite an arbitrary third-party package's template at runtime, and the namespaced grammar is not universally wrong (it is correct for a caller that offers namespaced tools — only a flat offer breaks it). The server is the **only** component that sees both the caller's offer and the model's emitted call, so it is the only place that can reconcile them without editing model files. A template correction for flat offers would still be worthwhile in the package, but it cannot be the server's runtime responsibility. ## Fix When a parsed call names a tool that is not on offer but whose **final `.`-segment exactly equals an offered tool's whole name**, the call is resolved to that offered tool; anything else is left exactly as the model spelled it. Applied at the three call sites that already have the request in scope (buffered, streaming, and the logprob path), via `ParsedAssistantOutput::aligned_to` / `align_tool_calls`. **Ambiguity is impossible by construction, not merely avoided:** the resolution target is always the single final segment string, and it resolves only if that exact string is an offered (unique) name — there is never a choice between two candidates. When two offered tools merely share a final segment (`fs.read`, `net.read`) and the model writes a third namespace (`svc.read`), the bare segment `read` is offered by neither, so the call is left alone for the caller to reject. ## Verification (mine, on this branch atop the merged stack) - **216 lib + 31 http + 11 bundle tests pass**; `cargo clippy -p onnx-genai-server --all-targets -- -D warnings` clean. - **Falsified the positive claim** (revert → RED → restore → GREEN): neutering `align_tool_calls` to a no-op made `a_namespaced_call_resolves_to_the_offered_tool` and the new full-parse-path test return `glob.glob` instead of `glob`. - **Confirmed the negative tests have teeth, and characterised their limits honestly:** a no-op revert leaves the negatives green (they guard against *over*-resolution, not absence). Dropping the exact-offer guard makes `an_offered_name_is_left_alone` go RED (`fs.read` → `read`), which is exactly what that test protects. - **Added tests:** `a_shared_final_segment_is_not_resolved_arbitrarily` (the ambiguity case above) and `a_namespaced_atem_call_dispatches_through_the_full_parse_path`, which drives a raw `<atem:invoke name="glob.glob">` turn through the real `parse_assistant_output` → `align_tool_calls` path and asserts the dispatched name is `glob`. - **End-to-end note:** I could not reproduce the OpenCode/Muse path — no local model emits ATEM namespaced calls — so the end-to-end evidence is the full-parse-path unit test above (the server's actual code path), clearly labelled as such rather than an HTTP round-trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
Stacked on #1224.
The defect
temperatureandtop_pcarried the OpenAI schema's documented1.0defaults via#[serde(default = ...)], so the server could not tell a caller who chose 1.0 from a caller who said nothing. Both were then handed toresolve_sampling_defaultsas explicit overrides:An explicit override beats the package's declaration by design, so a model that declares its own sampling regime lost it on every request. This model declares
top_p: 0.95; every OpenAI client silently widened it to1.0, the full distribution.The old doc comment stated the mistaken premise outright — that the schema "always supplies them ... and has no 'unspecified' state". It does have one: the field is simply absent.
An agent client is exactly the caller that hits this. OpenCode's captured requests carry no sampling controls at all, so every turn it drove sampled from the whole tail rather than the nucleus the package asked for.
The fix
Both fields become
Option<f32>, and an absent one is passed through asNone— "no opinion" — so the declared value wins. This is precisely the treatmenttop_kalready had, and the comment explaining why was already sitting three lines below:Validation now judges only what the caller actually sent, via one shared
validate_sampling_range.The same defect and the same fix apply to legacy
/v1/completions.What still behaves exactly as before
temperature: 0means greedy and beats a package declaringdo_sample: true.One existing test asserted the old behaviour directly (
assert_eq!(options.temperature, 1.0)with the comment "the schema always supplies a value"); it now asserts the declared0.6survives.Verification
--all-targetsclean.temperature: 0returns bit-identical output across runs, so the greedy path is untouched.