feat: implement 5 harvested feature requests (#4239, #4155, #3841, #3266, #4240) (#4313)

* feat(providers): add OpenAdapter, dit.ai and TokenRouter OpenAI-compatible providers (#4239, #4155, #3841)

Three community-requested OpenAI-compatible aggregators register as standard
named OpenAI-style providers (the zenmux pattern): live /v1/models discovery via
NAMED_OPENAI_STYLE_PROVIDERS, falling back to a seeded catalog on upstream error.
No custom executor/translator — default OpenAI passthrough.

- OpenAdapter  https://api.openadapter.in/v1  (free tier)            #4239
- dit.ai       https://api.dit.ai/v1          (dynamic-pricing)      #4155
- TokenRouter  https://api.tokenrouter.com/v1 (free MiniMax model)   #3841

Base paths confirmed live (each returns a 401 OpenAI-style error body). Seed
catalogs are intentionally minimal (author/doc-cited ids only; TokenRouter
deepseek ids come from production via #3946); full upstream model lists arrive
through live discovery once a key is configured.

* feat(combo): per-step account allowlist for round-robin over a connection subset (#3266)

A combo model step can now 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.

- steps.ts: parse `allowedConnectionIds` on the model step (trim + drop empty)
- comboStructure.ts: second writer — propagate the step allowlist onto the
  resolved target (tag routing is the first writer)
- autoStrategy.ts: when a step allowlist AND tag routing both apply, intersect
  them (most-restrictive wins); empty intersection drops the target
- builderDraft.ts + combos UI: optional 'Restrict to accounts' picker in the
  Precision step editor (a pinned single account still takes precedence)

The downstream credential-selection filter (auth.ts) already honours
allowedConnectionIds, so a round-robin scoped to {foo1, foo2} provably never
selects foo3/foo4 (regression test included). Ships the enhancement only; the
#2829 bug-triage half stays open pending the reporter.

* feat(dashboard): category (media serviceKind) filter on the providers page (#4240)

Add a media-category filter row (Image / Video / Music / Text→Speech /
Speech→Text / Embedding) to /dashboard/providers that composes with the existing
search, free-only and 'show configured only' filters.

- serviceKindIndex.ts: client-side resolver unioning a provider's declared
  serviceKinds with the registry-derived media kinds (memoised)
- providerPageUtils: filterConfiguredProviderEntries gains a serviceKindFilter
  argument; threaded through every provider section on the page
- ProviderSummaryCard: a second chip row drives the serviceKind filter

Membership is derived from the backend media registries, so a provider that
serves a kind is surfaced even when it never declared serviceKinds — keeping the
UI in lockstep with the backend (mirrors the media-providers pages).

* chore(quality): rebaseline file-size for the v3.8.30 harvested features

Four frozen files grew from their own additive feature wiring (#4239/#4155/#3841
providers, #3266 combo allowlist UI, #4240 serviceKind filter):
- src/shared/constants/providers.ts 3169->3213 (3 provider entries)
- src/app/api/providers/[id]/models/route.ts 2554->2560 (3 NAMED set entries)
- src/app/(dashboard)/dashboard/combos/page.tsx 4350->4385 (allowlist picker)
- src/app/(dashboard)/dashboard/providers/page.tsx 1925->1927 (serviceKind state)

All cohesive additive wiring at existing chokepoints; rationale recorded in the
_rebaseline_2026_06_19_v3830_harvest_features key.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 21:49:27 -03:00
committed by GitHub
parent 7ce875f404
commit 2c0fd04704
21 changed files with 904 additions and 33 deletions

View File

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

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_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 `<baseUrl>/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,

View File

@@ -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<string, RegistryEntry> = {
aimlapi: aimlapiProvider,
@@ -328,4 +331,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
byteplus: byteplusProvider,
mimocode: mimocodeProvider,
wafer: waferProvider,
openadapter: openadapterProvider,
dit: ditProvider,
tokenrouter: tokenrouterProvider,
};

View File

@@ -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,
},
],
};

View File

@@ -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 },
],
};

View File

@@ -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,
},
],
};

View File

@@ -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;
}, []);

View File

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

View File

@@ -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<string[]>([]);
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
</div>
</div>
{builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION &&
selectedBuilderConnections.length > 1 ? (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccounts",
"Restrict to accounts (optional)"
)}
</label>
<div className="flex flex-wrap gap-1.5" data-testid="combo-builder-allowlist">
{selectedBuilderConnections.map((connection) => {
const checked = builderAllowedConnectionIds.includes(connection.id);
return (
<button
type="button"
key={connection.id}
onClick={() => handleBuilderAllowedConnectionToggle(connection.id)}
aria-pressed={checked}
className={`text-[11px] px-2 py-1 rounded border transition-colors ${
checked
? "border-primary bg-primary/10 text-primary"
: "border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40"
}`}
>
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
</button>
);
})}
</div>
<p className="text-[10px] text-text-muted mt-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccountsHint",
"Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts."
)}
</p>
</div>
) : null}
{isExpertMode ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button

View File

@@ -33,6 +33,8 @@ export interface ProviderSummaryStats {
interface ProviderSummaryCardProps {
activeCategory: string | null;
activeServiceKind: string | null;
onServiceKindChange(kind: string | null): void;
disabledConfigured: boolean;
displayMode: ProviderDisplayMode;
modelSearchQuery: string;
@@ -68,8 +70,25 @@ function providerText(
return fallback;
}
const SERVICE_KIND_CHIPS: Array<{ key: string; icon: string; labelKey: string; fallback: string }> =
[
{ key: "image", icon: "image", labelKey: "serviceKindImage", fallback: "Image" },
{ key: "video", icon: "videocam", labelKey: "serviceKindVideo", fallback: "Video" },
{ key: "music", icon: "music_note", labelKey: "serviceKindMusic", fallback: "Music" },
{ key: "tts", icon: "record_voice_over", labelKey: "serviceKindTts", fallback: "Text→Speech" },
{ key: "stt", icon: "hearing", labelKey: "serviceKindStt", fallback: "Speech→Text" },
{
key: "embedding",
icon: "scatter_plot",
labelKey: "serviceKindEmbedding",
fallback: "Embedding",
},
];
export default function ProviderSummaryCard({
activeCategory,
activeServiceKind,
onServiceKindChange,
disabledConfigured,
displayMode,
modelSearchQuery,
@@ -228,6 +247,38 @@ export default function ProviderSummaryCard({
);
})}
</div>
<div className="border-t border-border pt-3 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-text-muted mr-1">
{providerText(t, "filterByMedia", "Media")}
</span>
{SERVICE_KIND_CHIPS.map((chip) => {
const isActive = activeServiceKind === chip.key;
return (
<button
key={chip.key}
onClick={() => onServiceKindChange(isActive ? null : chip.key)}
aria-pressed={isActive}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs font-medium transition-colors ${
isActive
? "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30"
}`}
>
<span className="material-symbols-outlined text-[14px]">{chip.icon}</span>
<span>{providerText(t, chip.labelKey, chip.fallback)}</span>
</button>
);
})}
{activeServiceKind && (
<button
onClick={() => onServiceKindChange(null)}
className="text-[11px] text-text-muted hover:text-text-primary underline-offset-2 hover:underline"
>
{providerText(t, "clearMediaFilter", "Clear")}
</button>
)}
</div>
</div>
</Card>
);

View File

@@ -187,6 +187,9 @@ export default function ProvidersPage() {
const [modelSearchQuery, setModelSearchQuery] = useState("");
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
// #4240: media-category (serviceKind) filter — composes with activeCategory,
// search and configured-only. null = no serviceKind filter.
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
const notify = useNotificationStore();
const hasSearchQuery = searchQuery.trim().length > 0 || modelSearchQuery.trim().length > 0;
const sectionCategoryAliases: Record<string, string> = {
@@ -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() {
<ProviderSummaryCard
activeCategory={activeCategory}
activeServiceKind={activeServiceKind}
onServiceKindChange={setActiveServiceKind}
disabledConfigured={connections.length === 0}
displayMode={effectiveProviderDisplayMode}
modelSearchQuery={modelSearchQuery}

View File

@@ -8,6 +8,7 @@ import {
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
import { getModelsByProviderId } from "@/shared/constants/models";
import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex";
import { compareTr, matchesSearch } from "@/shared/utils/turkishText";
import type { ProviderDisplayMode } from "./providerPageStorage";
@@ -114,10 +115,21 @@ export function filterConfiguredProviderEntries<TProvider>(
showConfiguredOnly: boolean,
searchQuery?: string,
showFreeOnly?: boolean,
modelSearchQuery?: string
modelSearchQuery?: string,
serviceKindFilter?: string | null
): ProviderEntry<TProvider>[] {
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

View File

@@ -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 `<baseUrl>/models` catalog, served
// their small hardcoded seed because unclassified. Seed stays as offline fallback.

View File

@@ -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,
};
}

View File

@@ -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 } : {}),
};
}

Binary file not shown.

View File

@@ -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",

View File

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

View File

@@ -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<string>();
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());
});

View File

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

View File

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