From 094ce0fd54550706cc1d64189cb3ea56973cdebb Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 10 Oct 2026 17:26:02 -0300 Subject: [PATCH] fix(providers): keep upstream reasoning efforts in OpenAI-like model discovery (#15762) (#15815) Batch-merged with the owner's 2026-10-10 hold/backlog PR wave (/merge-batch). Validated in one combined tree of 43 boarded PRs on top of release/v3.8.52 (048d9e2f): typecheck:core, check:open-sse-typecheck, check-deps, check-test-discovery, changelog-integrity, complexity + cognitive ratchets, check:docs-all, i18n UI coverage, ESLint on the 142 changed source files - green; 58 changed node:test files 584/586 (2 timing flakes in video-retention-http-12150 that pass in isolation) and 3 vitest files 116/116. File-size overruns in that tree belong only to PRs held out of this wave (#16110, #16112, #16140, #15790). Warning: base-red inherited: #15306. --- .../15762-openai-like-reasoning-efforts.md | 1 + .../[id]/models/discovery/normalizers.ts | 23 +++++- ...ai-like-normalizer-reasoning-15762.test.ts | 31 +++++++ tests/unit/openference-sync-e2e-15762.test.ts | 80 +++++++++++++++++++ 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/15762-openai-like-reasoning-efforts.md create mode 100644 tests/unit/openai-like-normalizer-reasoning-15762.test.ts create mode 100644 tests/unit/openference-sync-e2e-15762.test.ts diff --git a/changelog.d/fixes/15762-openai-like-reasoning-efforts.md b/changelog.d/fixes/15762-openai-like-reasoning-efforts.md new file mode 100644 index 0000000000..6935206aa2 --- /dev/null +++ b/changelog.d/fixes/15762-openai-like-reasoning-efforts.md @@ -0,0 +1 @@ +- fix(providers): keep upstream reasoning.supported_efforts when syncing named OpenAI-style providers so /v1/models exposes effort_tiers (#15762) diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index 5737f329b1..a6b923c7fb 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -240,6 +240,27 @@ export function normalizeDataRobotCatalogResponse( .filter((value): value is { id: string; name: string } => Boolean(value)); } +// Upstream fields that declare supported reasoning efforts (#15762). Only these are carried +// through so the sync extractor sees them; spreading the whole record would alter other flows. +const OPENAI_LIKE_EFFORT_DECLARATION_KEYS = [ + "reasoning", + "supported_reasoning_levels", + "thinking", + "metadata", + "capabilities", + "supportedThinkingEfforts", + "reasoning_options", + "vendors", +] as const; + +function pickEffortDeclarations(item: Record): Record { + const picked: Record = {}; + for (const key of OPENAI_LIKE_EFFORT_DECLARATION_KEYS) { + if (item[key] !== undefined && item[key] !== null) picked[key] = item[key]; + } + return picked; +} + export function normalizeOpenAiLikeModelsResponse( data: unknown, fallbackOwner: string @@ -266,7 +287,7 @@ export function normalizeOpenAiLikeModelsResponse( id; const ownedBy = toNonEmptyString(item.owned_by) || toNonEmptyString(item.provider) || fallbackOwner; - return { id, name, owned_by: ownedBy }; + return { ...pickEffortDeclarations(item), id, name, owned_by: ownedBy }; }) .filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value)); } diff --git a/tests/unit/openai-like-normalizer-reasoning-15762.test.ts b/tests/unit/openai-like-normalizer-reasoning-15762.test.ts new file mode 100644 index 0000000000..0aa0a36f53 --- /dev/null +++ b/tests/unit/openai-like-normalizer-reasoning-15762.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { normalizeOpenAiLikeModelsResponse } = + await import("../../src/app/api/providers/[id]/models/discovery/normalizers.ts"); + +test("normalizeOpenAiLikeModelsResponse keeps reasoning effort declarations (#15762)", () => { + const out = normalizeOpenAiLikeModelsResponse( + { + data: [ + { + id: "GLM-5.3", + reasoning: { supported: true, supported_efforts: ["max", "high", "low"] }, + context_length: 1234, + pricing: { prompt: "1" }, + }, + { id: "plain" }, + ], + }, + "owner" + ) as Array>; + assert.deepEqual((out[0].reasoning as { supported_efforts: string[] }).supported_efforts, [ + "max", + "high", + "low", + ]); + assert.equal(out[0].owned_by, "owner"); + assert.equal("context_length" in out[0], false); + assert.equal("pricing" in out[0], false); + assert.deepEqual(Object.keys(out[1]).sort(), ["id", "name", "owned_by"]); +}); diff --git a/tests/unit/openference-sync-e2e-15762.test.ts b/tests/unit/openference-sync-e2e-15762.test.ts new file mode 100644 index 0000000000..f75d824a78 --- /dev/null +++ b/tests/unit/openference-sync-e2e-15762.test.ts @@ -0,0 +1,80 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-15762e2e-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.CATALOG_BUILD_TIMEOUT_MS = "120000"; // cold catalog build can exceed 8s on loaded CI hosts +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret-15762"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const syncModelsRoute = await import("../../src/app/api/providers/[id]/sync-models/route.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { buildModelSyncInternalHeaders } = + await import("../../src/shared/services/modelSyncScheduler.ts"); + +const originalFetch = globalThis.fetch; +test.after(() => { + globalThis.fetch = originalFetch; + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +const UPSTREAM = { + object: "list", + data: [ + { + id: "GLM-5.3", + object: "model", + capabilities: { tool_calling: true, vision: false, attachment: false }, + reasoning: { supported: true, control: "effort", supported_efforts: ["max", "high", "low"] }, + }, + ], +}; + +for (const [provider, authType] of [ + ["openference", "oauth"], + ["openference-api", "apikey"], +] as const) { + test(`${provider}: sync-models then /v1/models exposes effort_tiers`, async () => { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); + const conn = await providersDb.createProviderConnection({ + provider, + authType, + name: `${provider}-e2e`, + ...(authType === "oauth" ? { accessToken: "jwt" } : { apiKey: "sk-x" }), + isActive: true, + testStatus: "active", + providerSpecificData: { autoFetchModels: true }, + }); + const calls: string[] = []; + globalThis.fetch = async (url: unknown) => { + const u = String(url); + calls.push(u); + if (u.includes("api.openference.com/v1/models")) return Response.json(UPSTREAM); + if (u.includes("__readiness_probe__")) return new Response("", { status: 404 }); + throw new Error("force in-process fallback: " + u); + }; + const res = await syncModelsRoute.POST( + new Request(`http://127.0.0.1/api/providers/${conn.id}/sync-models`, { + method: "POST", + headers: buildModelSyncInternalHeaders(), + }), + { params: Promise.resolve({ id: conn.id }) } + ); + assert.equal(res.status, 200); + globalThis.fetch = originalFetch; + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { + data: Array<{ id: string; capabilities?: { effort_tiers?: string[] } }>; + }; + const glm = body.data.filter((m) => m.id.endsWith("/GLM-5.3")); + assert.ok(glm.length > 0); + assert.deepEqual(glm[0].capabilities?.effort_tiers, ["max", "high", "low"]); + }); +}