Files
OmniRoute/open-sse/services/reasoningTokenBuffer.ts
Praveen K Palaniswamy 65e81158ab fix(ollama): route models by advertised capability (#11088)
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
2026-08-23 11:45:01 -03:00

128 lines
5.1 KiB
TypeScript

import {
getExplicitModelOutputCap,
getResolvedModelCapabilities,
} from "../../src/lib/modelCapabilities.ts";
/**
* Below this caller-supplied `max_tokens`, the request is treated as a probe
* (e.g. Claude Code's `/model` capability check sends `max_tokens: 1`) rather
* than a genuine reasoning budget, so no headroom is added. Keeping it a named
* constant makes the threshold easy to tune. See issue #6274 (probe inflated to
* 1001 upstream) vs. issue #3587 (headroom for real reasoning budgets).
*/
export const REASONING_BUFFER_MIN_TRIGGER = 256;
export function toPositiveInteger(value: unknown): number | null {
const numericValue =
typeof value === "number"
? value
: typeof value === "string" && value.trim() !== ""
? Number(value)
: null;
if (numericValue === null || !Number.isFinite(numericValue)) return null;
const normalized = Math.floor(numericValue);
return normalized > 0 ? normalized : null;
}
export function resolveReasoningBufferedMaxTokens(
modelStr: string,
currentMaxTokens: unknown,
options: { enabled?: boolean } = {}
): number | null {
if (options.enabled === false) return null;
const current = toPositiveInteger(currentMaxTokens);
if (current === null) return null;
const capabilities = getResolvedModelCapabilities(modelStr);
if (capabilities.supportsThinking !== true) return null;
const maxOutputTokens = toPositiveInteger(getExplicitModelOutputCap(modelStr));
if (maxOutputTokens === null) return null;
if (current > maxOutputTokens) return maxOutputTokens;
// Issue #6274: a tiny explicit budget is a capability probe, not a reasoning
// request. Respect it verbatim instead of inflating (e.g. 1 -> 1001).
if (current < REASONING_BUFFER_MIN_TRIGGER) return current;
// Issue #9507: never enlarge a client's explicit max_tokens. The #3587
// headroom heuristic (Math.ceil(current * 1.5)) silently rewrote reasoning
// budgets upward (64000 -> 96000 on claude-opus-5), violating the #1761
// contract that upward adjustment must be opt-in. The over-cap clamp above
// (line 42) already narrows, and the model's own output cap is the only
// legitimate ceiling; any headroom beyond the client-declared value is a
// silent cost increase the client did not authorize.
return current;
}
/**
* A tiny-budget reasoning probe is a request with an explicit `max_tokens`
* below REASONING_BUFFER_MIN_TRIGGER targeting a reasoning-capable model — e.g.
* Claude Code's `/model` capability check sends `max_tokens: 1`. Reasoning
* models burn the whole probe on thinking, so the upstream produces no visible
* content; some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the
* non-streaming probe with an HTTP 5xx (`"empty response content"`) instead of
* a truncated 200. See #10281.
*/
export function isTinyBudgetReasoningProbe(opts: { model: string; body: unknown }): boolean {
const body = (opts.body ?? {}) as Record<string, unknown>;
const maxTokens = toPositiveInteger(body.max_tokens ?? body.max_completion_tokens);
if (maxTokens === null || maxTokens >= REASONING_BUFFER_MIN_TRIGGER) return false;
const capabilities = getResolvedModelCapabilities(opts.model);
return capabilities.supportsThinking === true;
}
/**
* Upstream failure markers that describe the "model reasoned but produced no
* visible content" outcome (e.g. `{"error":{"message":"empty response content"}}`).
*/
const EMPTY_CONTENT_FAILURE_RE =
/empty(\s+response)?\s+content|no\s+(usable\s+)?content|reasoning\s+consumed/i;
/**
* True when the upstream failure is a 5xx describing the empty-content outcome
* of a reasoning probe rather than a genuine provider outage. Combined with
* `isTinyBudgetReasoningProbe`, false positives are not practical (a real 5xx
* carrying these markers on a tiny-budget reasoning request is this exact case).
*/
export function isEmptyContentUpstreamFailure(statusCode: number, message: string): boolean {
if (!Number.isFinite(statusCode) || statusCode < 500 || statusCode >= 600) return false;
return EMPTY_CONTENT_FAILURE_RE.test(String(message || ""));
}
/**
* Build a valid truncated OpenAI chat.completion response (200, empty content,
* `finish_reason: "length"`) used to answer a tiny-budget reasoning probe whose
* upstream answered the empty outcome with a 5xx. Mirrors the semantics OmniRoute
* already grants to `finish_reason: "length"` empty 200s (errorClassifier.ts).
*/
export function buildReasoningProbeTruncatedResponse(opts: {
model: string;
maxTokens: number | null;
requestId: string;
}): Response {
const maxTokens = opts.maxTokens ?? 1;
const body = {
id: `chatcmpl-${opts.requestId}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: opts.model,
choices: [
{
index: 0,
message: { role: "assistant", content: "" },
finish_reason: "length",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: maxTokens,
total_tokens: maxTokens,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
}