diff --git a/backend/src/utils/llm/providers.ts b/backend/src/utils/llm/providers.ts index b31cee5..1aaaf2b 100644 --- a/backend/src/utils/llm/providers.ts +++ b/backend/src/utils/llm/providers.ts @@ -364,6 +364,31 @@ function clampTemperature(model: string, requested: number): number { return requested; } +/** + * Detects the "this model only supports the default temperature" error. + * + * Real OpenAI populates `err.code === "unsupported_value"` and + * `err.param === "temperature"`, but many OpenAI-*compatible* endpoints only + * return the message string (e.g. "Unsupported value: 'temperature' does not + * support 0.7 with this model. Only the default (1) value is supported.") + * without those structured fields, so we fall back to matching the text. + */ +export function isTemperatureUnsupportedError(err: any): boolean { + const param = err?.param ?? err?.error?.param; + const code = err?.code ?? err?.error?.code; + if (code === "unsupported_value" && param === "temperature") return true; + + const message = String( + err?.message ?? err?.error?.message ?? "", + ).toLowerCase(); + return ( + message.includes("temperature") && + (message.includes("does not support") || + message.includes("only the default") || + message.includes("unsupported value")) + ); +} + function normalizeFinishReason(raw: string | null | undefined): FinishReason { if (raw === "stop" || raw === "end_turn") return "stop"; if (raw === "tool_calls" || raw === "tool_use") return "tool_calls"; @@ -623,9 +648,7 @@ export async function invoke_llm(opts: InvokeOptions): Promise { return await tryCompletion(requestedTemp); } catch (err: any) { const isTempUnsupported = - err?.code === "unsupported_value" && - err?.param === "temperature" && - requestedTemp !== 1; + requestedTemp !== 1 && isTemperatureUnsupportedError(err); if (isTempUnsupported) { console.warn( @@ -1259,9 +1282,7 @@ export async function invoke_llm_streaming( return await runStream(requestedTemp); } catch (err: any) { const isTempUnsupported = - err?.code === "unsupported_value" && - err?.param === "temperature" && - requestedTemp !== 1; + requestedTemp !== 1 && isTemperatureUnsupportedError(err); if (isTempUnsupported) { console.warn(`[inference] Retrying stream with temperature=1`); diff --git a/backend/tests/temperatureRetry.test.ts b/backend/tests/temperatureRetry.test.ts new file mode 100644 index 0000000..0f7f9c7 --- /dev/null +++ b/backend/tests/temperatureRetry.test.ts @@ -0,0 +1,51 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { isTemperatureUnsupportedError } from "../src/utils/llm/providers"; + +test("detects structured OpenAI unsupported_value/temperature error", () => { + const err = { + status: 400, + code: "unsupported_value", + param: "temperature", + message: "Unsupported value: 'temperature' does not support 0.7 with this model.", + }; + assert.equal(isTemperatureUnsupportedError(err), true); +}); + +test("detects OpenAI-compatible endpoints that only return a message string", () => { + // Many OpenAI-compatible servers omit err.code/err.param and only set the + // message — this is the exact error users hit against such endpoints. + const err = { + status: 400, + message: + "400 Unsupported value: 'temperature' does not support 0.7 with this model. Only the default (1) value is supported.", + }; + assert.equal(isTemperatureUnsupportedError(err), true); +}); + +test("detects nested error object shape", () => { + const err = { + error: { + code: "unsupported_value", + param: "temperature", + message: "temperature does not support 0.7", + }, + }; + assert.equal(isTemperatureUnsupportedError(err), true); +}); + +test("ignores unrelated errors", () => { + assert.equal( + isTemperatureUnsupportedError({ status: 500, message: "internal server error" }), + false, + ); + assert.equal( + isTemperatureUnsupportedError({ + code: "unsupported_value", + param: "top_p", + message: "Unsupported value: 'top_p'", + }), + false, + ); + assert.equal(isTemperatureUnsupportedError(undefined), false); +});