fix(api): resolve compatible provider node by base type, not only exact id (#4421) (#4576)

* fix(api): resolve compatible provider node by base type, not only exact id (#4421)

* chore(quality): rebaseline db/providers.ts file-size 1050->1063 (#4421)

* chore(quality): mark providerNodeSelect db-internal in db-rules allowlist (#4421)

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-21 21:17:39 -03:00
committed by GitHub
parent 7ef34c2246
commit f124806372
8 changed files with 122 additions and 4 deletions

View File

@@ -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 (<cap, unit-tested); cohesive DB wiring next to getProviderNodeById, not extractable. Covered by tests/unit/provider-node-select-4421.test.ts.",
"_rebaseline_2026_06_21_v3833_cycle_open_latent_filesize": "Abertura do ciclo v3.8.33: 4 arquivos cresceram no ciclo v3.8.32 sem bump de baseline e o drift escapou do fast-path do release (check:file-size não roda nas fast-gates p/ release/*, só no PR→main full CI, e o crescimento veio de commits entre fca66c644 e o head do merge 912239f46 — ex. #4475 targetFormat). Medido em origin/main (idêntico, cherry-picks deste ciclo NÃO tocam estes 4): open-sse/services/usage.ts 3408->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 <Card> with a text <Input> 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,

View File

@@ -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)

View File

@@ -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(
{

View File

@@ -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
* "<type>-<uuid>" (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 ("<type>-<uuid>", 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<T extends SelectableNode>(
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;
}

View File

@@ -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, "<type>-<uuid>") 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();

View File

@@ -21,6 +21,7 @@ export {
// Provider Nodes
getProviderNodes,
getProviderNodeById,
resolveProviderNodeForConnection,
createProviderNode,
updateProviderNode,
deleteProviderNode,

View File

@@ -8,6 +8,7 @@ export {
deleteProviderConnections,
getProviderNodes,
getProviderNodeById,
resolveProviderNodeForConnection,
createProviderNode,
updateProviderNode,
deleteProviderNode,

View File

@@ -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-<uuid>"). 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);
});