Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions scripts/check/check-db-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/providers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
createProviderConnection,
deleteProviderConnections,
updateProviderConnection,
getProviderNodeById,
resolveProviderNodeForConnection,
isCloudEnabled,
} from "@/models";
import {
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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(
{
Expand Down
48 changes: 48 additions & 0 deletions src/lib/db/providerNodeSelect.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/lib/db/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/lib/localDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export {
// Provider Nodes
getProviderNodes,
getProviderNodeById,
resolveProviderNodeForConnection,
createProviderNode,
updateProviderNode,
deleteProviderNode,
Expand Down
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
deleteProviderConnections,
getProviderNodes,
getProviderNodeById,
resolveProviderNodeForConnection,
createProviderNode,
updateProviderNode,
deleteProviderNode,
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/provider-node-select-4421.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading