diff --git a/src/core/settings.ts b/src/core/settings.ts index d69103b..d495e07 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -217,12 +217,16 @@ export interface ModelsSettings { export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192; export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096; -/** One discovered custom model. `image`/`context`/`maxTokens` are detected from - * the endpoint's /models metadata; anything it omits falls back to a default. */ +/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are + * detected from the endpoint's /models metadata; anything it omits falls back + * to a default. */ export interface CustomModel { id: string; /** Accepts image input (Vision-capable). Default false (text-only). */ 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?: number; /** Max output tokens, from the endpoint. Absent → the default. */ @@ -577,6 +581,7 @@ function sanitizeCustomProvider(raw: Record): CustomProviderSet if (!id) continue; const model: CustomModel = { id }; if (rec.image === true) model.image = true; + if (rec.reasoning === true) model.reasoning = true; const context = positiveInt(rec.context); if (context) model.context = context; const maxTokens = positiveInt(rec.maxTokens); diff --git a/src/server/custom-provider.ts b/src/server/custom-provider.ts index 3d42a67..c6015aa 100644 --- a/src/server/custom-provider.ts +++ b/src/server/custom-provider.ts @@ -35,8 +35,9 @@ const customEndpointAuth: ApiKeyAuth = { }, }; -/** Map a discovered model to a pi-ai entry: image→input, context/maxTokens→ - * limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is stamped per-model. */ +/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking + * levels, context/maxTokens→limits (else CUSTOM_MODEL_DEFAULT_*). baseUrl is + * stamped per-model. */ function toPiModel(model: CustomModel, baseUrl: string): Model { return { id: model.id, @@ -44,7 +45,10 @@ function toPiModel(model: CustomModel, baseUrl: string): Model { api: "openai-completions", provider: CUSTOM_PROVIDER.id, 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"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.context ?? CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW, @@ -139,6 +143,25 @@ function detectImage(row: Record): boolean { 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): 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 * inconsistently). */ function firstPositive(row: Record, 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()) { const model: CustomModel = { id: row.id }; if (detectImage(row)) model.image = true; + if (detectReasoning(row)) model.reasoning = true; const context = firstPositive(row, CONTEXT_KEYS); if (context) model.context = context; const maxTokens = firstPositive(row, MAX_TOKEN_KEYS); diff --git a/tests/custom-endpoint.test.ts b/tests/custom-endpoint.test.ts index 7cc8e71..d1d4845 100644 --- a/tests/custom-endpoint.test.ts +++ b/tests/custom-endpoint.test.ts @@ -2,8 +2,8 @@ * Tests for the custom OpenAI-compatible endpoint (custom-endpoint wave). * Covers the pure core (normalizeBaseUrl, customProviderUsable, the sanitizer, * resolution), the deep server module (provider build + live sync + model - * discovery incl. image-modality detection), and the catalog federation row. - * No network: the discovery probe runs against a stubbed global fetch. + * discovery incl. image-modality and reasoning detection), and the catalog + * federation row. No network: the discovery probe runs against a stubbed fetch. */ import assert from "node:assert/strict"; @@ -23,6 +23,7 @@ import { syncCustomProvider, } from "../src/server/custom-provider.ts"; import { piModels } from "../src/agent/models.ts"; +import { getSupportedThinkingLevels } from "@earendil-works/pi-ai"; // ─── normalizeBaseUrl ──────────────────────────────────────────────────────── describe("normalizeBaseUrl", () => { @@ -72,6 +73,15 @@ describe("sanitizeUserSettings — customProvider", () => { assert.equal(out?.customProvider?.baseUrl, 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 ────────────────────────────────────────────────────────────── @@ -114,6 +124,19 @@ describe("buildOpenAiCompatibleProvider / syncCustomProvider", () => { 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", () => { syncCustomProvider({ baseUrl: "http://h/v1", models: [{ id: "live" }] }); assert.ok(piModels().getProvider(CUSTOM_PROVIDER.id), "provider registered"); @@ -184,6 +207,29 @@ describe("discoverModels", () => { assert.equal(byId["ctx"]!.maxTokens, 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 () => { let called = false; globalThis.fetch = (async () => {