mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user