diff --git a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts index 94450e9a6..4d6c57134 100644 --- a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts +++ b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts @@ -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 { diff --git a/src/lib/ai/reasoning/answerless.ts b/src/lib/ai/reasoning/answerless.ts index 492a94e3f..6da80c24d 100644 --- a/src/lib/ai/reasoning/answerless.ts +++ b/src/lib/ai/reasoning/answerless.ts @@ -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 diff --git a/src/lib/ai/reasoning/dialect-cache.ts b/src/lib/ai/reasoning/dialect-cache.ts index 2258dd743..9289479ef 100644 --- a/src/lib/ai/reasoning/dialect-cache.ts +++ b/src/lib/ai/reasoning/dialect-cache.ts @@ -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 }); } /**