fix(ai): send temperature only to models that accept it - #240
Conversation
CPK-8074 Phase 0.2 — Stop a model bump turning every reply into the apology fallback (#149)
GitHub: #149. Fix is written and green — see below. All five Anthropic calls in The failure is silent and total. Fix: new Allowlist, not deny-list, deliberately. A deny-list means a model released after the file was last touched is unlisted, so we send the parameter and reproduce the exact outage. An allowlist means an unlisted model just doesn't get it: the request succeeds at the model's default sampling. Omitting is always accepted by the API; sending is not. 14 tests, including the two that pin the direction: unknown model → omitted, and
Two more fixes rode along, from the review passRemoving the 400 is not on its own enough to make the swap safe:
Both mutation-checked: reverting the classifier to Allowlist also gained Still open after this
Not taken here: the "fail loudly on a 400" half of #149. A |
jerelvelarde
left a comment
There was a problem hiding this comment.
Reviewed deeply, then put every blocking finding through an adversarial round. The core mechanism is sound and I verified the parts that matter:
samplingParamsreturns{}— a genuinely absent key, notundefined— for unlisted models, and all five call sites are converted.- The allowlist has no dangerous-direction hole: none of the rejecting IDs you name (opus-4-7/4-8, opus-5, sonnet-5, fable-5, mythos-5) is a prefix-match of any of the eight allowlisted stems.
- The warn
Setis bounded, because everymodeloriginates from env config rather than user input. - The three thinking-block tests genuinely exercise real code — aimock's
buildClaudeTextResponsepushes thethinkingblock first over real HTTP, so a revert tocontent[0]really does kill each one.
The allowlist-not-denylist argument is right and the reasoning is the best part of the PR. Two things before I approve.
The PR's central behavioural claim has no test at any call site
Revert any one call site from ...samplingParams(this.model, config.classifierTemperature) back to temperature: config.classifierTemperature and the whole suite stays green. Every existing test constructs its client with an allowlisted model (claude-haiku-4-5-20251001, claude-sonnet-4-6), so the parameter is sent either way and no assertion can tell the difference.
samplingParams itself is unit-tested — that part is fine. What is untested is that the call sites use it. So "send temperature only to models that accept it", the title claim, is pinned nowhere. One test per call site with a rejecting model, asserting the outgoing request has no temperature key, closes it. This is the same class you killed my #224 over, and you were right to, so I am holding this to the same line.
The seam this PR opens is fail-open in sentiment.ts
generator.ts:137 throws on empty extracted text. The three call sites you converted copied the extraction without that guard, and the consequences differ:
sentiment.ts— an empty response flips a fail-closed DB gate to fail-open, in precisely the env-swap scenario this PR exists to enable. That is the one I would gate on.classifier.ts:67-89(via theparseClassificationcatch at:208) — with any thinking-default model andmaxClassifierTokens: 512, the response is thinking-only,extractResponseTextreturns'', and the catch returns a hardcoded MEDIUM/OTHER whileclassifyreportsdegraded: false. It throws away the heuristic verdict it had already computed — and it contradicts the PR body's own claim that these calls "degraded to the heuristic". They do not; they degrade to a constant, and say they did not.
This is the same silent-degradation class you found one layer down and fixed here. It is worth closing at the same time, because the whole point of the change is to make the model swap safe, and right now the swap trades a loud 400 for a quiet wrong answer.
You already note that maxClassifierTokens (512) and maxConfidenceTokens (256) are both below a thinking turn. That is the same finding from the budget side, and it is why the empty-text path is not hypothetical for anyone who actually makes the swap.
Everything else here is good — the per-model warn dedupe, claude-opus-4-0/claude-sonnet-4-0 included as deprecated-but-live with claude-2.x/opus-4-1 deliberately out and a comment saying why, and asserting {} as an absent key rather than undefined because an undefined serializes to null through some JSON paths. That last one is a 400 on exactly the models this protects, and it is the kind of thing that only shows up in production.
Send the call-site tests and the sentiment guard and I will re-verify straight away.
…althy Addresses the review on #240. Both blockers were right. ## The title claim had no test at any call site Reverting any one call site from `...samplingParams(...)` to `temperature: config.…` left the whole suite green, because every existing test constructs its client with an allowlisted model — so the parameter is sent either way and no assertion could tell the difference. samplingParams was unit-tested; that the call sites USE it was pinned nowhere. One test per call site now configures a rejecting model and asserts the outgoing request carries no temperature, read from aimock's request journal. Asserted on the value rather than key presence: the journal is a normalized view of the request and always carries a `temperature` key, holding undefined when we sent none. Absence on the wire stays pinned by model-capabilities.test.ts; these pin that the call site routes through the gate at all. ## An empty response read as a healthy result The three converted call sites copied `extractResponseText` without the generator's empty-text guard, and each turned a missing answer into a fabricated one reported as fine: - sentiment.ts is the one with teeth. account-scoring.ts skips its DB write only when `degraded` is set, so an empty response returned a fabricated NEUTRAL with degraded:false — flipping a fail-CLOSED gate to fail-OPEN and persisting a sentiment nobody measured. - classifier.ts was worse than the PR body claimed. I wrote that these calls "degrade to the heuristic". They did not: parseClassification's catch returned a hardcoded MEDIUM/OTHER while `classify` reported degraded:false, throwing away the heuristic verdict it had already computed. The claim in the body was wrong, not just incomplete. - confidence.ts took the same shape. All three now treat an empty extraction as a failure, which routes to each one's existing catch — the heuristic for the classifier and scorer, the degraded fallback for sentiment. Reachable as soon as a thinking-default model is configured, since maxClassifierTokens (512) and maxConfidenceTokens (256) both sit below a thinking turn: exactly the swap this PR exists to enable. So the change no longer trades a loud 400 for a quiet wrong answer. Verification: ai package 289 -> 300, full repo turbo run test 10/10, typecheck clean. Four mutations, each killing exactly one test: reverting the classifier call site, reverting the generator call site, dropping the sentiment guard, dropping the classifier guard. Refs CPK-8074
f5bfc97 to
536d69b
Compare
|
Thanks Jerel — both blockers were right, and the second one was worse than I'd described. Fixed in The call sites had no testYou were right that reverting any one of them left the suite green. Every existing test constructs its client with an allowlisted model, so the parameter went out either way and nothing could tell the difference. There's now one test per call site configuring a rejecting model and reading aimock's request journal. One detail worth recording for whoever reads them next: that journal is a normalised view and always carries a The empty-response seamThe And you caught an error in my PR body, not just in the code. I wrote that these calls "degrade to the heuristic". They don't — All three sites now treat an empty extraction as a failure, routing to each one's existing catch — the heuristic for the classifier and scorer, the degraded fallback for sentiment. So the change no longer trades a loud 400 for a quiet wrong answer.
Still open and noted rather than fixed: Ready when you are. |
All five Anthropic calls in this package set a `temperature` while their model is env-overridable. Anthropic removed the sampling parameters — `temperature`, `top_p`, `top_k` — on Fable 5, Mythos 5, Opus 5, Opus 4.8, Opus 4.7 and Sonnet 5: sending any of them returns 400. The current defaults (claude-sonnet-4-6, claude-haiku-4-5-20251001) all still accept one, so nothing is broken today. The failure would be silent and total. Set AI_RESPONSE_MODEL=claude-opus-5, an entirely reasonable upgrade, and every messages.create in the generator 400s, the generator catches it, and the apology fallback is what gets posted publicly to every Discord thread and GitHub issue. The classifier, sentiment and confidence calls each swallow their own 400 and degrade to heuristics. The bot keeps answering; every answer is the apology. `samplingParams()` is spread into the request rather than setting the field, so the key is absent — not undefined — for models that reject it. Allowlist rather than a list of models to avoid, deliberately: a deny-list leaves a model released after this file was last touched unlisted, so we send the parameter and reproduce exactly the outage above. An allowlist leaves it out instead, and the request succeeds at the model's default sampling. Omitting is always accepted by the API; sending is not. Removing the 400 is not on its own enough to make such a swap safe, so two more things ride along: - classifier.ts, confidence.ts and sentiment.ts read `content[0]` only. Every model this change unblocks runs thinking on by default, which puts a `thinking` block first and the JSON second — so `text` came out empty, parsing found nothing, and each call degraded to its heuristic with no error. All three now use `extractResponseText`, as the generator already did. Note the separate remaining half: `maxClassifierTokens` (512) and `maxConfidenceTokens` (256) are both below a thinking turn, so those budgets want revisiting before anyone actually makes the swap. - the omission warning is deduped per model. It ran per request, so one env change meant thousands of identical lines a day burying real errors. Allowlist also gains claude-opus-4-0 and claude-sonnet-4-0, which accept a temperature and are deprecated but not yet retired. claude-2.x and claude-opus-4-1 are left out on purpose — already retired, so unreachable. Not taken here: the fail-loudly-on-400 half of #149. A BadRequestError is a deploy-time bug and should surface distinctly from a rate limit, but that changes the never-crash contract every caller relies on. Tracked in #231. Refs #149
…althy Addresses the review on #240. Both blockers were right. ## The title claim had no test at any call site Reverting any one call site from `...samplingParams(...)` to `temperature: config.…` left the whole suite green, because every existing test constructs its client with an allowlisted model — so the parameter is sent either way and no assertion could tell the difference. samplingParams was unit-tested; that the call sites USE it was pinned nowhere. One test per call site now configures a rejecting model and asserts the outgoing request carries no temperature, read from aimock's request journal. Asserted on the value rather than key presence: the journal is a normalized view of the request and always carries a `temperature` key, holding undefined when we sent none. Absence on the wire stays pinned by model-capabilities.test.ts; these pin that the call site routes through the gate at all. ## An empty response read as a healthy result The three converted call sites copied `extractResponseText` without the generator's empty-text guard, and each turned a missing answer into a fabricated one reported as fine: - sentiment.ts is the one with teeth. account-scoring.ts skips its DB write only when `degraded` is set, so an empty response returned a fabricated NEUTRAL with degraded:false — flipping a fail-CLOSED gate to fail-OPEN and persisting a sentiment nobody measured. - classifier.ts was worse than the PR body claimed. I wrote that these calls "degrade to the heuristic". They did not: parseClassification's catch returned a hardcoded MEDIUM/OTHER while `classify` reported degraded:false, throwing away the heuristic verdict it had already computed. The claim in the body was wrong, not just incomplete. - confidence.ts took the same shape. All three now treat an empty extraction as a failure, which routes to each one's existing catch — the heuristic for the classifier and scorer, the degraded fallback for sentiment. Reachable as soon as a thinking-default model is configured, since maxClassifierTokens (512) and maxConfidenceTokens (256) both sit below a thinking turn: exactly the swap this PR exists to enable. So the change no longer trades a loud 400 for a quiet wrong answer. Verification: ai package 289 -> 300, full repo turbo run test 10/10, typecheck clean. Four mutations, each killing exactly one test: reverting the classifier call site, reverting the generator call site, dropping the sentiment guard, dropping the classifier guard. Refs CPK-8074
536d69b to
edf510e
Compare
jerelvelarde
left a comment
There was a problem hiding this comment.
Re-reviewed against the tree rather than the description. Both blockers are closed, and I checked the claims by execution this time rather than by reading.
Call-site coverage. ai/src is 300 tests, and each of the three sites now has a test that configures claude-opus-5 and asserts body.temperature is undefined, with a positive control asserting it is defined on an allowlisted model. That positive control is the part that makes the pair meaningful — without it the assertion passes for a request that was never sent.
Ran the mutations rather than taking the count:
| Mutation | Result |
|---|---|
classifier.ts:62 → temperature: config.classifierTemperature |
1 failed / 299 passed |
confidence.ts:69 → same |
1 failed / 299 passed |
sentiment.ts:71 → same |
1 failed / 299 passed |
sentiment.ts empty-extraction guard deleted |
1 failed / 299 passed |
Four for four, each killing exactly one test. The title claim is now pinned where it is made.
The sentiment.ts seam. The guard throws into the existing catch, which returns degraded: true (:106) — so account-scoring.ts keeps skipping its DB write, and the fail-closed gate stays fail-closed. That was the one I was going to hold on and it is the right shape.
Also worth saying: you corrected a claim in your own PR body rather than the code around it. parseClassification's catch returning a hardcoded MEDIUM/OTHER while reporting degraded: false is not "degrading to the heuristic", and the body now says so. That is the harder half of a fix round.
Approving.
Not blocking, but it is the gating item for anyone who actually flips the env var: maxClassifierTokens (512) and maxConfidenceTokens (256) are both below a thinking turn. You have flagged this twice now from two directions. Removing the 400 makes the swap possible; those two budgets are what make it safe. Worth an issue with a linked owner rather than a third mention in a PR body.
Closes #149.
All five Anthropic calls in
packages/outpost/aiset atemperaturewhile the model on each is env-overridable. Checked against the current API reference: the sampling parameters —temperature,top_p,top_k— are removed on Fable 5, Mythos 5, Opus 5, Opus 4.8, Opus 4.7 and Sonnet 5, and sending any of them returns400 invalid_request_error. The defaults today (claude-sonnet-4-6,claude-haiku-4-5-20251001) all still accept one, so nothing is broken right now.What makes it worth fixing ahead of time is the shape of the failure. Set
AI_RESPONSE_MODEL=claude-opus-5, an entirely reasonable upgrade, and everymessages.createin the generator 400s, the generator catches it, and "I apologize, but I was unable to generate a response at this time" is what gets posted publicly to every Discord thread and GitHub issue. Classifier, sentiment and confidence each swallow their own 400 and degrade to heuristics. The bot keeps answering; every answer is the apology.samplingParams()is spread into the request rather than assigning the field, so the key is absent rather thanundefinedfor models that reject it.Why an allowlist rather than a list of models to avoid
The two directions fail differently and only one fails safely. A deny-list leaves a model released after the file was last touched unlisted, so we send the parameter and reproduce exactly the outage above. An allowlist leaves it out instead, and the request succeeds at the model's default sampling. Omitting is always accepted by the API; sending is not.
onOmittedreports the drop so a silent sampling change still leaves a trace.Two things riding along, because removing the 400 isn't enough on its own
A review pass turned these up, and the first one is the reason this PR is bigger than its title:
classifier.ts,confidence.tsandsentiment.tsreadcontent[0]only. Every model this change unblocks runs thinking on by default, which puts athinkingblock first and the JSON second — sotextcame out empty, parsing found nothing, and each call degraded to its heuristic with no error at all. Same silent degradation, one layer down. All three now useextractResponseText, as the generator already did after fix(ai): read every text block from the model, and fail loudly on none #223.The allowlist also gains
claude-opus-4-0andclaude-sonnet-4-0— they accept a temperature and are deprecated but not yet retired.claude-2.xandclaude-opus-4-1are left out on purpose, since both are already retired and unreachable; there's a comment recording that so the list doesn't read as an incomplete model registry.Tests
14 new. Mutation-checked rather than trusted green:
classifier.tstocontent[0]-onlyshould read past a leading thinking blockwarns once per model rather than once per requestEach kills exactly one test. Also pinned: an unknown model omits rather than sends, and
{}is asserted as an absent key rather thanundefined, since anundefinedserializes tonullthrough some JSON paths — a 400 on exactly the models this protects.aipackage 270 → 289. Full repoturbo run test10/10 packages.tsc --noEmitclean.Still open after this
maxClassifierTokens(512) andmaxConfidenceTokens(256) are both below a thinking turn, so those budgets want revisiting before anyone actually makes the swap. That's a cost and behaviour decision rather than a mechanical one, so it isn't here.BadRequestErroris a deploy-time bug and should surface distinctly from a rate limit, but that changes the never-crash contract atgenerator.ts:163that every caller relies on. Tracked in generator: fail loudly on the unusable-response modes that actually happen #231.Independent of #239 — branched from
main, not from it, so they can merge in either order.