From b1fe1f0597464d88d67c33897b4221d2287be2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 19:39:38 +0100 Subject: [PATCH] fix(ai): hold a refused reasoning field for a day A gateway that refuses the reasoning field does so by its configuration, so the lesson now holds for 24 hours instead of one. At the hour every expiry cost one refused request, a few a day on a gateway that runs the briefings unattended. Lessons read from a single reply keep the hour. Refs #1279 --- src/lib/ai/__tests__/local-reasoning.test.ts | 8 +++- src/lib/ai/__tests__/openai-reasoning.test.ts | 8 +++- .../reasoning/__tests__/dialect-cache.test.ts | 47 +++++++++++++++++-- src/lib/ai/reasoning/dialect-cache.ts | 30 ++++++++++-- 4 files changed, 80 insertions(+), 13 deletions(-) diff --git a/src/lib/ai/__tests__/local-reasoning.test.ts b/src/lib/ai/__tests__/local-reasoning.test.ts index 30e5f091f..c492e4f29 100644 --- a/src/lib/ai/__tests__/local-reasoning.test.ts +++ b/src/lib/ai/__tests__/local-reasoning.test.ts @@ -533,7 +533,7 @@ describe("local reasoning refusals the client must not learn from", () => { expect(body(mock, 1).reasoning_effort).toBe("low"); }); - it("sends the field again an hour after a refusal", async () => { + it("holds a refusal for a day, then sends the field again", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const refusal = readFileSync( @@ -550,9 +550,13 @@ describe("local reasoning refusals the client must not learn from", () => { const wanted = params({ reasoning: { effort: "low", summaries: true } }); await client().generateCompletion(wanted); expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + // #1279: the hour cost one refused request per expiry. vi.advanceTimersByTime(60 * 60 * 1000); await client().generateCompletion(wanted); - expect(body(mock, 2).reasoning_effort).toBe("low"); + expect(body(mock, 2)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + await client().generateCompletion(wanted); + expect(body(mock, 3).reasoning_effort).toBe("low"); } finally { vi.useRealTimers(); } diff --git a/src/lib/ai/__tests__/openai-reasoning.test.ts b/src/lib/ai/__tests__/openai-reasoning.test.ts index 176ce2763..fd38826e4 100644 --- a/src/lib/ai/__tests__/openai-reasoning.test.ts +++ b/src/lib/ai/__tests__/openai-reasoning.test.ts @@ -348,7 +348,7 @@ describe("OpenAI reasoning refusals the client must not learn from", () => { expect(body(mock, 1).reasoning_effort).toBe("low"); }); - it("sends reasoning again an hour after a refusal", async () => { + it("holds a refusal for a day, then sends reasoning again", async () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const mock = vi @@ -365,9 +365,13 @@ describe("OpenAI reasoning refusals the client must not learn from", () => { const wanted = params({ reasoning: { effort: "low", summaries: false } }); await c.generateCompletion(wanted); expect(body(mock, 1)).not.toHaveProperty("reasoning_effort"); + // #1279: the hour cost one refused request per expiry. vi.advanceTimersByTime(60 * 60 * 1000); await c.generateCompletion(wanted); - expect(body(mock, 2).reasoning_effort).toBe("low"); + expect(body(mock, 2)).not.toHaveProperty("reasoning_effort"); + vi.advanceTimersByTime(23 * 60 * 60 * 1000); + await c.generateCompletion(wanted); + expect(body(mock, 3).reasoning_effort).toBe("low"); } finally { vi.useRealTimers(); } diff --git a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts index 37014bf91..94450e9a6 100644 --- a/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts +++ b/src/lib/ai/reasoning/__tests__/dialect-cache.test.ts @@ -9,8 +9,12 @@ vi.mock("@/lib/logging/context", () => ({ })); import { + ANSWERS_ONLY_UNREASONED, annotateReasoningDowngrade, DIALECT_TTL_MS, + FIELD_REFUSED_REASONS_REGARDLESS, + FIELD_REFUSED_TTL_MS, + REASONS_REGARDLESS, isReasoningParameterRejection, learnedReasoningDialect, rememberReasoningDialect, @@ -56,19 +60,54 @@ describe("reasoning dialect cache", () => { vi.useFakeTimers({ toFake: ["Date"] }); try { const key = { provider: "openai", endpoint: "http://g/v1", model: "m" }; - rememberReasoningDialect(key, "none"); + rememberReasoningDialect(key, ANSWERS_ONLY_UNREASONED); vi.advanceTimersByTime(DIALECT_TTL_MS - 1); - expect(learnedReasoningDialect(key)).toBe("none"); + expect(learnedReasoningDialect(key)).toBe(ANSWERS_ONLY_UNREASONED); vi.advanceTimersByTime(1); expect(learnedReasoningDialect(key)).toBeUndefined(); - rememberReasoningDialect(key, "none"); + rememberReasoningDialect(key, REASONS_REGARDLESS); vi.advanceTimersByTime(DIALECT_TTL_MS / 2); - expect(learnedReasoningDialect(key)).toBe("none"); + expect(learnedReasoningDialect(key)).toBe(REASONS_REGARDLESS); expect(DIALECT_TTL_MS).toBe(60 * 60 * 1000); } finally { vi.useRealTimers(); } }); + + // #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) => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const key = { provider: "openai", endpoint: "http://g/v1", model: "m" }; + rememberReasoningDialect(key, value); + vi.advanceTimersByTime(DIALECT_TTL_MS * 2); + expect(learnedReasoningDialect(key)).toBe(value); + vi.advanceTimersByTime(FIELD_REFUSED_TTL_MS - DIALECT_TTL_MS * 2 - 1); + expect(learnedReasoningDialect(key)).toBe(value); + vi.advanceTimersByTime(1); + expect(learnedReasoningDialect(key)).toBeUndefined(); + expect(FIELD_REFUSED_TTL_MS).toBe(24 * 60 * 60 * 1000); + } 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); + vi.advanceTimersByTime(DIALECT_TTL_MS); + expect(learnedReasoningDialect(key)).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); }); describe("isReasoningParameterRejection", () => { diff --git a/src/lib/ai/reasoning/dialect-cache.ts b/src/lib/ai/reasoning/dialect-cache.ts index b1d7f841c..2258dd743 100644 --- a/src/lib/ai/reasoning/dialect-cache.ts +++ b/src/lib/ai/reasoning/dialect-cache.ts @@ -1,8 +1,9 @@ /** * v1.41 — what an endpoint answered when it was asked to reason, learned at - * runtime and kept for an hour (`DIALECT_TTL_MS`), so an endpoint that - * gains the parameter, or a downgrade learned from a misread error, heals - * without a restart. + * 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 + * parameter, or a downgrade learned from a misread error, heals without a + * restart. * * Every client that sends reasoning parameters to a wire it does not control * (a gateway, a local server, a model name the client has never seen, a Codex @@ -76,6 +77,18 @@ export function refusesReasoningField(learned: string | undefined): boolean { /** How long a learned downgrade holds before the full request is tried again. */ 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. + */ +export const FIELD_REFUSED_TTL_MS = 24 * 60 * 60 * 1000; + /** * Pinned on `globalThis`: a module imported from more than one bundler layer * is instantiated once per layer (see `caches` in `server-cache.ts`), so the @@ -108,12 +121,19 @@ export function learnedReasoningDialect( return entry.value; } -/** Record what `key` accepts after it refused a richer request. */ +/** + * Record what `key` accepts after it refused a richer request. A refused + * field holds for `FIELD_REFUSED_TTL_MS`, every other lesson for + * `DIALECT_TTL_MS`. + */ export function rememberReasoningDialect( key: ReasoningDialectKey, value: string, ): void { - learned.set(cacheKey(key), { value, expiresAt: Date.now() + DIALECT_TTL_MS }); + const ttl = refusesReasoningField(value) + ? FIELD_REFUSED_TTL_MS + : DIALECT_TTL_MS; + learned.set(cacheKey(key), { value, expiresAt: Date.now() + ttl }); } /**