diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e01ff8fad..df43754a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features +- **feat(dashboard): category (media serviceKind) filter on the providers page** — `/dashboard/providers` gains a media-category filter row (Image / Video / Music / Text→Speech / Speech→Text / Embedding) that composes with the existing search, free-only and "show configured only" filters. Membership is derived from the backend media registries (a provider that serves a kind is surfaced even if it never declared `serviceKinds`), keeping the UI in lockstep with the backend. ([#4240](https://github.com/diegosouzapw/OmniRoute/issues/4240)) +- **feat(combo): per-step account allowlist — scope a round-robin/weighted step to a subset of a provider's connections** — a combo model step can now carry a first-class account allowlist so a round-robin (or weighted) strategy is scoped to a chosen subset of a provider's connections (e.g. only `foo1`+`foo2` out of `foo1..foo4`) without hand-pinning one step per account. Empty = the whole active pool (unchanged). When a step both has an allowlist and is tag-routed, the two intersect (most-restrictive wins); a single pinned account still takes precedence. The combo builder's Precision step editor gains an optional "Restrict to accounts" picker. ([#3266](https://github.com/diegosouzapw/OmniRoute/issues/3266)) +- **feat(providers): add OpenAdapter, dit.ai and TokenRouter as OpenAI-compatible providers** — three community-requested OpenAI-compatible aggregators now register as standard named OpenAI-style providers with live `/v1/models` discovery (the zenmux pattern), falling back to a seeded catalog when the upstream list is unavailable: **OpenAdapter** (`https://api.openadapter.in/v1`, free tier, 70+ open-source models — [#4239](https://github.com/diegosouzapw/OmniRoute/issues/4239)), **dit.ai** (`https://api.dit.ai/v1`, dynamic-pricing router/gateway — [#4155](https://github.com/diegosouzapw/OmniRoute/issues/4155)), and **TokenRouter** (`https://api.tokenrouter.com/v1`, free MiniMax model — [#3841](https://github.com/diegosouzapw/OmniRoute/issues/3841), thanks @FerLuisxd). No custom executor/translator — default OpenAI passthrough. - **feat(api): `x-omniroute-no-memory` request header — per-request opt-out of memory/skills injection** — clients that manage their own context (e.g. their own RAG/memory) can send `x-omniroute-no-memory: true` (mirrors the existing `x-omniroute-no-cache` convention) to skip the gateway injecting up to `memorySettings.maxTokens` (~2k) tokens of memory **and** skills context into that chat request — avoiding the token/cost inflation it otherwise adds on every call. Absent the header, behavior is unchanged. (PRD-2026-06-19-no-memory-header) - **feat(dashboard): MITM tool card lists the exact hosts-file entries to add manually** — the CLI-tools MITM card's "How it works" section now lists the full set of `127.0.0.1 ` lines for the selected tool (sourced from the canonical MITM target registry) instead of a single example domain. Users on locked-down machines — where the automatic, sudo-gated hosts-file edit isn't available — can now copy every required entry by hand. (thanks @mrcyclo) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b911cd1ec7..7ada1ec130 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_19_4313_harvest_x_sweep_reconcile": "RECONCILIACAO ao mergear #4313 (5 harvested features) APOS o provider-sweep #4324: valores MEDIDOS na arvore combinada release(com #4324)+#4313. route.ts 2580->2586 (sweep +19 NAMED_OPENAI_STYLE + #4313 +3 openadapter/dit/tokenrouter = uniao no Set, regioes disjuntas). providers.ts 3198->3242 (sweep 6 dead-provider marks + #4313 3 novas entries APIKEY_PROVIDERS). combos/page.tsx 4350->4385 (#4313 #3266 allowlist UI) e providers/page.tsx 1925->1927 (#4313 #4240 serviceKind state) — intocados pelo sweep, crescimento puro do #4313. Mesmo padrao release-volatil de medir-no-merge dos baselines de complexity/zizmor deste lote.", "_rebaseline_2026_06_19_provider_sweep_merge_reconcile": "provider-model-sweep PR merge into release/v3.8.30: the sweep's route.ts growth (NAMED_OPENAI_STYLE_PROVIDERS +19 entries, base 2538->2564) and #4259's cloudflare parseResponse (2538->2554) are disjoint regions that both land, so the merged route.ts frozen value is reconciled to its measured size here. constants/providers.ts carries the sweep's 6 dead-provider deprecation marks on top of release. chatCore.ts stays at #4266's 5128 (sweep does not touch it).", "_rebaseline_2026_06_19_provider_sweep_dead_providers": "provider-model-sweep Track C: src/shared/constants/providers.ts 3169->3198 (+29 = deprecated:true + riskNoticeVariant:\"deprecated\" + a deprecationReason for six providers the sweep confirmed dead — kluster/glhf/predibase/inclusionai/galadriel (DNS no longer resolves) + phind (API shut down 2026-01). Mirrors the existing qwen deprecation-flag pattern; pure additive metadata so the UI surfaces a deprecation notice instead of silently offering a non-working provider. Not extractable (it IS the per-provider constant data).", "_rebaseline_2026_06_19_provider_sweep_live_discovery_2": "provider-model-sweep cont.: providers/[id]/models/route.ts 2548->2564 (+16 = twelve more NAMED_OPENAI_STYLE_PROVIDERS Set entries `crof`/`featherless-ai`/`ovhcloud`/`sambanova`/`orcarouter`/`uncloseai`/`opencode-go`/`baseten`/`hyperbolic`/`nebius`/`scaleway`/`together` + a 4-line comment). Same fix shape: GPU-cloud / aggregator marketplaces hosting large volatile OSS catalogs, each with a live `/v1/models` endpoint the sweep probed (200 public or 401/403 = exists+keyed); live fetch keeps the catalog fresh, the registry seed stays as the offline fallback. Pure additive Set membership; not extractable. Covered by tests/unit/provider-sweep-live-discovery.test.ts.", @@ -114,7 +115,7 @@ "src/app/(dashboard)/dashboard/cache/page.tsx": 841, "src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": 894, "src/app/(dashboard)/dashboard/cloud-agents/page.tsx": 922, - "src/app/(dashboard)/dashboard/combos/page.tsx": 4350, + "src/app/(dashboard)/dashboard/combos/page.tsx": 4385, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1495, "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": 1007, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, @@ -129,7 +130,7 @@ "src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": 264, "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 955, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, - "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, + "src/app/(dashboard)/dashboard/providers/page.tsx": 1927, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1198, "src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": 819, "src/app/(dashboard)/dashboard/settings/components/CompressionSettingsTab.tsx": 974, @@ -143,7 +144,7 @@ "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148, "src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069, "src/app/api/oauth/[provider]/[action]/route.ts": 918, - "src/app/api/providers/[id]/models/route.ts": 2580, + "src/app/api/providers/[id]/models/route.ts": 2586, "src/app/api/providers/[id]/test/route.ts": 842, "src/app/api/usage/analytics/route.ts": 941, "src/app/api/v1/models/catalog.ts": 1478, @@ -169,7 +170,7 @@ "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, "src/shared/constants/pricing.ts": 1581, - "src/shared/constants/providers.ts": 3198, + "src/shared/constants/providers.ts": 3242, "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, "src/shared/validation/schemas.ts": 2523, diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 8ced8d5fc9..21a74252fc 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -163,6 +163,9 @@ import { veoaifree_webProvider } from "./registry/veoaifree-web/index.ts"; import { codexProvider } from "./registry/codex/index.ts"; import { veniceProvider } from "./registry/venice/index.ts"; import { kiroProvider } from "./registry/kiro/index.ts"; +import { openadapterProvider } from "./registry/openadapter/index.ts"; +import { ditProvider } from "./registry/dit/index.ts"; +import { tokenrouterProvider } from "./registry/tokenrouter/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -328,4 +331,7 @@ export const REGISTRY: Record = { byteplus: byteplusProvider, mimocode: mimocodeProvider, wafer: waferProvider, + openadapter: openadapterProvider, + dit: ditProvider, + tokenrouter: tokenrouterProvider, }; diff --git a/open-sse/config/providers/registry/dit/index.ts b/open-sse/config/providers/registry/dit/index.ts new file mode 100644 index 0000000000..64d8d2aab3 --- /dev/null +++ b/open-sse/config/providers/registry/dit/index.ts @@ -0,0 +1,41 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// dit.ai (#4155) — "Distributed Intelligence Trade", an OpenAI-compatible +// router/gateway with dynamic per-request pricing (a marketplace where provider +// channels bid and the cheapest qualified one wins). Standard named OpenAI-style +// provider, zenmux shape. No public free tier. +// +// Seed list is a fallback ONLY — the provider is in NAMED_OPENAI_STYLE_PROVIDERS +// so `/models` serves the live upstream catalog and falls back here on error. +// `gpt-5.4` / `claude-sonnet-4-6` are the example ids cited in dit.ai's dashboard +// docs. Base path confirmed live (returns a 401 OpenAI-style error body). Full +// upstream model-id list pending a live key. +export const ditProvider: RegistryEntry = { + id: "dit", + alias: "dai", + format: "openai", + executor: "default", + baseUrl: "https://api.dit.ai/v1/chat/completions", + modelsUrl: "https://api.dit.ai/v1/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 200000, + models: [ + { + id: "gpt-5.4", + name: "GPT-5.4 (DIT.ai)", + contextLength: 400000, + toolCalling: true, + supportsReasoning: true, + supportsVision: true, + }, + { + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6 (DIT.ai)", + contextLength: 200000, + toolCalling: true, + supportsReasoning: true, + supportsVision: true, + }, + ], +}; diff --git a/open-sse/config/providers/registry/openadapter/index.ts b/open-sse/config/providers/registry/openadapter/index.ts new file mode 100644 index 0000000000..6723e5ed71 --- /dev/null +++ b/open-sse/config/providers/registry/openadapter/index.ts @@ -0,0 +1,25 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// OpenAdapter (#4239) — subscription LLM gateway exposing 70+ open-source SOTA +// models through one OpenAI-compatible endpoint. API lives on `.in` (the `.dev` +// domain is marketing only). Standard named OpenAI-style provider, zenmux shape. +// +// Seed list is a fallback ONLY — the provider is in NAMED_OPENAI_STYLE_PROVIDERS +// so `/models` serves the live upstream catalog and falls back here on error. +// `glm-4.7` is the single model id cited in OpenAdapter's public docs +// (https://docs.openadapter.dev). Base path confirmed live (returns a 401 +// OpenAI-style error body). Full upstream model-id list pending a live key. +export const openadapterProvider: RegistryEntry = { + id: "openadapter", + alias: "oad", + format: "openai", + executor: "default", + baseUrl: "https://api.openadapter.in/v1/chat/completions", + modelsUrl: "https://api.openadapter.in/v1/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { id: "glm-4.7", name: "GLM 4.7 (OpenAdapter)", contextLength: 128000, toolCalling: true }, + ], +}; diff --git a/open-sse/config/providers/registry/tokenrouter/index.ts b/open-sse/config/providers/registry/tokenrouter/index.ts new file mode 100644 index 0000000000..a3c39f2644 --- /dev/null +++ b/open-sse/config/providers/registry/tokenrouter/index.ts @@ -0,0 +1,44 @@ +import type { RegistryEntry } from "../../shared.ts"; + +// TokenRouter (#3841) — OpenAI-compatible aggregator. Author @FerLuisxd confirmed +// Bearer auth, OpenAI-compatible, working `/v1/models`, and a free `minimax 3` +// model. Standard named OpenAI-style provider, zenmux shape. +// +// Seed list is a fallback ONLY — the provider is in NAMED_OPENAI_STYLE_PROVIDERS +// so `/models` serves the live upstream catalog and falls back here on error. +// `deepseek-v4-pro` / `deepseek-v4-flash` are REAL ids already used in production +// (they appear in the vision-bridge force-list added by #3946, @WormAlien); +// `minimax-3` is the free model the author cited. Base path confirmed live +// (returns a 401 OpenAI-style error body). Full upstream list pending a live key. +// +// Note: TokenRouter's deepseek models overstate vision support upstream and are +// already forced through the Vision Bridge (see visionBridgeDefaults.ts), so they +// are intentionally left without `supportsVision` here. +export const tokenrouterProvider: RegistryEntry = { + id: "tokenrouter", + alias: "trk", + format: "openai", + executor: "default", + baseUrl: "https://api.tokenrouter.com/v1/chat/completions", + modelsUrl: "https://api.tokenrouter.com/v1/models", + authType: "apikey", + authHeader: "bearer", + defaultContextLength: 128000, + models: [ + { id: "minimax-3", name: "MiniMax 3 (free, TokenRouter)", contextLength: 128000, toolCalling: true }, + { + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro (TokenRouter)", + contextLength: 163840, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash (TokenRouter)", + contextLength: 163840, + toolCalling: true, + supportsReasoning: true, + }, + ], +}; diff --git a/open-sse/services/combo/autoStrategy.ts b/open-sse/services/combo/autoStrategy.ts index fa5e918bcf..26e7759637 100644 --- a/open-sse/services/combo/autoStrategy.ts +++ b/open-sse/services/combo/autoStrategy.ts @@ -21,16 +21,9 @@ */ import { isRecord } from "./comboData.ts"; -import type { - AutoProviderCandidate, - ComboLike, - ResolvedComboTarget, -} from "./types.ts"; +import type { AutoProviderCandidate, ComboLike, ResolvedComboTarget } from "./types.ts"; import { extractSessionAffinityKey } from "@/sse/services/auth"; -import { - DEFAULT_INTENT_CONFIG, - type IntentClassifierConfig, -} from "../intentClassifier.ts"; +import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts"; import { getTaskFitness } from "../autoCombo/taskFitness.ts"; import { calculateFactors, @@ -289,9 +282,28 @@ export async function applyRequestTagRouting( return acc; } + // #3266: when a step already carries an account allowlist, intersect it with + // the tag-matched connections (most-restrictive wins). An empty intersection + // means no connection satisfies both constraints, so the target is dropped — + // the same outcome the `matchedConnectionIds.length === 0` guard above yields. + const tagMatched = Array.from(new Set(matchedConnectionIds)); + const stepAllow = Array.isArray(target.allowedConnectionIds) + ? target.allowedConnectionIds.filter( + (id): id is string => typeof id === "string" && id.length > 0 + ) + : null; + const effectiveAllow = + stepAllow && stepAllow.length > 0 + ? tagMatched.filter((id) => stepAllow.includes(id)) + : tagMatched; + + if (effectiveAllow.length === 0) { + return acc; + } + acc.push({ ...target, - allowedConnectionIds: Array.from(new Set(matchedConnectionIds)), + allowedConnectionIds: effectiveAllow, }); return acc; }, []); diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 69850e76e2..36c2c2e287 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -102,6 +102,13 @@ function normalizeRuntimeStep( provider: getTargetProvider(modelStr, step.providerId), providerId: step.providerId || null, connectionId: step.connectionId || null, + // #3266: a per-step account allowlist scopes round-robin/weighted selection + // to a subset of the provider's connections. This is the second writer of + // `allowedConnectionIds` (tag routing is the first); both feed the existing + // credential-selection filter in auth.ts. + ...(Array.isArray(step.allowedConnectionIds) && step.allowedConnectionIds.length > 0 + ? { allowedConnectionIds: step.allowedConnectionIds } + : {}), weight, label, } satisfies ResolvedComboTarget; diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index eea7fbc6d4..ce5840de69 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1950,6 +1950,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo const [builderProviderId, setBuilderProviderId] = useState(""); const [builderModelId, setBuilderModelId] = useState(""); const [builderConnectionId, setBuilderConnectionId] = useState(COMBO_BUILDER_AUTO_CONNECTION); + // #3266: optional account allowlist — scopes an auto-selecting step's round-robin + // to a subset of the provider's connections. Empty = whole active pool. + const [builderAllowedConnectionIds, setBuilderAllowedConnectionIds] = useState([]); const [manualModelInput, setManualModelInput] = useState(""); const [manualModelError, setManualModelError] = useState(""); const [builderComboRefName, setBuilderComboRefName] = useState(""); @@ -2075,6 +2078,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo ? selectedBuilderConnections.find((connection) => connection.id === builderConnectionId) || null : null; + // Defensive: only carry allowlist ids that still belong to the selected provider's + // connections, so stale ids from a previous provider can never leak into a step. + const builderEffectiveAllowedConnectionIds = builderAllowedConnectionIds.filter((id) => + selectedBuilderConnections.some((connection) => connection.id === id) + ); const builderCandidateStep = selectedBuilderProvider && selectedBuilderModel ? buildPrecisionComboModelStep({ @@ -2083,6 +2091,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo connectionId: builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null, connectionLabel: selectedBuilderConnection?.label || null, + allowedConnectionIds: builderEffectiveAllowedConnectionIds, }) : null; const builderHasDuplicate = @@ -2250,6 +2259,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setBuilderProviderId(""); setBuilderModelId(""); setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION); + setBuilderAllowedConnectionIds([]); setManualModelInput(""); setManualModelError(""); setBuilderComboRefName(""); @@ -2348,6 +2358,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo setBuilderProviderId(nextProviderId); setBuilderModelId(""); setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION); + setBuilderAllowedConnectionIds([]); + setBuilderError(""); + }; + + const handleBuilderAllowedConnectionToggle = (connectionId: string) => { + setBuilderAllowedConnectionIds((prev) => + prev.includes(connectionId) + ? prev.filter((id) => id !== connectionId) + : [...prev, connectionId] + ); setBuilderError(""); }; @@ -2395,6 +2415,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo connectionId: builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null, connectionLabel: selectedBuilderConnection?.label || null, + allowedConnectionIds: builderEffectiveAllowedConnectionIds, }); if (hasExactModelStepDuplicate(models, nextStep)) { @@ -2411,6 +2432,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo const nextModels = [...models, nextStep]; setModels(nextModels); setBuilderError(""); + setBuilderAllowedConnectionIds([]); setBuilderConnectionId( findNextSuggestedConnectionId( nextModels, @@ -3166,6 +3188,46 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo + {builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION && + selectedBuilderConnections.length > 1 ? ( +
+ +
+ {selectedBuilderConnections.map((connection) => { + const checked = builderAllowedConnectionIds.includes(connection.id); + return ( + + ); + })} +
+

+ {getI18nOrFallback( + t, + "builderRestrictAccountsHint", + "Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts." + )} +

+
+ ) : null} + {isExpertMode ? (
+ +
+ + {providerText(t, "filterByMedia", "Media")} + + {SERVICE_KIND_CHIPS.map((chip) => { + const isActive = activeServiceKind === chip.key; + return ( + + ); + })} + {activeServiceKind && ( + + )} +
); diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index a70038fb39..731457bfb5 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -187,6 +187,9 @@ export default function ProvidersPage() { const [modelSearchQuery, setModelSearchQuery] = useState(""); const [showFreeOnly, setShowFreeOnly] = useState(false); const [activeCategory, setActiveCategory] = useState(null); + // #4240: media-category (serviceKind) filter — composes with activeCategory, + // search and configured-only. null = no serviceKind filter. + const [activeServiceKind, setActiveServiceKind] = useState(null); const notify = useNotificationStore(); const hasSearchQuery = searchQuery.trim().length > 0 || modelSearchQuery.trim().length > 0; const sectionCategoryAliases: Record = { @@ -523,7 +526,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const blockedProviderSet = new Set(blockedProviders); @@ -537,7 +541,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats); @@ -554,7 +559,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId) @@ -564,7 +570,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) @@ -574,7 +581,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) @@ -584,7 +592,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId) @@ -594,7 +603,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) @@ -604,7 +614,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); @@ -613,7 +624,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats); @@ -622,7 +634,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats); @@ -631,7 +644,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats); @@ -640,7 +654,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats); @@ -649,7 +664,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); @@ -658,7 +674,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const compatibleProviderEntriesAll = [ @@ -689,7 +706,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const staticProviderEntriesAll = dedupeProviderEntries([ @@ -713,7 +731,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, undefined, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); // IDE providers: subset of oauth/apikey providers that are editors/IDEs with @@ -727,7 +746,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const oauthOnlyEntriesAll = oauthProviderEntriesAll @@ -746,7 +766,8 @@ export default function ProvidersPage() { effectiveShowConfiguredOnly, searchQuery, showFreeOnly, - modelSearchQuery + modelSearchQuery, + activeServiceKind ); const compactProviderEntries = buildCompactProviderEntriesForPage({ @@ -835,6 +856,8 @@ export default function ProvidersPage() { ( showConfiguredOnly: boolean, searchQuery?: string, showFreeOnly?: boolean, - modelSearchQuery?: string + modelSearchQuery?: string, + serviceKindFilter?: string | null ): ProviderEntry[] { let filtered = entries; + // #4240: category (serviceKind) filter — keep providers whose declared OR + // registry-derived serviceKinds include the selected kind. Composes with the + // configured-only / free / search predicates below. + if (serviceKindFilter) { + filtered = filtered.filter((entry) => { + const declared = (entry.provider as { serviceKinds?: string[] }).serviceKinds; + return providerHasServiceKind(entry.providerId, declared, serviceKindFilter); + }); + } + if (showConfiguredOnly) { // no-auth providers never create a DB connection row (stats.total === 0) but // are always usable and appear unconditionally in the /v1/models catalog, so diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 2e317fc03e..447def5a48 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -155,6 +155,12 @@ const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ // was unclassified, so import served the 5-entry hardcoded catalog instead of the // live `https://ai-gateway.vercel.sh/v1/models` list. Falls back to local on error. "vercel-ai-gateway", + // #4239 / #4155 / #3841: OpenAI-compatible aggregators whose real catalog lives + // on the upstream `/v1/models` list — serve it live, fall back to the seeded + // registry catalog on error (same case as zenmux). + "openadapter", + "dit", + "tokenrouter", // provider-model-sweep (2026-06-19): same class as #3976/#4202/#4249 — keyed // openai-style providers with a real live `/models` catalog, served // their small hardcoded seed because unclassified. Seed stays as offline fallback. diff --git a/src/lib/combos/builderDraft.ts b/src/lib/combos/builderDraft.ts index fe51b616a1..31ec199281 100644 --- a/src/lib/combos/builderDraft.ts +++ b/src/lib/combos/builderDraft.ts @@ -61,18 +61,31 @@ export function buildPrecisionComboModelStep({ modelId, connectionId = null, connectionLabel, + allowedConnectionIds = null, weight = 0, }: { providerId: string; modelId: string; connectionId?: string | null; connectionLabel?: string | null; + /** #3266: account allowlist scoping round-robin to a subset of connections. */ + allowedConnectionIds?: string[] | null; weight?: number; }): ComboModelStep { const normalizedProviderId = toTrimmedString(providerId) || "provider"; const normalizedModelId = toTrimmedString(modelId) || "model"; const normalizedConnectionId = toTrimmedString(connectionId); const normalizedConnectionLabel = toTrimmedString(connectionLabel); + // A pinned single connection wins over an allowlist, so only carry the allowlist + // when the step is auto-selecting (no forced connectionId). + const normalizedAllowed = + !normalizedConnectionId && Array.isArray(allowedConnectionIds) + ? Array.from( + new Set( + allowedConnectionIds.map((id) => toTrimmedString(id)).filter((id): id is string => !!id) + ) + ) + : []; return { kind: "model", @@ -80,6 +93,7 @@ export function buildPrecisionComboModelStep({ model: `${normalizedProviderId}/${normalizedModelId}`, ...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}), ...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}), + ...(normalizedAllowed.length > 0 ? { allowedConnectionIds: normalizedAllowed } : {}), weight: Number.isFinite(weight) ? Math.max(0, Math.min(100, Number(weight))) : 0, }; } diff --git a/src/lib/combos/steps.ts b/src/lib/combos/steps.ts index 33f08e58a5..1487ddfa79 100644 --- a/src/lib/combos/steps.ts +++ b/src/lib/combos/steps.ts @@ -8,6 +8,12 @@ export interface ComboModelStep { model: string; providerId?: string | null; connectionId?: string | null; + /** + * Account allowlist (#3266): scope this step's round-robin/weighted selection + * to a subset of the provider's connections. Empty/absent = whole active pool. + * Reuses the `allowedConnectionIds` plumbing tag routing already populates. + */ + allowedConnectionIds?: string[] | null; weight: number; label?: string; tags?: string[]; @@ -277,6 +283,11 @@ export function normalizeComboStep( const tags = Array.isArray(value.tags) ? value.tags.map((tag) => toTrimmedString(tag)).filter((tag): tag is string => !!tag) : undefined; + const allowedConnectionIds = Array.isArray(value.allowedConnectionIds) + ? value.allowedConnectionIds + .map((connId) => toTrimmedString(connId)) + .filter((connId): connId is string => !!connId) + : undefined; return { id: @@ -289,6 +300,7 @@ export function normalizeComboStep( weight, ...(label ? { label } : {}), ...(tags && tags.length > 0 ? { tags } : {}), + ...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}), }; } diff --git a/src/lib/providers/serviceKindIndex.ts b/src/lib/providers/serviceKindIndex.ts new file mode 100644 index 0000000000..3f33e592bb Binary files /dev/null and b/src/lib/providers/serviceKindIndex.ts differ diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 32a86de86d..453a32e513 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -30,6 +30,9 @@ export const PROVIDER_ENDPOINTS = { "minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages", crof: "https://crof.ai/v1/chat/completions", zenmux: "https://zenmux.ai/api/v1/chat/completions", + openadapter: "https://api.openadapter.in/v1/chat/completions", + dit: "https://api.dit.ai/v1/chat/completions", + tokenrouter: "https://api.tokenrouter.com/v1/chat/completions", openai: "https://api.openai.com/v1/chat/completions", anthropic: "https://api.anthropic.com/v1/messages", gemini: "https://generativelanguage.googleapis.com/v1beta/models", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index ce92edf14d..3e8825beea 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -2401,6 +2401,50 @@ export const APIKEY_PROVIDERS = { apiHint: "ZenMux exposes an OpenAI-compatible chat completions endpoint at /api/v1/chat/completions, plus Anthropic Messages (/api/anthropic/v1/messages) and Google Gemini (/api/vertex-ai) protocol surfaces. OmniRoute uses the OpenAI protocol.", }, + openadapter: { + id: "openadapter", + alias: "oad", + name: "OpenAdapter", + icon: "hub", + color: "#10B981", + textIcon: "OD", + website: "https://openadapter.dev", + hasFree: true, + freeNote: + "Free tier with a generous quota and no credit card — 15+ open-source models with daily quota. Get your API key at https://dashboard.openadapter.in.", + authHint: + "Use your OpenAdapter API key in Authorization: Bearer sk-cv-. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1.", + apiHint: + "OpenAdapter exposes an OpenAI-compatible chat completions endpoint at https://api.openadapter.in/v1/chat/completions, aggregating 70+ open-source models (DeepSeek, Qwen, Kimi, MiniMax, GLM, Llama, Mistral, …). OmniRoute uses the OpenAI protocol.", + }, + dit: { + id: "dit", + alias: "dai", + name: "DIT.ai", + icon: "hub", + color: "#0EA5E9", + textIcon: "DT", + website: "https://dit.ai", + authHint: + "Use your dit.ai API key in Authorization: Bearer . Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1.", + apiHint: + "dit.ai (Distributed Intelligence Trade) is an OpenAI-compatible router/gateway with dynamic per-request pricing, exposing /v1/chat/completions at https://api.dit.ai/v1. OmniRoute uses the OpenAI protocol; spend/savings analytics live in the dit.ai dashboard.", + }, + tokenrouter: { + id: "tokenrouter", + alias: "trk", + name: "TokenRouter", + icon: "hub", + color: "#F59E0B", + textIcon: "TK", + website: "https://tokenrouter.com", + hasFree: true, + freeNote: "Free tier includes the MiniMax 3 model. Get your API key at https://tokenrouter.com.", + authHint: + "Use your TokenRouter API key in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1.", + apiHint: + "TokenRouter exposes an OpenAI-compatible chat completions endpoint at https://api.tokenrouter.com/v1/chat/completions, plus a working /v1/models catalog. OmniRoute uses the OpenAI protocol.", + }, }; // Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views). diff --git a/tests/unit/combo-account-allowlist-3266.test.ts b/tests/unit/combo-account-allowlist-3266.test.ts new file mode 100644 index 0000000000..53ad377944 --- /dev/null +++ b/tests/unit/combo-account-allowlist-3266.test.ts @@ -0,0 +1,226 @@ +/** + * #3266 — Per-combo account allowlist. + * + * A combo model step can carry a first-class `allowedConnectionIds` so a + * round-robin / weighted strategy is scoped to a subset of a provider's + * connections (e.g. {foo1, foo2}) without hand-pinning one step per account. + * + * Acceptance (owner): a round-robin scoped to {foo1, foo2} over an active pool + * {foo1..foo4} never selects foo3/foo4 on real chat requests. + * + * Coverage: + * 1. steps.ts parses `allowedConnectionIds` (trim + drop empty). + * 2. handleComboChat propagates the step allowlist onto target.allowedConnectionIds. + * 3. getProviderCredentials never hands back a connection outside the allowlist. + */ +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-combo-allowlist-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); +const { normalizeComboStep } = await import("../../src/lib/combos/steps.ts"); +const { buildPrecisionComboModelStep } = await import("../../src/lib/combos/builderDraft.ts"); + +function createLog() { + return { info() {}, warn() {}, debug() {}, error() {} }; +} + +function okResponse(content: string) { + return new Response(JSON.stringify({ choices: [{ message: { content } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConn(name: string, tags?: string[]) { + return providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name, + apiKey: `sk-${name}`, + isActive: true, + ...(tags ? { providerSpecificData: { tags } } : {}), + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── 1. Schema parse ───────────────────────────────────────────────────────── + +test("normalizeComboStep parses allowedConnectionIds (trim + drop empty)", () => { + const step = normalizeComboStep({ + kind: "model", + model: "openai/gpt-4o-mini", + allowedConnectionIds: [" foo1 ", "foo2", "", " "], + }); + assert.ok(step && step.kind === "model"); + assert.deepEqual((step as { allowedConnectionIds?: string[] }).allowedConnectionIds, [ + "foo1", + "foo2", + ]); +}); + +test("normalizeComboStep omits allowedConnectionIds when absent or all-empty", () => { + const bare = normalizeComboStep({ kind: "model", model: "openai/gpt-4o-mini" }); + assert.ok(bare && bare.kind === "model"); + assert.equal((bare as { allowedConnectionIds?: string[] }).allowedConnectionIds, undefined); + + const emptyish = normalizeComboStep({ + kind: "model", + model: "openai/gpt-4o-mini", + allowedConnectionIds: ["", " "], + }); + assert.equal((emptyish as { allowedConnectionIds?: string[] }).allowedConnectionIds, undefined); +}); + +test("buildPrecisionComboModelStep carries an allowlist when auto-selecting (#3266)", () => { + const step = buildPrecisionComboModelStep({ + providerId: "openai", + modelId: "gpt-4o-mini", + connectionId: null, + allowedConnectionIds: [" a ", "b", "", "b"], + }); + // trims, drops empty, dedupes + assert.deepEqual((step as { allowedConnectionIds?: string[] }).allowedConnectionIds, ["a", "b"]); +}); + +test("buildPrecisionComboModelStep drops the allowlist when a single account is pinned (#3266)", () => { + const step = buildPrecisionComboModelStep({ + providerId: "openai", + modelId: "gpt-4o-mini", + connectionId: "pinned-1", + allowedConnectionIds: ["a", "b"], + }); + // a forced connectionId wins — allowlist is meaningless and must not be carried + assert.equal(step.connectionId, "pinned-1"); + assert.equal((step as { allowedConnectionIds?: string[] }).allowedConnectionIds, undefined); +}); + +// ── 2. Propagation onto the resolved combo target ─────────────────────────── + +test("handleComboChat propagates a step allowlist onto target.allowedConnectionIds (#3266)", async () => { + const foo1 = await seedConn("foo1"); + const foo2 = await seedConn("foo2"); + await seedConn("foo3"); + await seedConn("foo4"); + + let captured: string[] | null = null; + const response = await handleComboChat({ + body: { model: "rr", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "rr", + strategy: "round-robin", + models: [ + { + kind: "model", + model: "openai/gpt-4o-mini", + allowedConnectionIds: [foo1.id, foo2.id], + }, + ], + }, + handleSingleModel: async ( + _body: unknown, + modelStr: string, + target: { allowedConnectionIds?: unknown } + ) => { + captured = Array.isArray(target?.allowedConnectionIds) ? target.allowedConnectionIds : null; + return okResponse(modelStr); + }, + log: createLog(), + }); + + assert.equal(response.status, 200); + assert.ok(captured, "target.allowedConnectionIds must be populated from the step"); + assert.deepEqual([...captured!].sort(), [foo1.id, foo2.id].sort()); +}); + +// ── 3. Acceptance: the credential selector never escapes the allowlist ─────── + +test("getProviderCredentials never selects a connection outside the step allowlist (#3266)", async () => { + const foo1 = await seedConn("foo1"); + const foo2 = await seedConn("foo2"); + const foo3 = await seedConn("foo3"); + const foo4 = await seedConn("foo4"); + const allowed = [foo1.id, foo2.id]; + const forbidden = new Set([foo3.id, foo4.id]); + + const seen = new Set(); + for (let i = 0; i < 24; i++) { + const cred = (await getProviderCredentials("openai", null, allowed)) as { + connectionId?: string; + } | null; + assert.ok(cred && cred.connectionId, "a credential within the allowlist must be returned"); + assert.ok( + allowed.includes(cred!.connectionId!), + `selected ${cred!.connectionId} which is outside the allowlist` + ); + assert.ok(!forbidden.has(cred!.connectionId!), "must never select foo3/foo4"); + seen.add(cred!.connectionId!); + } + // Both allowed accounts should be reachable (round-robin spreads across the subset). + assert.ok(seen.size >= 1 && [...seen].every((id) => allowed.includes(id))); +}); + +// ── 4. Step allowlist + tag routing → most-restrictive (intersection) ─────── + +test("a step allowlist intersects with tag routing — most-restrictive wins (#3266)", async () => { + await seedConn("foo1", ["us"]); + const foo2 = await seedConn("foo2", ["eu"]); + const foo3 = await seedConn("foo3", ["eu"]); + await seedConn("foo4", ["us"]); + + let captured: string[] | null = null; + const response = await handleComboChat({ + body: { + model: "rr", + messages: [{ role: "user", content: "hi" }], + metadata: { tags: ["eu"] }, + }, + combo: { + name: "rr", + strategy: "priority", + models: [ + { + kind: "model", + model: "openai/gpt-4o-mini", + // allowlist excludes foo4; tags=[eu] match foo2+foo3 → intersection {foo2,foo3} + allowedConnectionIds: [foo2.id, foo3.id, "nonexistent-but-allowed"], + }, + ], + }, + handleSingleModel: async ( + _body: unknown, + modelStr: string, + target: { allowedConnectionIds?: unknown } + ) => { + captured = Array.isArray(target?.allowedConnectionIds) ? target.allowedConnectionIds : null; + return okResponse(modelStr); + }, + log: createLog(), + }); + + assert.equal(response.status, 200); + assert.ok(captured, "target.allowedConnectionIds must be the intersection"); + assert.deepEqual([...captured!].sort(), [foo2.id, foo3.id].sort()); +}); diff --git a/tests/unit/openai-style-providers-4239-4155-3841.test.ts b/tests/unit/openai-style-providers-4239-4155-3841.test.ts new file mode 100644 index 0000000000..aad137e22a --- /dev/null +++ b/tests/unit/openai-style-providers-4239-4155-3841.test.ts @@ -0,0 +1,198 @@ +/** + * Batch coverage for three named OpenAI-style aggregator providers harvested in + * the v3.8.30 cycle — all follow the zenmux (PR #4202) shape: + * - #4239 OpenAdapter (https://api.openadapter.in/v1) + * - #4155 dit.ai (https://api.dit.ai/v1) + * - #3841 TokenRouter (https://api.tokenrouter.com/v1) + * + * Each is registered in APIKEY_PROVIDERS + PROVIDER_ENDPOINTS + the modular + * registry, and added to NAMED_OPENAI_STYLE_PROVIDERS so `/models` serves the + * live upstream catalog (falling back to the seeded list when the fetch fails). + * + * NOTE (Rule #18): the base paths were confirmed live (each returns a 401 + * OpenAI-style error body, i.e. the endpoint exists and requires a Bearer key). + * The exact upstream model-id list could not be fetched without a key, so the + * seed lists below are intentionally minimal — populated only from author/doc + * confirmed ids (TokenRouter deepseek ids come from production via #3946). Live + * discovery via NAMED_OPENAI_STYLE_PROVIDERS is the source of truth at runtime. + */ +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 { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); + +interface ProviderSpec { + id: string; + alias: string; + name: string; + website: string; + chatUrl: string; + modelsUrl: string; + seedSample: string; +} + +const SPECS: ProviderSpec[] = [ + { + id: "openadapter", + alias: "oad", + name: "OpenAdapter", + website: "https://openadapter.dev", + chatUrl: "https://api.openadapter.in/v1/chat/completions", + modelsUrl: "https://api.openadapter.in/v1/models", + seedSample: "glm-4.7", + }, + { + id: "dit", + alias: "dai", + name: "DIT.ai", + website: "https://dit.ai", + chatUrl: "https://api.dit.ai/v1/chat/completions", + modelsUrl: "https://api.dit.ai/v1/models", + seedSample: "gpt-5.4", + }, + { + id: "tokenrouter", + alias: "trk", + name: "TokenRouter", + website: "https://tokenrouter.com", + chatUrl: "https://api.tokenrouter.com/v1/chat/completions", + modelsUrl: "https://api.tokenrouter.com/v1/models", + seedSample: "deepseek-v4-pro", + }, +]; + +for (const spec of SPECS) { + test(`${spec.name} is registered as an API-key provider with the canonical identity`, () => { + const entry = APIKEY_PROVIDERS[spec.id]; + assert.ok(entry, `APIKEY_PROVIDERS.${spec.id} must be defined`); + assert.equal(entry.id, spec.id); + assert.equal(entry.alias, spec.alias); + assert.equal(entry.name, spec.name); + assert.equal(entry.website, spec.website); + assert.equal(typeof entry.textIcon, "string"); + }); + + test(`${spec.name} exposes the OpenAI-compatible chat completions URL`, () => { + assert.equal(PROVIDER_ENDPOINTS[spec.id], spec.chatUrl); + }); + + test(`${spec.name} registry entry uses OpenAI format with bearer apikey auth`, () => { + const entry = providerRegistry[spec.id]; + assert.ok(entry, `providerRegistry.${spec.id} must be defined`); + assert.equal(entry.id, spec.id); + assert.equal(entry.alias, spec.alias); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, spec.chatUrl); + assert.equal(entry.modelsUrl, spec.modelsUrl); + }); + + test(`${spec.name} ships a non-empty unique seed catalog including ${spec.seedSample}`, () => { + const models = providerRegistry[spec.id].models; + const ids = models.map((m: { id: string }) => m.id); + assert.ok(ids.length >= 1, "seed list must be non-empty for the fallback path"); + assert.equal(new Set(ids).size, ids.length, "model ids must be unique"); + assert.ok(ids.includes(spec.seedSample), `seed list must include ${spec.seedSample}`); + }); +} + +// ── Live /models discovery + fallback (the NAMED_OPENAI_STYLE_PROVIDERS branch) ── + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-providers-batch-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +interface ModelsBody { + provider: string; + models: Array<{ id: string }>; + source?: string; +} + +for (const spec of SPECS) { + test(`#${spec.id} import fetches the live ${spec.modelsUrl} catalog`, async () => { + resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: spec.id, + authType: "apikey", + name: `${spec.id}-live`, + apiKey: `${spec.alias}-key`, + }); + + let fetched = false; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + if (String(url) === spec.modelsUrl) { + fetched = true; + return Response.json({ + object: "list", + data: [{ id: "live-only-model-xyz" }, { id: spec.seedSample }], + }); + } + return new Response("not found", { status: 404 }); + }; + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.provider, spec.id); + assert.equal(body.source, "api", "should serve the live upstream catalog"); + assert.ok(fetched, `should have probed ${spec.modelsUrl}`); + const ids = body.models.map((m) => m.id); + assert.ok(ids.includes("live-only-model-xyz"), `live model missing: ${ids.join(",")}`); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test(`#${spec.id} import falls back to the local seed catalog when the live fetch fails`, async () => { + resetStorage(); + const connection = await providersDb.createProviderConnection({ + provider: spec.id, + authType: "apikey", + name: `${spec.id}-fallback`, + apiKey: `${spec.alias}-key-2`, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response("bad gateway", { status: 502 }); + + try { + const response = await modelsRoute.GET( + new Request(`http://localhost/api/providers/${connection.id}/models?refresh=true`), + { params: { id: connection.id } } + ); + assert.equal(response.status, 200); + const body = (await response.json()) as ModelsBody; + assert.equal(body.provider, spec.id); + assert.equal(body.source, "local_catalog", "import must not break when upstream is down"); + assert.ok(body.models.length > 0, "fallback catalog should be non-empty"); + } finally { + globalThis.fetch = originalFetch; + } + }); +} diff --git a/tests/unit/provider-service-kind-filter-4240.test.ts b/tests/unit/provider-service-kind-filter-4240.test.ts new file mode 100644 index 0000000000..11781e2088 --- /dev/null +++ b/tests/unit/provider-service-kind-filter-4240.test.ts @@ -0,0 +1,81 @@ +/** + * #4240 — Category (serviceKind) filter on /dashboard/providers. + * + * filterConfiguredProviderEntries gains a `serviceKindFilter` argument that keeps + * only providers whose serviceKinds — declared OR registry-derived (image/video/ + * music/tts/stt/embedding) — include the selected kind, composing with the + * existing configured-only / free / search predicates. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { filterConfiguredProviderEntries } = + await import("../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts"); +const { getProviderServiceKinds } = await import("../../src/lib/providers/serviceKindIndex.ts"); + +type Entry = { + providerId: string; + provider: { name: string; serviceKinds?: string[] }; + stats: { total: number }; + displayAuthType: "apikey"; + toggleAuthType: "apikey"; +}; + +function entry(providerId: string, name: string, total: number): Entry { + return { + providerId, + provider: { name }, + stats: { total }, + displayAuthType: "apikey", + toggleAuthType: "apikey", + }; +} + +// vertex → video/music, haiper → video, openai → image/embedding, suno → music +// (all registry-derived; none declare serviceKinds explicitly here) +const ENTRIES: Entry[] = [ + entry("vertex", "Vertex AI", 1), + entry("haiper", "Haiper", 0), + entry("openai", "OpenAI", 1), + entry("suno", "Suno", 1), +]; + +function ids(list: Entry[]): string[] { + return list.map((e) => e.providerId).sort(); +} + +test("serviceKindFilter keeps only providers whose (registry-derived) kinds include it", () => { + const out = filterConfiguredProviderEntries(ENTRIES, false, "", false, "", "video"); + assert.deepEqual(ids(out), ["haiper", "vertex"]); +}); + +test("a registry-derived kind is resolved even with no declared serviceKinds (#4240)", () => { + assert.ok(getProviderServiceKinds("vertex", undefined).includes("video")); + assert.ok(getProviderServiceKinds("openai", undefined).includes("image")); + // a pure declared kind still works through the union + assert.ok(getProviderServiceKinds("openai", ["llm"]).includes("llm")); +}); + +test("serviceKindFilter composes with showConfiguredOnly (intersection)", () => { + // video → {vertex, haiper}; configured-only drops haiper (stats.total === 0) + const out = filterConfiguredProviderEntries(ENTRIES, true, "", false, "", "video"); + assert.deepEqual(ids(out), ["vertex"]); +}); + +test("serviceKindFilter composes with the search query (intersection)", () => { + // video → {vertex, haiper}; search "haip" narrows to haiper only + const out = filterConfiguredProviderEntries(ENTRIES, false, "haip", false, "", "video"); + assert.deepEqual(ids(out), ["haiper"]); +}); + +test("a null/undefined serviceKindFilter leaves the list unchanged", () => { + const out = filterConfiguredProviderEntries(ENTRIES, false, "", false, "", null); + assert.deepEqual(ids(out), ids(ENTRIES)); + const out2 = filterConfiguredProviderEntries(ENTRIES, false, "", false, ""); + assert.deepEqual(ids(out2), ids(ENTRIES)); +}); + +test("serviceKindFilter=music keeps only music providers", () => { + const out = filterConfiguredProviderEntries(ENTRIES, false, "", false, "", "music"); + assert.deepEqual(ids(out), ["suno", "vertex"]); +});