diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index cd641e112e..8afc3b16a7 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_21_4421_node_lookup": "Issue #4421 own growth: src/lib/db/providers.ts 1050->1063 (+13 = resolveProviderNodeForConnection at the existing provider-node lookup — resolves a connection node by exact id OR the bare derived type when unambiguous, + import). Pure selection logic in the new src/lib/db/providerNodeSelect.ts (3414, src/lib/db/core.ts 1820->1825, src/lib/usage/providerLimits.ts 949->950, src/shared/constants/providers.ts 3242->3243. Reconcílio ao valor real de main p/ abrir o .33 verde; shrink estrutural rastreado em #3501.", "_rebaseline_2026_06_21_4481_websearch_routing_ui": "Feature #4481 layer 2 UI own growth: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1594->1629 (+35 = one Settings → Routing with a text bound to the `webSearchRouteModel` setting, placed next to the adjacent echo/LKGP cards; value/onChange via updateSetting, i18n labels with inline English fallbacks). Presentational wiring for the setting shipped in the same PR (#4509); the routing logic itself lives in the already-tested pure leaf open-sse/services/webSearchRouting.ts. Cohesive next to the existing routing cards; not extractable. Covered by tests/unit/web-search-tool-routing-4481.test.ts (UI source-guard + en.json key presence).", "_rebaseline_2026_06_21_4481_websearch_routing": "Feature #4481 layer 2 own growth: src/sse/handlers/chat.ts 1491->1513 (+22 = the CCR-style web-search routing hook at the request entrypoint, right after the T05 task-aware-routing block — a 5-line comment + one `if (hasNativeWebSearchTool(body)) { ... }` guard that reads getCachedSettings only when a web_search tool is present and overrides resolvedModelStr/body.model via the pure helper, plus a 4-line import). When a request carries a native web_search server tool and the operator set `webSearchRouteModel`, the whole request routes to that model instead of the default (some providers, e.g. MiniMax, don't implement Anthropic's web_search_20250305 server tool). The detection + override logic lives in the new pure leaf open-sse/services/webSearchRouting.ts (no DB, unit-testable); chat.ts is thin wiring mirroring the adjacent T05 override. Lands BEFORE auto/combo resolution + the layer-1 webSearchFallback so the target's own format/fallback handling applies. Not extractable further (it IS the entrypoint wiring). Covered by tests/unit/web-search-tool-routing-4481.test.ts. Structural shrink of this handler tracked separately.", @@ -184,7 +185,7 @@ "src/lib/db/core.ts": 1825, "src/lib/db/migrationRunner.ts": 1125, "src/lib/db/models.ts": 1259, - "src/lib/db/providers.ts": 1050, + "src/lib/db/providers.ts": 1063, "src/lib/db/proxies.ts": 1048, "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 873, diff --git a/scripts/check/check-db-rules.mjs b/scripts/check/check-db-rules.mjs index ba0c84f3e0..c310d56c6f 100644 --- a/scripts/check/check-db-rules.mjs +++ b/scripts/check/check-db-rules.mjs @@ -59,6 +59,7 @@ export const INTENTIONALLY_INTERNAL = new Set([ "obsidian", // intentionally-internal: src/lib/obsidianSync.ts + settings/obsidian route + MCP obsidianTools.ts "pluginMetrics", // DEAD? (production): write path não foi conectado ainda (documentado no cabeçalho do módulo); testado por tests/unit/plugins-metrics.test.ts "prompts", // DEAD? (production): zero callers de produção encontrados; domínio domain/prompts.ts é independente; testado por tests/integration/proxy-pipeline.test.ts + "providerNodeSelect", // db-internal: importado só por db/providers.ts (selectProviderNodeForConnection — lógica pura de seleção de provider node split do providers.ts, #4421) "providerStats", // intentionally-internal: src/app/api/provider-stats/route.ts "recovery", // intentionally-internal: bin/cli/runtime.mjs (import() dinâmico) + tests "secrets", // intentionally-internal: src/instrumentation-node.ts (import() dinâmico na inicialização) diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index cd6aef3870..7d0aad17db 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -9,7 +9,7 @@ import { createProviderConnection, deleteProviderConnections, updateProviderConnection, - getProviderNodeById, + resolveProviderNodeForConnection, isCloudEnabled, } from "@/models"; import { @@ -110,7 +110,7 @@ export async function POST(request: Request) { } if (isOpenAICompatibleProvider(provider)) { - const node: any = await getProviderNodeById(provider); + const node: any = await resolveProviderNodeForConnection(provider); if (!node) { return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); } @@ -129,7 +129,7 @@ export async function POST(request: Request) { ...(node.customHeaders ? { customHeaders: node.customHeaders } : {}), }; } else if (isAnthropicCompatibleProvider(provider)) { - const node: any = await getProviderNodeById(provider); + const node: any = await resolveProviderNodeForConnection(provider); if (!node) { return NextResponse.json( { diff --git a/src/lib/db/providerNodeSelect.ts b/src/lib/db/providerNodeSelect.ts new file mode 100644 index 0000000000..ba18323b33 --- /dev/null +++ b/src/lib/db/providerNodeSelect.ts @@ -0,0 +1,48 @@ +/** + * Pure provider-node selection helper (#4421). + * + * Kept dependency-free (no DB import) so it can be unit-tested without opening a + * SQLite connection. + */ + +export interface SelectableNode { + id?: unknown; + [key: string]: unknown; +} + +const TRAILING_UUID = + /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Recover the derived TYPE of a provider node from its id. Node ids are + * "-" (e.g. "openai-compatible-responses-1715ed0f-..."), so stripping a + * trailing UUID yields the type ("openai-compatible-responses"). Ids without a UUID + * suffix are returned unchanged. + */ +export function nodeTypeFromId(id: unknown): string { + const s = String(id ?? ""); + return TRAILING_UUID.test(s) ? s.replace(TRAILING_UUID, "") : s; +} + +/** + * Resolve the provider node a new connection should bind to, given either: + * - the concrete node id ("-", what the dashboard sends), or + * - the bare derived type ("openai-compatible-responses", what callers using the + * /api/providers API directly often pass instead — #4421). + * + * When the exact id is not present, fall back to the SOLE node whose derived type + * equals `idOrType` — but only when exactly one such node exists, so an ambiguous type + * never silently picks the wrong node (returns null instead, preserving the 404). The + * type is matched precisely (UUID-stripped), so "openai-compatible" never matches an + * "openai-compatible-responses" node. + */ +export function selectProviderNodeForConnection( + idOrType: string, + nodes: T[] +): T | null { + if (!idOrType) return null; + const exact = nodes.find((n) => n.id === idOrType); + if (exact) return exact; + const ofType = nodes.filter((n) => typeof n.id === "string" && nodeTypeFromId(n.id) === idOrType); + return ofType.length === 1 ? ofType[0] : null; +} diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 74be63c616..c14a4a3e6e 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel, cleanNulls } from "./core"; +import { selectProviderNodeForConnection } from "./providerNodeSelect"; import { backupDbFile } from "./backup"; import { encryptConnectionFields, @@ -774,6 +775,18 @@ export async function getProviderNodeById(id: string) { return row ? rowToCamel(row) : null; } +// #4421: resolve the provider node for a new connection from either its concrete id +// (what the dashboard sends, "-") OR the bare derived type (what callers +// using the /api/providers API directly often pass, e.g. "openai-compatible-responses"). +// Falls back to the sole node of that type only when unambiguous; otherwise null (so the +// caller still surfaces the existing 404). +export async function resolveProviderNodeForConnection(idOrType: string) { + const exact = await getProviderNodeById(idOrType); + if (exact) return exact; + const all = (await getProviderNodes()) as JsonRecord[]; + return selectProviderNodeForConnection(idOrType, all); +} + export async function createProviderNode(data: JsonRecord) { const db = getDbInstance() as unknown as DbLike; const now = new Date().toISOString(); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index a581e56b79..43b3c5823b 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -21,6 +21,7 @@ export { // Provider Nodes getProviderNodes, getProviderNodeById, + resolveProviderNodeForConnection, createProviderNode, updateProviderNode, deleteProviderNode, diff --git a/src/models/index.ts b/src/models/index.ts index 09147c7b60..795fe3d67f 100755 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -8,6 +8,7 @@ export { deleteProviderConnections, getProviderNodes, getProviderNodeById, + resolveProviderNodeForConnection, createProviderNode, updateProviderNode, deleteProviderNode, diff --git a/tests/unit/provider-node-select-4421.test.ts b/tests/unit/provider-node-select-4421.test.ts new file mode 100644 index 0000000000..0727ba37cd --- /dev/null +++ b/tests/unit/provider-node-select-4421.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #4421: creating a connection with `provider: "openai-compatible-responses"` (the bare +// derived type) returned 404 "OpenAI Compatible node not found" even though a node of +// that type existed — the handler did an exact-id lookup, but node ids carry a UUID +// suffix ("openai-compatible-responses-"). selectProviderNodeForConnection now +// also resolves the bare type to the sole matching node. + +const { selectProviderNodeForConnection, nodeTypeFromId } = await import( + "../../src/lib/db/providerNodeSelect.ts" +); + +const UUID = "1715ed0f-1111-2222-3333-444455556666"; +const UUID2 = "2222aaaa-1111-2222-3333-444455556666"; + +test("#4421 resolves by exact node id (dashboard path, unchanged)", () => { + const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }]; + const r = selectProviderNodeForConnection(`openai-compatible-responses-${UUID}`, nodes); + assert.equal(r?.name, "fox"); +}); + +test("#4421 resolves the bare base type to the sole matching node (direct-API path)", () => { + // Before the fix the handler called getProviderNodeById("openai-compatible-responses") + // — an exact-id lookup that returned null → 404. + const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }]; + const r = selectProviderNodeForConnection("openai-compatible-responses", nodes); + assert.equal(r?.name, "fox"); +}); + +test("#4421 returns null when the base type is ambiguous (more than one node)", () => { + const nodes = [ + { id: `openai-compatible-responses-${UUID}`, name: "a" }, + { id: `openai-compatible-responses-${UUID2}`, name: "b" }, + ]; + assert.equal(selectProviderNodeForConnection("openai-compatible-responses", nodes), null); +}); + +test("#4421 a base type does not match a more-specific node type", () => { + // "openai-compatible" (chat) must NOT resolve an "openai-compatible-responses" node. + const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }]; + assert.equal(selectProviderNodeForConnection("openai-compatible", nodes), null); +}); + +test("#4421 nodeTypeFromId strips a trailing UUID", () => { + assert.equal(nodeTypeFromId(`openai-compatible-responses-${UUID}`), "openai-compatible-responses"); + assert.equal(nodeTypeFromId(`openai-compatible-${UUID}`), "openai-compatible"); + assert.equal(nodeTypeFromId("no-uuid-here"), "no-uuid-here"); +}); + +test("#4421 returns null when no node matches", () => { + assert.equal(selectProviderNodeForConnection("anthropic-compatible", []), null); +});