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

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

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

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

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

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

A combo model step can now carry a first-class `allowedConnectionIds` so a
round-robin / weighted strategy is scoped to a subset of a provider's
connections (e.g. {foo1, foo2}) without hand-pinning one step per account.

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

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

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

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

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

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

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

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

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

View File

@@ -1950,6 +1950,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
const [builderProviderId, setBuilderProviderId] = useState("");
const [builderModelId, setBuilderModelId] = useState("");
const [builderConnectionId, setBuilderConnectionId] = useState(COMBO_BUILDER_AUTO_CONNECTION);
// #3266: optional account allowlist — scopes an auto-selecting step's round-robin
// to a subset of the provider's connections. Empty = whole active pool.
const [builderAllowedConnectionIds, setBuilderAllowedConnectionIds] = useState<string[]>([]);
const [manualModelInput, setManualModelInput] = useState("");
const [manualModelError, setManualModelError] = useState("");
const [builderComboRefName, setBuilderComboRefName] = useState("");
@@ -2075,6 +2078,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
? selectedBuilderConnections.find((connection) => connection.id === builderConnectionId) ||
null
: null;
// Defensive: only carry allowlist ids that still belong to the selected provider's
// connections, so stale ids from a previous provider can never leak into a step.
const builderEffectiveAllowedConnectionIds = builderAllowedConnectionIds.filter((id) =>
selectedBuilderConnections.some((connection) => connection.id === id)
);
const builderCandidateStep =
selectedBuilderProvider && selectedBuilderModel
? buildPrecisionComboModelStep({
@@ -2083,6 +2091,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
connectionId:
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
})
: null;
const builderHasDuplicate =
@@ -2250,6 +2259,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
setBuilderProviderId("");
setBuilderModelId("");
setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION);
setBuilderAllowedConnectionIds([]);
setManualModelInput("");
setManualModelError("");
setBuilderComboRefName("");
@@ -2348,6 +2358,16 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
setBuilderProviderId(nextProviderId);
setBuilderModelId("");
setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION);
setBuilderAllowedConnectionIds([]);
setBuilderError("");
};
const handleBuilderAllowedConnectionToggle = (connectionId: string) => {
setBuilderAllowedConnectionIds((prev) =>
prev.includes(connectionId)
? prev.filter((id) => id !== connectionId)
: [...prev, connectionId]
);
setBuilderError("");
};
@@ -2395,6 +2415,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
connectionId:
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
});
if (hasExactModelStepDuplicate(models, nextStep)) {
@@ -2411,6 +2432,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
const nextModels = [...models, nextStep];
setModels(nextModels);
setBuilderError("");
setBuilderAllowedConnectionIds([]);
setBuilderConnectionId(
findNextSuggestedConnectionId(
nextModels,
@@ -3166,6 +3188,46 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</div>
</div>
{builderConnectionId === COMBO_BUILDER_AUTO_CONNECTION &&
selectedBuilderConnections.length > 1 ? (
<div className="mt-2 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccounts",
"Restrict to accounts (optional)"
)}
</label>
<div className="flex flex-wrap gap-1.5" data-testid="combo-builder-allowlist">
{selectedBuilderConnections.map((connection) => {
const checked = builderAllowedConnectionIds.includes(connection.id);
return (
<button
type="button"
key={connection.id}
onClick={() => handleBuilderAllowedConnectionToggle(connection.id)}
aria-pressed={checked}
className={`text-[11px] px-2 py-1 rounded border transition-colors ${
checked
? "border-primary bg-primary/10 text-primary"
: "border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40"
}`}
>
{pickDisplayValue([connection.label], emailsVisible, connection.label)}
</button>
);
})}
</div>
<p className="text-[10px] text-text-muted mt-1.5">
{getI18nOrFallback(
t,
"builderRestrictAccountsHint",
"Leave empty to use the whole active pool. When selected, round-robin / weighted picks stay within this subset of accounts."
)}
</p>
</div>
) : null}
{isExpertMode ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<Button

View File

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

View File

@@ -187,6 +187,9 @@ export default function ProvidersPage() {
const [modelSearchQuery, setModelSearchQuery] = useState("");
const [showFreeOnly, setShowFreeOnly] = useState(false);
const [activeCategory, setActiveCategory] = useState<string | null>(null);
// #4240: media-category (serviceKind) filter — composes with activeCategory,
// search and configured-only. null = no serviceKind filter.
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
const notify = useNotificationStore();
const hasSearchQuery = searchQuery.trim().length > 0 || modelSearchQuery.trim().length > 0;
const sectionCategoryAliases: Record<string, string> = {
@@ -523,7 +526,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const blockedProviderSet = new Set(blockedProviders);
@@ -537,7 +541,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats);
@@ -554,7 +559,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
AGGREGATOR_PROVIDER_IDS.has(entry.providerId)
@@ -564,7 +570,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId)
@@ -574,7 +581,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId)
@@ -584,7 +592,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
VIDEO_PROVIDER_IDS.has(entry.providerId)
@@ -594,7 +603,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) =>
EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId)
@@ -604,7 +614,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats);
@@ -613,7 +624,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats);
@@ -622,7 +634,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats);
@@ -631,7 +644,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats);
@@ -640,7 +654,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats);
@@ -649,7 +664,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats);
@@ -658,7 +674,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const compatibleProviderEntriesAll = [
@@ -689,7 +706,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const staticProviderEntriesAll = dedupeProviderEntries([
@@ -713,7 +731,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
undefined,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
// IDE providers: subset of oauth/apikey providers that are editors/IDEs with
@@ -727,7 +746,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const oauthOnlyEntriesAll = oauthProviderEntriesAll
@@ -746,7 +766,8 @@ export default function ProvidersPage() {
effectiveShowConfiguredOnly,
searchQuery,
showFreeOnly,
modelSearchQuery
modelSearchQuery,
activeServiceKind
);
const compactProviderEntries = buildCompactProviderEntriesForPage({
@@ -835,6 +856,8 @@ export default function ProvidersPage() {
<ProviderSummaryCard
activeCategory={activeCategory}
activeServiceKind={activeServiceKind}
onServiceKindChange={setActiveServiceKind}
disabledConfigured={connections.length === 0}
displayMode={effectiveProviderDisplayMode}
modelSearchQuery={modelSearchQuery}

View File

@@ -8,6 +8,7 @@ import {
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
import { getModelsByProviderId } from "@/shared/constants/models";
import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex";
import { compareTr, matchesSearch } from "@/shared/utils/turkishText";
import type { ProviderDisplayMode } from "./providerPageStorage";
@@ -114,10 +115,21 @@ export function filterConfiguredProviderEntries<TProvider>(
showConfiguredOnly: boolean,
searchQuery?: string,
showFreeOnly?: boolean,
modelSearchQuery?: string
modelSearchQuery?: string,
serviceKindFilter?: string | null
): ProviderEntry<TProvider>[] {
let filtered = entries;
// #4240: category (serviceKind) filter — keep providers whose declared OR
// registry-derived serviceKinds include the selected kind. Composes with the
// configured-only / free / search predicates below.
if (serviceKindFilter) {
filtered = filtered.filter((entry) => {
const declared = (entry.provider as { serviceKinds?: string[] }).serviceKinds;
return providerHasServiceKind(entry.providerId, declared, serviceKindFilter);
});
}
if (showConfiguredOnly) {
// no-auth providers never create a DB connection row (stats.total === 0) but
// are always usable and appear unconditionally in the /v1/models catalog, so

View File

@@ -155,6 +155,12 @@ const NAMED_OPENAI_STYLE_PROVIDERS = new Set([
// was unclassified, so import served the 5-entry hardcoded catalog instead of the
// live `https://ai-gateway.vercel.sh/v1/models` list. Falls back to local on error.
"vercel-ai-gateway",
// #4239 / #4155 / #3841: OpenAI-compatible aggregators whose real catalog lives
// on the upstream `/v1/models` list — serve it live, fall back to the seeded
// registry catalog on error (same case as zenmux).
"openadapter",
"dit",
"tokenrouter",
// provider-model-sweep (2026-06-19): same class as #3976/#4202/#4249 — keyed
// openai-style providers with a real live `<baseUrl>/models` catalog, served
// their small hardcoded seed because unclassified. Seed stays as offline fallback.

View File

@@ -61,18 +61,31 @@ export function buildPrecisionComboModelStep({
modelId,
connectionId = null,
connectionLabel,
allowedConnectionIds = null,
weight = 0,
}: {
providerId: string;
modelId: string;
connectionId?: string | null;
connectionLabel?: string | null;
/** #3266: account allowlist scoping round-robin to a subset of connections. */
allowedConnectionIds?: string[] | null;
weight?: number;
}): ComboModelStep {
const normalizedProviderId = toTrimmedString(providerId) || "provider";
const normalizedModelId = toTrimmedString(modelId) || "model";
const normalizedConnectionId = toTrimmedString(connectionId);
const normalizedConnectionLabel = toTrimmedString(connectionLabel);
// A pinned single connection wins over an allowlist, so only carry the allowlist
// when the step is auto-selecting (no forced connectionId).
const normalizedAllowed =
!normalizedConnectionId && Array.isArray(allowedConnectionIds)
? Array.from(
new Set(
allowedConnectionIds.map((id) => toTrimmedString(id)).filter((id): id is string => !!id)
)
)
: [];
return {
kind: "model",
@@ -80,6 +93,7 @@ export function buildPrecisionComboModelStep({
model: `${normalizedProviderId}/${normalizedModelId}`,
...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}),
...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}),
...(normalizedAllowed.length > 0 ? { allowedConnectionIds: normalizedAllowed } : {}),
weight: Number.isFinite(weight) ? Math.max(0, Math.min(100, Number(weight))) : 0,
};
}

View File

@@ -8,6 +8,12 @@ export interface ComboModelStep {
model: string;
providerId?: string | null;
connectionId?: string | null;
/**
* Account allowlist (#3266): scope this step's round-robin/weighted selection
* to a subset of the provider's connections. Empty/absent = whole active pool.
* Reuses the `allowedConnectionIds` plumbing tag routing already populates.
*/
allowedConnectionIds?: string[] | null;
weight: number;
label?: string;
tags?: string[];
@@ -277,6 +283,11 @@ export function normalizeComboStep(
const tags = Array.isArray(value.tags)
? value.tags.map((tag) => toTrimmedString(tag)).filter((tag): tag is string => !!tag)
: undefined;
const allowedConnectionIds = Array.isArray(value.allowedConnectionIds)
? value.allowedConnectionIds
.map((connId) => toTrimmedString(connId))
.filter((connId): connId is string => !!connId)
: undefined;
return {
id:
@@ -289,6 +300,7 @@ export function normalizeComboStep(
weight,
...(label ? { label } : {}),
...(tags && tags.length > 0 ? { tags } : {}),
...(allowedConnectionIds && allowedConnectionIds.length > 0 ? { allowedConnectionIds } : {}),
};
}

Binary file not shown.

View File

@@ -30,6 +30,9 @@ export const PROVIDER_ENDPOINTS = {
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
crof: "https://crof.ai/v1/chat/completions",
zenmux: "https://zenmux.ai/api/v1/chat/completions",
openadapter: "https://api.openadapter.in/v1/chat/completions",
dit: "https://api.dit.ai/v1/chat/completions",
tokenrouter: "https://api.tokenrouter.com/v1/chat/completions",
openai: "https://api.openai.com/v1/chat/completions",
anthropic: "https://api.anthropic.com/v1/messages",
gemini: "https://generativelanguage.googleapis.com/v1beta/models",

View File

@@ -2401,6 +2401,50 @@ export const APIKEY_PROVIDERS = {
apiHint:
"ZenMux exposes an OpenAI-compatible chat completions endpoint at /api/v1/chat/completions, plus Anthropic Messages (/api/anthropic/v1/messages) and Google Gemini (/api/vertex-ai) protocol surfaces. OmniRoute uses the OpenAI protocol.",
},
openadapter: {
id: "openadapter",
alias: "oad",
name: "OpenAdapter",
icon: "hub",
color: "#10B981",
textIcon: "OD",
website: "https://openadapter.dev",
hasFree: true,
freeNote:
"Free tier with a generous quota and no credit card — 15+ open-source models with daily quota. Get your API key at https://dashboard.openadapter.in.",
authHint:
"Use your OpenAdapter API key in Authorization: Bearer sk-cv-<key>. Fully OpenAI-compatible. API base URL: https://api.openadapter.in/v1.",
apiHint:
"OpenAdapter exposes an OpenAI-compatible chat completions endpoint at https://api.openadapter.in/v1/chat/completions, aggregating 70+ open-source models (DeepSeek, Qwen, Kimi, MiniMax, GLM, Llama, Mistral, …). OmniRoute uses the OpenAI protocol.",
},
dit: {
id: "dit",
alias: "dai",
name: "DIT.ai",
icon: "hub",
color: "#0EA5E9",
textIcon: "DT",
website: "https://dit.ai",
authHint:
"Use your dit.ai API key in Authorization: Bearer <key>. Fully OpenAI-compatible — a drop-in replacement, just change the base URL to https://api.dit.ai/v1.",
apiHint:
"dit.ai (Distributed Intelligence Trade) is an OpenAI-compatible router/gateway with dynamic per-request pricing, exposing /v1/chat/completions at https://api.dit.ai/v1. OmniRoute uses the OpenAI protocol; spend/savings analytics live in the dit.ai dashboard.",
},
tokenrouter: {
id: "tokenrouter",
alias: "trk",
name: "TokenRouter",
icon: "hub",
color: "#F59E0B",
textIcon: "TK",
website: "https://tokenrouter.com",
hasFree: true,
freeNote: "Free tier includes the MiniMax 3 model. Get your API key at https://tokenrouter.com.",
authHint:
"Use your TokenRouter API key in Authorization: Bearer <key>. Fully OpenAI-compatible. API base URL: https://api.tokenrouter.com/v1.",
apiHint:
"TokenRouter exposes an OpenAI-compatible chat completions endpoint at https://api.tokenrouter.com/v1/chat/completions, plus a working /v1/models catalog. OmniRoute uses the OpenAI protocol.",
},
};
// Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views).