mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
* 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:
committed by
GitHub
parent
7ce875f404
commit
2c0fd04704
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
41
open-sse/config/providers/registry/dit/index.ts
Normal file
41
open-sse/config/providers/registry/dit/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
25
open-sse/config/providers/registry/openadapter/index.ts
Normal file
25
open-sse/config/providers/registry/openadapter/index.ts
Normal 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 },
|
||||
],
|
||||
};
|
||||
44
open-sse/config/providers/registry/tokenrouter/index.ts
Normal file
44
open-sse/config/providers/registry/tokenrouter/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
}, []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
BIN
src/lib/providers/serviceKindIndex.ts
Normal file
BIN
src/lib/providers/serviceKindIndex.ts
Normal file
Binary file not shown.
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
226
tests/unit/combo-account-allowlist-3266.test.ts
Normal file
226
tests/unit/combo-account-allowlist-3266.test.ts
Normal 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());
|
||||
});
|
||||
198
tests/unit/openai-style-providers-4239-4155-3841.test.ts
Normal file
198
tests/unit/openai-style-providers-4239-4155-3841.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
81
tests/unit/provider-service-kind-filter-4240.test.ts
Normal file
81
tests/unit/provider-service-kind-filter-4240.test.ts
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user