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
29 changes: 29 additions & 0 deletions docs/ports/upstream-2526.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Port: 9router #2526 — Hide disabled provider connections in combo picker

## Source
- Upstream: `decolua/9router` PR #2526
- Fetched via: `gh pr diff 2526 -R decolua/9router`
- Preflight: `filterActiveConnections` absent on `origin/dev` (git grep empty); dev's `src/shared/utils/connectionStatus.js` held only `getStatusVariant`; combos page used raw `providersData.connections || []`. Not a duplicate.

## Behavior / adaptation
Disabled provider connections (`isActive === false`) no longer appear as combo targets on the combos dashboard page. Enabled connections, legacy rows without the `isActive` flag, and no-auth connections remain visible.

- New helper `filterActiveConnections(connections)` in `src/shared/utils/connectionStatus.js` (JSDoc-documented at the change site): non-array input → `[]`; keeps every connection except those explicitly `isActive === false`.
- `src/app/(dashboard)/dashboard/combos/page.js` wraps `providersData.connections` with the filter before `setActiveProviders`, matching the upstream diff verbatim (JS-to-JS, no adaptation needed).

## Files
- `src/shared/utils/connectionStatus.js` — added `filterActiveConnections` (+ JSDoc)
- `src/app/(dashboard)/dashboard/combos/page.js` — import + apply filter in `fetchData`
- `tests/unit/connection-status.test.js` — focused unit test (new)

## Test
`tests/unit/connection-status.test.js` covers the acceptance controls directly:
- `isActive: true` connection kept
- `isActive: false` connection hidden
- legacy row without `isActive` kept
- no-auth connections (with/without flag) kept
- mixed list filters to non-disabled only
- invalid input (`undefined`/`null`) → `[]`

## Verification
No gates run per assignment (parent orchestrator verifies once: `.omc/gate.sh`, lint, build). Doc form: this file plus JSDoc at the change site.
3 changes: 2 additions & 1 deletion src/app/(dashboard)/dashboard/combos/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CSS } from "@dnd-kit/utilities";
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, ConfirmModal, CapacityBadges, Select } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { filterActiveConnections } from "@/shared/utils/connectionStatus";
import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { aggregateComboCapabilities } from "open-sse/providers/capabilities.js";
import { translate } from "@/i18n/runtime";
Expand Down Expand Up @@ -43,7 +44,7 @@ export default function CombosPage() {
// Only LLM combos here - webSearch/webFetch combos belong to media-providers/web
if (combosRes.ok) setCombos((combosData.combos || []).filter(c => !c.kind || c.kind === "llm"));
if (providersRes.ok) {
setActiveProviders(providersData.connections || []);
setActiveProviders(filterActiveConnections(providersData.connections));
Comment thread
bloodf marked this conversation as resolved.
}
setComboStrategies(settingsData.comboStrategies || {});
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Link from "next/link";
import { Card, Button, Input, Toggle, ModelSelectModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
import { AI_PROVIDERS, MEDIA_PROVIDER_KINDS } from "@/shared/constants/providers";
import { filterActiveConnections } from "@/shared/utils/connectionStatus";

// Parse "providerId/model" or just "providerId" → { providerId, model }
function parseModelEntry(entry) {
Expand Down Expand Up @@ -72,7 +73,7 @@ export default function ComboDetailPage() {
fetch("/api/models/alias", { cache: "no-store" }),
]);
if (aliasesRes.ok) setModelAliases((await aliasesRes.json()).aliases || {});
if (connsRes.ok) setConnections((await connsRes.json()).connections || []);
if (connsRes.ok) setConnections(filterActiveConnections((await connsRes.json()).connections));
if (!comboRes.ok) { setCombo(null); setLoading(false); return; }
const c = await comboRes.json();
setCombo(c);
Expand Down
8 changes: 5 additions & 3 deletions src/shared/components/ModelSelectModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,13 @@ export default function ModelSelectModal({
? NO_AUTH_PROVIDER_IDS.filter((id) => (AI_PROVIDERS[id]?.serviceKinds || ["llm"]).includes(kindFilter))
: NO_AUTH_PROVIDER_IDS;

// Show connected providers, no-auth providers, and custom provider nodes
// Show connected providers and no-auth providers. Custom compatible-provider
// nodes are covered by activeConnectionIds (connection.provider === node id),
// so they appear only with a non-disabled connection — the parent already
// filtered disabled connections out of activeProviders (9router #2526).
const providerIdsToShow = new Set([
...activeConnectionIds, // Connected providers
...activeConnectionIds, // Connected providers (incl. active custom nodes)
Comment thread
bloodf marked this conversation as resolved.
...noAuthIds, // No-auth providers (kind-filtered)
...providerNodes.map(n => n.id), // Custom provider nodes (openai-compatible, etc.)
]);

// Sort by PROVIDER_ORDER
Expand Down
10 changes: 10 additions & 0 deletions src/shared/utils/connectionStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,13 @@ export function getStatusVariant(isActive, effectiveStatus) {
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
return "default";
}

/**
* Keep only connections eligible for pickers (combo targets, etc.).
* A connection is hidden only when explicitly disabled (`isActive === false`);
* legacy rows without the flag and no-auth connections stay visible.
*/
export function filterActiveConnections(connections) {
if (!Array.isArray(connections)) return [];
return connections.filter((connection) => connection?.isActive !== false);
}
38 changes: 38 additions & 0 deletions tests/unit/connection-status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";

import { filterActiveConnections } from "../../src/shared/utils/connectionStatus.js";

describe("filterActiveConnections", () => {
it("keeps explicitly enabled connections", () => {
const active = { id: "active", isActive: true };
expect(filterActiveConnections([active])).toEqual([active]);
});

it("hides explicitly disabled connections from the combo picker", () => {
const disabled = { id: "disabled", isActive: false };
expect(filterActiveConnections([disabled])).toEqual([]);
});

it("keeps legacy rows without the isActive flag", () => {
const legacy = { id: "legacy" };
expect(filterActiveConnections([legacy])).toEqual([legacy]);
});

it("keeps no-auth connections that are not disabled", () => {
const noAuth = { id: "noauth", authType: "none", isActive: true };
const noAuthLegacy = { id: "noauth-legacy", authType: "none" };
expect(filterActiveConnections([noAuth, noAuthLegacy])).toEqual([noAuth, noAuthLegacy]);
});

it("filters mixed lists to only non-disabled connections", () => {
const active = { id: "active", isActive: true };
const legacy = { id: "legacy" };
const disabled = { id: "disabled", isActive: false };
expect(filterActiveConnections([active, disabled, legacy])).toEqual([active, legacy]);
});

it("returns an empty list for invalid input", () => {
expect(filterActiveConnections()).toEqual([]);
expect(filterActiveConnections(null)).toEqual([]);
});
});
Loading