feat: thinking levels for custom models (reasoning detected from /models metadata)
Custom models were pinned reasoning:false, so every one of them offered a
single thinking level ("off") while built-in reasoning models offered the
standard ladder. The endpoint's /models metadata now decides:
- detectReasoning() trusts explicit metadata only — a boolean
reasoning / supports_reasoning / thinking, or a reasoning token in
supported_parameters (vLLM, OpenRouter), capabilities, tags or
architecture.output_modalities. No name guessing: a false positive would
send reasoning_effort to a server that rejects it.
- CustomModel carries the detected flag (persisted, sanitizer keeps it),
toPiModel passes it through, and pi-ai does the rest — default
off/minimal/low/medium/high set, reasoning_effort on the wire.
Verified in the browser against a local OpenAI-compatible mock: the
reasoning-capable model exposes off/minimal/low/medium/high, the plain one
stays at off, and a prompt with medium actually arrives as
reasoning_effort=medium.
This commit is contained in:
@@ -217,12 +217,16 @@ export interface ModelsSettings {
|
|||||||
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||||
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||||
|
|
||||||
/** One discovered custom model. `image`/`context`/`maxTokens` are detected from
|
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
|
||||||
* the endpoint's /models metadata; anything it omits falls back to a default. */
|
* detected from the endpoint's /models metadata; anything it omits falls back
|
||||||
|
* to a default. */
|
||||||
export interface CustomModel {
|
export interface CustomModel {
|
||||||
id: string;
|
id: string;
|
||||||
/** Accepts image input (Vision-capable). Default false (text-only). */
|
/** Accepts image input (Vision-capable). Default false (text-only). */
|
||||||
image?: boolean;
|
image?: boolean;
|
||||||
|
/** Accepts a reasoning effort (thinking). Default false → the only
|
||||||
|
* thinking level is "off", exactly like a non-reasoning built-in. */
|
||||||
|
reasoning?: boolean;
|
||||||
/** Context window in tokens, from the endpoint. Absent → the default. */
|
/** Context window in tokens, from the endpoint. Absent → the default. */
|
||||||
context?: number;
|
context?: number;
|
||||||
/** Max output tokens, from the endpoint. Absent → the default. */
|
/** Max output tokens, from the endpoint. Absent → the default. */
|
||||||
@@ -577,6 +581,7 @@ function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSet
|
|||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
const model: CustomModel = { id };
|
const model: CustomModel = { id };
|
||||||
if (rec.image === true) model.image = true;
|
if (rec.image === true) model.image = true;
|
||||||
|
if (rec.reasoning === true) model.reasoning = true;
|
||||||
const context = positiveInt(rec.context);
|
const context = positiveInt(rec.context);
|
||||||
if (context) model.context = context;
|
if (context) model.context = context;
|
||||||
const maxTokens = positiveInt(rec.maxTokens);
|
const maxTokens = positiveInt(rec.maxTokens);
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ const customEndpointAuth: ApiKeyAuth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Map a discovered model to a pi-ai entry: image→input, context/maxTokens→
|
/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
|
||||||
* limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is stamped per-model. */
|
* levels, context/maxTokens→limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is
|
||||||
|
* stamped per-model. */
|
||||||
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
||||||
return {
|
return {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
@@ -44,7 +45,10 @@ function toPiModel(model: CustomModel, baseUrl: string): Model<Api> {
|
|||||||
api: "openai-completions",
|
api: "openai-completions",
|
||||||
provider: CUSTOM_PROVIDER.id,
|
provider: CUSTOM_PROVIDER.id,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
reasoning: false,
|
// Detected from the endpoint's metadata (see detectReasoning). true makes
|
||||||
|
// pi-ai offer the standard levels and send reasoning_effort on the wire;
|
||||||
|
// no thinkingLevelMap → the default off/minimal/low/medium/high set.
|
||||||
|
reasoning: Boolean(model.reasoning),
|
||||||
input: model.image ? ["text", "image"] : ["text"],
|
input: model.image ? ["text", "image"] : ["text"],
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||||
@@ -139,6 +143,25 @@ function detectImage(row: Record<string, unknown>): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Does a /models row advertise reasoning? Metadata only — no name guessing,
|
||||||
|
* because a false positive sends reasoning_effort an endpoint will reject.
|
||||||
|
* Trusted signals: an explicit boolean (reasoning / supports_reasoning /
|
||||||
|
* thinking) or a reasoning token in the capability/parameter lists servers
|
||||||
|
* publish (vLLM + OpenRouter's supported_parameters, capabilities, tags).
|
||||||
|
* Absent or unrecognized → false (thinking stays "off"). */
|
||||||
|
function detectReasoning(row: Record<string, unknown>): boolean {
|
||||||
|
for (const key of ["reasoning", "supports_reasoning", "thinking"] as const) {
|
||||||
|
if (row[key] === true) return true;
|
||||||
|
}
|
||||||
|
const architecture = isRecord(row.architecture) ? row.architecture : undefined;
|
||||||
|
const candidates = [row.supported_parameters, row.capabilities, row.tags, architecture?.output_modalities];
|
||||||
|
return candidates.some(
|
||||||
|
(list) =>
|
||||||
|
Array.isArray(list) &&
|
||||||
|
list.some((v) => typeof v === "string" && (/reasoning/i.test(v) || v.toLowerCase() === "thinking")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** First positive integer among `keys` on a row (endpoints name limits
|
/** First positive integer among `keys` on a row (endpoints name limits
|
||||||
* inconsistently). */
|
* inconsistently). */
|
||||||
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
function firstPositive(row: Record<string, unknown>, keys: readonly string[]): number | undefined {
|
||||||
@@ -167,6 +190,7 @@ function extractModels(body: unknown): CustomModel[] | null {
|
|||||||
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
|
if (isRecord(row) && typeof row.id === "string" && row.id.trim()) {
|
||||||
const model: CustomModel = { id: row.id };
|
const model: CustomModel = { id: row.id };
|
||||||
if (detectImage(row)) model.image = true;
|
if (detectImage(row)) model.image = true;
|
||||||
|
if (detectReasoning(row)) model.reasoning = true;
|
||||||
const context = firstPositive(row, CONTEXT_KEYS);
|
const context = firstPositive(row, CONTEXT_KEYS);
|
||||||
if (context) model.context = context;
|
if (context) model.context = context;
|
||||||
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
|
const maxTokens = firstPositive(row, MAX_TOKEN_KEYS);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave).
|
* Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave).
|
||||||
* Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer,
|
* Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer,
|
||||||
* resolution), the deep server module (provider build + live sync + model
|
* resolution), the deep server module (provider build + live sync + model
|
||||||
* discovery incl. image-modality detection), and the catalog federation row.
|
* discovery incl. image-modality and reasoning detection), and the catalog
|
||||||
* No network: the discovery probe runs against a stubbed global fetch.
|
* federation row. No network: the discovery probe runs against a stubbed fetch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
syncCustomProvider,
|
syncCustomProvider,
|
||||||
} from "../src/server/custom-provider.ts";
|
} from "../src/server/custom-provider.ts";
|
||||||
import { piModels } from "../src/agent/models.ts";
|
import { piModels } from "../src/agent/models.ts";
|
||||||
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
||||||
|
|
||||||
// ─── normalizeBaseUrl ────────────────────────────────────────────────────────
|
// ─── normalizeBaseUrl ────────────────────────────────────────────────────────
|
||||||
describe("normalizeBaseUrl", () => {
|
describe("normalizeBaseUrl", () => {
|
||||||
@@ -72,6 +73,15 @@ describe("sanitizeUserSettings — customProvider", () => {
|
|||||||
assert.equal(out?.customProvider?.baseUrl, undefined);
|
assert.equal(out?.customProvider?.baseUrl, undefined);
|
||||||
assert.equal(out?.customProvider?.models, undefined);
|
assert.equal(out?.customProvider?.models, undefined);
|
||||||
});
|
});
|
||||||
|
it("keeps reasoning:true and drops a non-true reasoning flag", () => {
|
||||||
|
const out = sanitizeUserSettings({
|
||||||
|
customProvider: {
|
||||||
|
baseUrl: "https://h/v1",
|
||||||
|
models: [{ id: "thinker", reasoning: true }, { id: "plain", reasoning: "yes" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(out?.customProvider?.models, [{ id: "thinker", reasoning: true }, { id: "plain" }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── resolution ──────────────────────────────────────────────────────────────
|
// ─── resolution ──────────────────────────────────────────────────────────────
|
||||||
@@ -114,6 +124,19 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => {
|
|||||||
assert.equal(big!.maxTokens, 393216);
|
assert.equal(big!.maxTokens, 393216);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives a reasoning-capable model the standard thinking levels (others stay \"off\")", () => {
|
||||||
|
const provider = buildOpenAiCompatibleProvider({
|
||||||
|
baseUrl: "http://h/v1",
|
||||||
|
models: [{ id: "thinker", reasoning: true }, { id: "plain" }],
|
||||||
|
});
|
||||||
|
const [thinker, plain] = provider.getModels();
|
||||||
|
assert.equal(thinker!.reasoning, true);
|
||||||
|
// No thinkingLevelMap → pi-ai's default set, same as any reasoning model.
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(thinker!), ["off", "minimal", "low", "medium", "high"]);
|
||||||
|
assert.equal(plain!.reasoning, false);
|
||||||
|
assert.deepEqual(getSupportedThinkingLevels(plain!), ["off"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("registers on a usable sync and removes when the base URL clears", () => {
|
it("registers on a usable sync and removes when the base URL clears", () => {
|
||||||
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
|
syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] });
|
||||||
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
|
assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered");
|
||||||
@@ -184,6 +207,29 @@ describe("discoverModels", () => {
|
|||||||
assert.equal(byId["ctx"]!.maxTokens, undefined);
|
assert.equal(byId["ctx"]!.maxTokens, undefined);
|
||||||
assert.equal(byId["plain"]!.context, undefined);
|
assert.equal(byId["plain"]!.context, undefined);
|
||||||
});
|
});
|
||||||
|
it("detects reasoning from endpoint metadata only — never from the model name", async () => {
|
||||||
|
stubFetch(() => ({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: [
|
||||||
|
{ id: "vllm-reasoner", supported_parameters: ["temperature", "reasoning", "reasoning_effort"] },
|
||||||
|
{ id: "bool-reasoner", reasoning: true },
|
||||||
|
{ id: "thinking-flag", thinking: true },
|
||||||
|
{ id: "cap-reasoner", capabilities: ["completion", "reasoning"] },
|
||||||
|
{ id: "deepseek-r1-distill" }, // a reasoning-sounding NAME, no metadata → stays off
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const r = await discoverModels("http://h/v1");
|
||||||
|
assert.ok(r.ok);
|
||||||
|
const byId = Object.fromEntries(r.models.map((m) => [m.id, Boolean(m.reasoning)]));
|
||||||
|
assert.equal(byId["vllm-reasoner"], true);
|
||||||
|
assert.equal(byId["bool-reasoner"], true);
|
||||||
|
assert.equal(byId["thinking-flag"], true);
|
||||||
|
assert.equal(byId["cap-reasoner"], true);
|
||||||
|
assert.equal(byId["deepseek-r1-distill"], false, "name guessing would risk sending reasoning_effort to a server that rejects it");
|
||||||
|
});
|
||||||
|
|
||||||
it("guards the scheme before any fetch", async () => {
|
it("guards the scheme before any fetch", async () => {
|
||||||
let called = false;
|
let called = false;
|
||||||
globalThis.fetch = (async () => {
|
globalThis.fetch = (async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user