Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,15 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: src/lib/jobs/reasoningCacheCleanupJob.ts.
#OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS=1800000

# Recover from a reasoning_effort 4xx whose body names no accepted set, by
# probing one tier down and learning that tier when the probe is answered.
# Comma-separated provider ids, or * for all. Default: unset (disabled).
# Off by default because an opaque 4xx carries no signal that the *effort* was
# the cause — a 2xx on the probe could mask an unrelated validation failure and
# pin a reasoning cap that was never proven. Upstreams that DO name their
# accepted set are already handled without this flag.
#OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS=

# Opt-in minimum output budget (tokens) for reasoning models (#10281 follow-up).
# When set, a caller max_tokens in [256, floor) on a thinking-capable model is
# raised to the floor so reasoning tokens cannot consume the whole budget
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** recover from a `reasoning_effort` 4xx that names no accepted set — opt in per provider with `OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS` and the reactive path probes one tier down, learning that single tier when the probe is answered so later requests clamp up front instead of 400ing on every attempt
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ detection above).
| `OMNIROUTE_DISABLE_CONNECTION_RECOVERY` | `false` | `src/lib/quota/connectionRecovery.ts` | Disable the proactive connection-cooldown recovery scheduler (lazy recovery in `getProviderCredentials` still applies). |
| `OMNIROUTE_REASONING_CACHE_CLEANUP_INTERVAL_MS` | `1800000` | `src/lib/jobs/reasoningCacheCleanupJob.ts` | Reasoning cache cleanup cadence (ms). Floor `60000`. |
| `OMNIROUTE_REASONING_MIN_BUDGET` | _unset_ (disabled) | `open-sse/services/reasoningTokenBuffer.ts` | Opt-in floor for thinking-model output budgets: caller `max_tokens` in `[256, floor)` is raised to the floor (capped by the model output cap). Unset = client budgets never enlarged (#9507). |
| `OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS` | _unset_ (disabled) | `open-sse/services/learnedReasoningEffortCaps.ts` | Opt-in, comma-separated provider ids (or `*`): when a `reasoning_effort` 400/422 names no accepted set, retry once one tier lower (never below `low`) and learn that tier only if the probe is answered (#14895). |
| `OMNIROUTE_LOG_EXPORT_CRON` | `0 * * * *` | `src/lib/jobs/logExportJob.ts` | Cron expression (UTC) for the call-log export job that drains every enabled log-export destination. |
| `OMNIROUTE_CONFIG_HOT_RELOAD_MS` | `5000` | `src/lib/config/hotReload.ts` | Polling interval (ms) for config hot-reload. Lower than `1000` is rejected. |
| `OMNIROUTE_DISABLE_REDIS_AUTH_CACHE` | _(enabled)_ | `src/lib/db/apiKeys.ts` | Set `1` to bypass the Redis-backed API-key auth cache (forces DB reads). |
Expand Down
23 changes: 23 additions & 0 deletions open-sse/executors/base/reasoningEffort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,29 @@ function writeEffortValue(
return next;
}

/**
* The effort the outgoing body actually asks for, across all three carriers.
* Used by the reactive 4xx probe in `base.ts` to decide which tier to step
* down to, so it must report what is on the wire — not what the registry says
* the model supports.
*/
export function readBodyReasoningEffort(body: unknown): string | null {
if (!body || typeof body !== "object") return null;
const effort = readEffortCarriers(body as Record<string, unknown>).effort;
return typeof effort === "string" ? effort : null;
}

/**
* Write `value` onto every carrier the body already uses, leaving the shape of
* the body untouched — the reactive probe rewrites one field of the request
* that is already on its way upstream, so it must not reshape anything else.
*/
export function writeBodyReasoningEffort(body: unknown, value: string): unknown {
if (!body || typeof body !== "object") return body;
const record = body as Record<string, unknown>;
return writeEffortValue(record, value, readEffortCarriers(record));
}

/** Strip the effort field from every carrier that was present. */
function stripEffortValue(b: Record<string, unknown>, c: EffortCarriers): Record<string, unknown> {
const next: Record<string, unknown> = { ...b };
Expand Down
55 changes: 53 additions & 2 deletions open-sse/executors/base/reasoningEffortRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@
// sanitizer, and retry the SAME url once.
import { HTTP_STATUS } from "../../config/constants.ts";
import {
nextProbeReasoningEffort,
parseReasoningEffortEnum,
reasoningEffortProbeEnabled,
recordLearnedProbeReasoningEffort,
recordLearnedReasoningEffort,
} from "../../services/learnedReasoningEffortCaps.ts";
import { sanitizeReasoningEffortForProvider } from "./reasoningEffort.ts";
import {
readBodyReasoningEffort,
sanitizeReasoningEffortForProvider,
writeBodyReasoningEffort,
} from "./reasoningEffort.ts";

type ReasoningRecoveryLog = {
info?: (tag: string, message: string) => void;
Expand Down Expand Up @@ -83,7 +90,7 @@ export async function applyReasoningEffortRecovery(
.catch(() => "");
const acceptedValues = parseReasoningEffortEnum(errText);
if (!acceptedValues) {
return notRecoverable;
return probeOneTierDown(params, body, notRecoverable);
}

const learned = recordLearnedReasoningEffort(provider, model as string, acceptedValues);
Expand Down Expand Up @@ -111,3 +118,47 @@ export async function applyReasoningEffortRecovery(
const retriedResponse = await fetchFn(url, { ...fetchOptions, body: retryBody });
return { response: retriedResponse, body: clampedBody, retried: true, attempted: true };
}

/**
* #14895 — the 4xx body named no accepted set, so there is nothing to learn from
* it and no way to tell an effort rejection from an unrelated validation failure.
* Opt in per provider (OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS, off by default)
* rather than guess for everyone: a 2xx on the probe would otherwise mask the real
* error and record a cap nothing was proven against. When enabled, step down ONE
* tier and retry the same URL once; only an ANSWERED probe is recorded (that one
* tier), so later requests clamp up front. A refused probe learns nothing and its
* response is surfaced. Lives here (not inline in BaseExecutor) so every caller of
* applyReasoningEffortRecovery — commandCode, cliproxyapi, glm — gets it too.
*/
async function probeOneTierDown(
params: ReasoningEffortRecoveryParams,
body: unknown,
notRecoverable: ReasoningEffortRecoveryResult
): Promise<ReasoningEffortRecoveryResult> {
const { response, url, provider, model, fetchOptions, fetchFn, log } = params;
if (!reasoningEffortProbeEnabled(provider)) return notRecoverable;
const probe = nextProbeReasoningEffort(readBodyReasoningEffort(body) ?? "");
if (!probe) return notRecoverable;

const probeBody = writeBodyReasoningEffort(body, probe);
const serialize = params.serializeBody ?? ((b: unknown) => JSON.stringify(b));
const serialized = await serialize(probeBody);
log?.info?.(
"REASONING_SANITIZE",
`Upstream ${response.status} refused reasoning_effort on ${url} without naming the accepted set — probing one step down (${probe}) for ${provider}/${model}`
);
const probeResponse = await fetchFn(url, { ...fetchOptions, body: serialized });
if (probeResponse.ok) {
const learned = recordLearnedProbeReasoningEffort(provider, model, probe);
log?.info?.(
"REASONING_SANITIZE",
`Probe accepted for ${provider}/${model} — learned ceiling up to ${[...(learned ?? [])].join(",") || probe}`
);
} else {
log?.info?.(
"REASONING_SANITIZE",
`Probe ${probe} also refused for ${provider}/${model} — learned nothing, surfacing the probe's ${probeResponse.status}`
);
}
return { response: probeResponse, body: probeBody, retried: true, attempted: true };
}
95 changes: 95 additions & 0 deletions open-sse/services/learnedReasoningEffortCaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,101 @@ export function clampToLearned(effortStr: string, accepted: Set<string>): string
return nearestAbove ?? highest;
}

// ── Single-step probe for 4xx bodies that name no enum ────────────────────
// Most gateways that reject an out-of-range reasoning_effort DO advertise the
// accepted set ("expected one of `low`, `medium`, `high`"), and
// `parseReasoningEffortEnum` reads it. Some do not: the body is an opaque
// "Invalid request parameters" / "Streaming response failed: [400] ..." with
// no list at all. Nothing can be learned from that text, so the request 400s
// forever — the only remedy is to try a lower tier and see whether it is
// accepted. These two helpers drive that one-step probe: pick the tier to try,
// and — once a probe SUCCEEDS — remember the whole range it just proved
// accepted, so every later request clamps without another round trip.

// The probe floor. `low` is the universally-accepted baseline on every
// OpenAI-compatible reasoning surface, so it is the safest rung to land on. The
// ladder deliberately stops HERE: the two rungs below it (`minimal`, `none`) are
// not just "less thinking", they are the carriers that switch thinking OFF. A
// probe that happened to be answered on `minimal` proves the model tolerates
// `minimal`, not that it wants reasoning disabled, so stepping down that far
// would silently downgrade a request's intent rather than repair it.
const PROBE_FLOOR = "low";

/**
* The next tier to try when a 4xx named no enum, given the effort that was sent.
*
* Steps down exactly one rung (xhigh → high, max → xhigh, ultra → max, …) so a
* probe costs the client as little reasoning as possible, and never below
* `PROBE_FLOOR`. Returns null when there is nothing to step down to (already at
* the floor, an unrecognized value, or nothing left but the thinking-off tiers):
* stripping the field entirely is the learned path's job, not the probe's.
*/
export function nextProbeReasoningEffort(sent: string): string | null {
const idx = REASONING_EFFORT_ORDER.indexOf(sent);
const floorIdx = REASONING_EFFORT_ORDER.indexOf(PROBE_FLOOR);
if (idx < 0 || floorIdx < 0 || idx <= floorIdx) return null;
const next = REASONING_EFFORT_ORDER[idx - 1];
return next && next !== sent ? next : null;
}

/**
* Whether the opaque-4xx probe may run for this provider.
*
* The probe is a *guess-driven* retry: when a 4xx body names no accepted set
* there is no way to tell an effort rejection from an unrelated validation
* failure (context too long, a malformed tool schema, a bad image URL). If the
* upstream happens to answer 2xx for the probe, an unrelated failure would be
* masked and a bogus cap recorded — permanently lowering reasoning quality for
* that provider/model in this process, for a tier nothing was ever proven
* against.
*
* So it is opt-in per provider. An empty/absent `OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS`
* disables the probe everywhere, which is the default: the enum-naming path
* (#14013) already covers every upstream that cooperates, and this is only for
* gateways that answer opaquely. `*` enables it for all providers.
*/
export function reasoningEffortProbeEnabled(provider: string | null | undefined): boolean {
const raw = process.env.OMNIROUTE_REASONING_EFFORT_PROBE_PROVIDERS;
if (!raw) return false;
const entries = raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
if (entries.length === 0) return false;
if (entries.includes("*")) return true;
return provider != null && entries.includes(String(provider).toLowerCase());
}

/**
* Record that `acceptedProbe` was accepted, after a 4xx on a HIGHER tier proved
* the higher tier is refused.
*
* Only that one rung is recorded. A single accepted probe is proof about one
* value and says nothing about the tiers below it: a model may well answer
* `high` and still refuse `low` (the learned-cap tests already cover sparse
* accepted sets such as `{high,max}`), so inferring the whole
* `PROBE_FLOOR..acceptedProbe` slice would let later `low`/`medium` requests
* bypass clamping and go out unproven. Pinning just the probed tier is the
* conservative direction — the clamp then maps anything higher onto a value the
* upstream has actually acknowledged, and leaves anything lower alone.
*
* Only call this once the probe has actually succeeded: a failed probe proves
* nothing, and learning from it would pin the model to a ceiling it may well
* still support. Monotonic, like `recordLearnedReasoningEffort`.
*/
export function recordLearnedProbeReasoningEffort(
provider: string | null | undefined,
model: string | null | undefined,
acceptedProbe: string
): Set<string> | null {
const idx = REASONING_EFFORT_ORDER.indexOf(acceptedProbe);
const floorIdx = REASONING_EFFORT_ORDER.indexOf(PROBE_FLOOR);
// A probe below the floor never happens (nextProbeReasoningEffort refuses to
// step there), so treat it as a caller error rather than learning it.
if (idx < 0 || floorIdx < 0 || idx < floorIdx) return null;
return recordLearnedReasoningEffort(provider, model, [acceptedProbe]);
}

// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer
// ("expected one of `a`, `b`"), generic ("Supported types are a, b, and c"),
// and "please use a, b, or c".
Expand Down
Loading
Loading