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 }); } /**