fix(ai): keep the reasoning-field day for what a 400 taught

The dialect cache gave a day to every value that says the reasoning
field is refused, and that includes the combined "field refused, reasons
regardless" value, which one answerless reply writes. One reply could
therefore stretch a lesson to a full fresh day, against the rule that a
lesson read from a single reply keeps the hour.

A lesson now says what taught it. A 400 refusal gives a refused field
its day. A reply that upgrades a refused field to the combined value
keeps the refusal's remaining lifetime rather than a new day or the
hour, so the refused request does not come back early either.
This commit is contained in:
Marc-André Bombeck committed 2026-10-29 21:58:40 +01:00
1 parent c920266f32
commit 5dec158c9d
3 files changed
+96 -29

No files matched your search

@@ -20,6 +20,7 @@ import {
rememberReasoningDialect,
resetReasoningDialectCache,
} from "../dialect-cache";
import { learnReasoningFieldRefused, nextAnswerlessStep } from "../answerless";
beforeEach(() => {
resetReasoningDialectCache();
@@ -29,7 +30,7 @@ beforeEach(() => {
describe("reasoning dialect cache", () => {
it("keys on provider, endpoint and model together", () => {
const key = { provider: "local", endpoint: "http://a/v1", model: "m1" };
rememberReasoningDialect(key, "none");
rememberReasoningDialect(key, "none", "refusal");
expect(learnedReasoningDialect(key)).toBe("none");
expect(learnedReasoningDialect({ ...key, model: "m2" })).toBeUndefined();
expect(
@@ -60,12 +61,12 @@ describe("reasoning dialect cache", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
expect(learnedReasoningDialect(key)).toBe(ANSWERS_ONLY_UNREASONED);
vi.advanceTimersByTime(1);
expect(learnedReasoningDialect(key)).toBeUndefined();
rememberReasoningDialect(key, REASONS_REGARDLESS);
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS / 2);
expect(learnedReasoningDialect(key)).toBe(REASONS_REGARDLESS);
expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000);
@@ -75,14 +76,22 @@ describe("reasoning dialect cache", () => {
});
// #1279: at the hour, a gateway that refuses the field paid one refused
// request per expiry, a few a day.
it.each(["none", FIELD_REFUSED_REASONS_REGARDLESS])(
"holds a refused field (%s) for a day, not an hour",
(value) => {
// request per expiry, a few a day. Only a 400 refusing the field teaches
// the day.
it.each([
["none", false],
[FIELD_REFUSED_REASONS_REGARDLESS, true],
] as const)(
"holds a refused field a 400 taught (%s) for a day, not an hour",
(value, reasonedUnasked) => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "openai", endpoint: "http://g/v1", model: "m" };
rememberReasoningDialect(key, value);
if (reasonedUnasked) {
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
}
learnReasoningFieldRefused(key, "openai", "low");
expect(learnedReasoningDialect(key)).toBe(value);
vi.advanceTimersByTime(DIALECT_TTL_MS * 2);
expect(learnedReasoningDialect(key)).toBe(value);
vi.advanceTimersByTime(FIELD_REFUSED_TTL_MS - DIALECT_TTL_MS * 2 - 1);
@@ -96,12 +105,41 @@ describe("reasoning dialect cache", () => {
},
);
it("keeps a refused field's own day when one reply adds that the model reasons regardless", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
learnReasoningFieldRefused(key, "local", "low");
// Late in the field's day, an answerless reply that reasoned.
const late = FIELD_REFUSED_TTL_MS - 2 * DIALECT_TTL_MS;
vi.advanceTimersByTime(late);
expect(nextAnswerlessStep(key, null, true)).toEqual({
kind: "final",
learnedRegardless: true,
});
expect(learnedReasoningDialect(key)).toBe(
FIELD_REFUSED_REASONS_REGARDLESS,
);
// Not cut to the hour: the field refusal would come back after it.
vi.advanceTimersByTime(DIALECT_TTL_MS + 1);
expect(learnedReasoningDialect(key)).toBe(
FIELD_REFUSED_REASONS_REGARDLESS,
);
// Not a fresh day either: one reply taught it, and the field's day
// ends where the 400 set it.
vi.advanceTimersByTime(DIALECT_TTL_MS - 1);
expect(learnedReasoningDialect(key)).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("gives a behavioural lesson that replaces a refused field the hour", () => {
vi.useFakeTimers({ toFake: ["Date"] });
try {
const key = { provider: "local", endpoint: "http://l/v1", model: "m" };
rememberReasoningDialect(key, "none");
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, "none", "refusal");
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
vi.advanceTimersByTime(DIALECT_TTL_MS);
expect(learnedReasoningDialect(key)).toBeUndefined();
} finally {
+4 -3
View File
@@ -71,18 +71,18 @@ export function nextAnswerlessStep(
// The endpoint refuses the field: there is no `none` to ask with.
if (learned === "none") {
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS);
rememberReasoningDialect(key, FIELD_REFUSED_REASONS_REGARDLESS, "reply");
return { kind: "final", learnedRegardless: true };
}
if (sent === "none") {
if (!hadReasoning) return { kind: "final", learnedRegardless: false };
rememberReasoningDialect(key, REASONS_REGARDLESS);
rememberReasoningDialect(key, REASONS_REGARDLESS, "reply");
return { kind: "final", learnedRegardless: true };
}
if (!hadReasoning && sent === null) {
return { kind: "final", learnedRegardless: false };
}
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED);
rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED, "reply");
return { kind: "ask_unreasoned" };
}
@@ -212,6 +212,7 @@ export function learnReasoningFieldRefused(
rememberReasoningDialect(
key,
reasonsUnasked(key) ? FIELD_REFUSED_REASONS_REGARDLESS : "none",
"refusal",
);
annotateReasoningDowngrade(provider, sent, "none");
// One line per lesson, not per call: the operator learns that the
+44 -16
View File
@@ -1,7 +1,7 @@
/**
* v1.41 — what an endpoint answered when it was asked to reason, learned at
* runtime and kept for an hour (`DIALECT_TTL_MS`), a refused reasoning field
* for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the
* runtime and kept for an hour (`DIALECT_TTL_MS`), a reasoning field a 400
* refused for a day (`FIELD_REFUSED_TTL_MS`), so an endpoint that gains the
* parameter, or a downgrade learned from a misread error, heals without a
* restart.
*
@@ -78,14 +78,15 @@ export function refusesReasoningField(learned: string | undefined): boolean {
export const DIALECT_TTL_MS = 60 * 60 * 1000;
/**
* #1279 — how long a refused reasoning field holds (`refusesReasoningField`).
* A gateway that refuses the field does so by its configuration (LiteLLM
* without `drop_params`), which changes when its operator changes it, not
* between two calls, and only a 400 naming the field with refusal wording
* can teach it. At the hour every expiry cost one refused request, a few a
* day on a gateway that runs the briefings unattended. A day bounds that to
* one per day, and a gateway that gains the field still gets it the next day.
* The other lessons read a single reply and keep the hour.
* #1279 — how long a reasoning field refused by a 400 holds
* (`refusesReasoningField`). A gateway that refuses the field does so by its
* configuration (LiteLLM without `drop_params`), which changes when its
* operator changes it, not between two calls, and only a 400 naming the
* field with refusal wording can teach it. At the hour every expiry cost one
* refused request, a few a day on a gateway that runs the briefings
* unattended. A day bounds that to one per day, and a gateway that gains the
* field still gets it the next day. A lesson read from a single reply keeps
* the hour.
*/
export const FIELD_REFUSED_TTL_MS = 24 * 60 * 60 * 1000;
@@ -122,18 +123,45 @@ export function learnedReasoningDialect(
}
/**
* Record what `key` accepts after it refused a richer request. A refused
* field holds for `FIELD_REFUSED_TTL_MS`, every other lesson for
* What taught a lesson: a 400 that refused a parameter the client sent, or a
* single reply (one that came back without an answer).
*/
export type ReasoningLessonSource = "refusal" | "reply";
/**
* Record what `key` accepts, by default after a 400 refused a richer request;
* a lesson read from a reply says so (`"reply"`). A reasoning field a 400
* refused holds for `FIELD_REFUSED_TTL_MS`, every other lesson for
* `DIALECT_TTL_MS`.
*
* `FIELD_REFUSED_REASONS_REGARDLESS` carries two facts, and a reply only ever
* adds the second ("reasons regardless") to a field refusal a 400 taught
* earlier. That upgrade keeps the refusal's remaining lifetime: a fresh day
* would let one reply extend what the 400 taught, and the hour would bring
* the refused request back after it. The "regardless" half rides along and
* is learned again from the next answerless reply if it lapses.
*/
export function rememberReasoningDialect(
key: ReasoningDialectKey,
value: string,
source: ReasoningLessonSource = "refusal",
): void {
const ttl = refusesReasoningField(value)
? FIELD_REFUSED_TTL_MS
: DIALECT_TTL_MS;
learned.set(cacheKey(key), { value, expiresAt: Date.now() + ttl });
const k = cacheKey(key);
const now = Date.now();
let expiresAt = now + DIALECT_TTL_MS;
if (refusesReasoningField(value)) {
const before = learned.get(k);
if (source === "refusal") {
expiresAt = now + FIELD_REFUSED_TTL_MS;
} else if (
before &&
before.expiresAt > now &&
refusesReasoningField(before.value)
) {
expiresAt = before.expiresAt;
}
}
learned.set(k, { value, expiresAt });
}
/**