mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 22:22:57 +03:00
Merge remote main
This commit is contained in:
@@ -1542,6 +1542,8 @@ Models:
|
||||
|
||||
**Models:** Access 100+ models from all major providers through a single API key.
|
||||
|
||||
**Dashboard behavior:** OpenRouter models are managed from **Available Models**. Manual add, import, and auto-sync all update the same list.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -686,25 +686,25 @@ Additional processing layers in the translation pipeline:
|
||||
|
||||
## Supported API Endpoints
|
||||
|
||||
| Endpoint | Format | Handler |
|
||||
| -------------------------------------------------- | ------------------ | ---------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Model listing | API route |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Model listing | API route |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
|
||||
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
|
||||
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
|
||||
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
|
||||
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
|
||||
| `GET/POST/DELETE /api/provider-models` | Custom Models | Custom model management per provider |
|
||||
| Endpoint | Format | Handler |
|
||||
| -------------------------------------------------- | ------------------ | ------------------------------------------------------------------- |
|
||||
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
|
||||
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
|
||||
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
|
||||
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
|
||||
| `GET /v1/embeddings` | Model listing | API route |
|
||||
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
|
||||
| `GET /v1/images/generations` | Model listing | API route |
|
||||
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
|
||||
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
|
||||
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
|
||||
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
|
||||
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
|
||||
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
|
||||
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
|
||||
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
|
||||
| `GET/POST/DELETE /api/provider-models` | Provider Models | Provider model metadata backing custom and managed available models |
|
||||
|
||||
## Bypass Handler
|
||||
|
||||
|
||||
@@ -596,6 +596,11 @@ curl -X POST http://localhost:20128/api/provider-models \
|
||||
|
||||
Or use Dashboard: **Providers → [Provider] → Custom Models**.
|
||||
|
||||
Notes:
|
||||
|
||||
- OpenRouter and OpenAI/Anthropic-compatible providers are managed from **Available Models** only. Manual add, import, and auto-sync all land in the same available-model list, so there is no separate Custom Models section for those providers.
|
||||
- The **Custom Models** section is intended for providers that do not expose managed available-model imports.
|
||||
|
||||
### Dedicated Provider Routes
|
||||
|
||||
Route requests directly to a specific provider with model validation:
|
||||
|
||||
@@ -212,7 +212,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
|
||||
"gemini-cli": {
|
||||
id: "gemini-cli",
|
||||
alias: "gc",
|
||||
alias: "gemini-cli",
|
||||
format: "gemini-cli",
|
||||
executor: "gemini-cli",
|
||||
baseUrl: "https://cloudcode-pa.googleapis.com/v1internal",
|
||||
|
||||
@@ -950,11 +950,24 @@ export async function handleChatCore({
|
||||
|
||||
const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => {
|
||||
const execute = async () => {
|
||||
const bodyToSend =
|
||||
let bodyToSend =
|
||||
translatedBody.model === modelToCall
|
||||
? translatedBody
|
||||
: { ...translatedBody, model: modelToCall };
|
||||
|
||||
// Inject prompt_cache_key for OpenAI providers if not already set
|
||||
if (
|
||||
targetFormat === FORMATS.OPENAI &&
|
||||
!bodyToSend.prompt_cache_key &&
|
||||
Array.isArray(bodyToSend.messages)
|
||||
) {
|
||||
const { generatePromptCacheKey } = await import("@/lib/promptCache");
|
||||
const cacheKey = generatePromptCacheKey(bodyToSend.messages);
|
||||
if (cacheKey) {
|
||||
bodyToSend = { ...bodyToSend, prompt_cache_key: cacheKey };
|
||||
}
|
||||
}
|
||||
|
||||
const rawResult = await withRateLimit(provider, connectionId, modelToCall, () =>
|
||||
executor.execute({
|
||||
model: modelToCall,
|
||||
@@ -1444,11 +1457,19 @@ export async function handleChatCore({
|
||||
const cachedTokens = toPositiveNumber(
|
||||
usage.cache_read_input_tokens ??
|
||||
usage.cached_tokens ??
|
||||
((usage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens
|
||||
(
|
||||
(usage as Record<string, unknown>).prompt_tokens_details as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.cached_tokens
|
||||
);
|
||||
const cacheCreationTokens = toPositiveNumber(
|
||||
usage.cache_creation_input_tokens ??
|
||||
((usage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cache_creation_tokens
|
||||
(
|
||||
(usage as Record<string, unknown>).prompt_tokens_details as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.cache_creation_tokens
|
||||
);
|
||||
|
||||
saveRequestUsage({
|
||||
@@ -1604,11 +1625,19 @@ export async function handleChatCore({
|
||||
const cachedTokens = toPositiveNumber(
|
||||
streamUsage.cache_read_input_tokens ??
|
||||
streamUsage.cached_tokens ??
|
||||
((streamUsage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens
|
||||
(
|
||||
(streamUsage as Record<string, unknown>).prompt_tokens_details as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.cached_tokens
|
||||
);
|
||||
const cacheCreationTokens = toPositiveNumber(
|
||||
streamUsage.cache_creation_input_tokens ??
|
||||
((streamUsage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cache_creation_tokens
|
||||
(
|
||||
(streamUsage as Record<string, unknown>).prompt_tokens_details as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.cache_creation_tokens
|
||||
);
|
||||
|
||||
saveRequestUsage({
|
||||
|
||||
@@ -52,6 +52,7 @@ type GeminiRequest = {
|
||||
safetySettings: unknown;
|
||||
systemInstruction?: GeminiContent;
|
||||
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
|
||||
cachedContent?: string;
|
||||
};
|
||||
|
||||
type CloudCodeEnvelope = {
|
||||
@@ -82,6 +83,11 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
||||
};
|
||||
|
||||
// Preserve cachedContent if provided by client (for explicit Gemini caching)
|
||||
if (body.cachedContent) {
|
||||
result.cachedContent = body.cachedContent;
|
||||
}
|
||||
|
||||
// Generation config
|
||||
if (body.temperature !== undefined) {
|
||||
result.generationConfig.temperature = body.temperature;
|
||||
|
||||
8
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
8
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
@@ -5,6 +5,7 @@ import { Card, Button, EmptyState } from "@/shared/components";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import CacheEntriesTab from "./components/CacheEntriesTab";
|
||||
import CacheStatsCard from "../settings/components/CacheStatsCard";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -371,7 +372,9 @@ export default function CachePage() {
|
||||
<div className="text-lg font-semibold tabular-nums text-green-500">
|
||||
{promptCacheHitRate.toFixed(1)}%
|
||||
</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">
|
||||
{t("cacheHitRate")} ({pc.requestsWithCacheControl}/{pc.totalRequests})
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-surface/50">
|
||||
<div className="text-lg font-semibold tabular-nums text-blue-400">
|
||||
@@ -432,6 +435,9 @@ export default function CachePage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Prompt Cache Metrics (cumulative with reset) */}
|
||||
<CacheStatsCard />
|
||||
|
||||
{/* Cache Trend (24h) */}
|
||||
{trend.length > 0 && (
|
||||
<Card>
|
||||
|
||||
@@ -1439,7 +1439,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
|
||||
};
|
||||
|
||||
const FREE_STACK_PRESET_MODELS = [
|
||||
{ model: "gc/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "gemini-cli/gemini-3-flash-preview", weight: 0 },
|
||||
{ model: "kr/claude-sonnet-4.5", weight: 0 },
|
||||
{ model: "if/kimi-k2-thinking", weight: 0 },
|
||||
{ model: "if/qwen3-coder-plus", weight: 0 },
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
MODEL_COMPAT_PROTOCOL_KEYS,
|
||||
type ModelCompatProtocolKey,
|
||||
} from "@/shared/constants/modelCompat";
|
||||
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
|
||||
|
||||
type CompatByProtocolMap = Partial<
|
||||
Record<
|
||||
@@ -331,6 +332,10 @@ interface CompatibleModelsSectionProps {
|
||||
providerStorageAlias: string;
|
||||
providerDisplayAlias: string;
|
||||
modelAliases: Record<string, string>;
|
||||
fallbackModels?: CompatModelRow[];
|
||||
description: string;
|
||||
inputLabel: string;
|
||||
inputPlaceholder: string;
|
||||
copied?: string;
|
||||
onCopy: (text: string, key: string) => void;
|
||||
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
|
||||
@@ -850,6 +855,7 @@ export default function ProviderDetailPage() {
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
|
||||
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
|
||||
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
|
||||
const isSearchProvider = providerId.endsWith("-search");
|
||||
|
||||
const providerStorageAlias = isCompatible ? providerId : providerAlias;
|
||||
@@ -1666,6 +1672,14 @@ export default function ProviderDetailPage() {
|
||||
};
|
||||
|
||||
const [clearingModels, setClearingModels] = useState(false);
|
||||
const providerAliasEntries = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(([, model]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
|
||||
const handleClearAllModels = async () => {
|
||||
if (clearingModels) return;
|
||||
if (!confirm(t("clearAllModelsConfirm"))) return;
|
||||
@@ -1677,11 +1691,8 @@ export default function ProviderDetailPage() {
|
||||
);
|
||||
if (res.ok) {
|
||||
// Also delete all aliases that belong to this provider
|
||||
const aliasEntries = Object.entries(modelAliases).filter(([, model]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
);
|
||||
await Promise.all(
|
||||
aliasEntries.map(([alias]) =>
|
||||
providerAliasEntries.map(([alias]) =>
|
||||
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {})
|
||||
@@ -1808,7 +1819,8 @@ export default function ProviderDetailPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
const clearAllButton = modelMeta.customModels.length > 0 && (
|
||||
const clearAllButton = (modelMeta.customModels.length > 0 ||
|
||||
providerAliasEntries.length > 0) && (
|
||||
<button
|
||||
onClick={handleClearAllModels}
|
||||
disabled={clearingModels}
|
||||
@@ -1820,7 +1832,21 @@ export default function ProviderDetailPage() {
|
||||
</button>
|
||||
);
|
||||
|
||||
if (isCompatible) {
|
||||
if (isManagedAvailableModelsProvider) {
|
||||
const description =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterAnyModelHint")
|
||||
: t("compatibleModelsDescription", {
|
||||
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
|
||||
});
|
||||
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
|
||||
const inputPlaceholder =
|
||||
providerId === "openrouter"
|
||||
? t("openRouterModelPlaceholder")
|
||||
: isAnthropicCompatible
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
@@ -1831,6 +1857,10 @@ export default function ProviderDetailPage() {
|
||||
providerStorageAlias={providerStorageAlias}
|
||||
providerDisplayAlias={providerDisplayAlias}
|
||||
modelAliases={modelAliases}
|
||||
fallbackModels={providerId === "openrouter" ? modelMeta.customModels : undefined}
|
||||
description={description}
|
||||
inputLabel={inputLabel}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onSetAlias={handleSetAlias}
|
||||
@@ -2369,8 +2399,8 @@ export default function ProviderDetailPage() {
|
||||
<h2 className="text-lg font-semibold mb-4">{t("availableModels")}</h2>
|
||||
{renderModelsSection()}
|
||||
|
||||
{/* Custom Models — available for non-compatible, non-search providers */}
|
||||
{!isCompatible && (
|
||||
{/* Custom Models — available for providers without managed available-model metadata */}
|
||||
{!isManagedAvailableModelsProvider && (
|
||||
<CustomModelsSection
|
||||
providerId={providerId}
|
||||
providerAlias={providerDisplayAlias}
|
||||
@@ -3412,6 +3442,10 @@ function CompatibleModelsSection({
|
||||
providerStorageAlias,
|
||||
providerDisplayAlias,
|
||||
modelAliases,
|
||||
fallbackModels = [],
|
||||
description,
|
||||
inputLabel,
|
||||
inputPlaceholder,
|
||||
copied,
|
||||
onCopy,
|
||||
onSetAlias,
|
||||
@@ -3432,33 +3466,45 @@ function CompatibleModelsSection({
|
||||
const [importing, setImporting] = useState(false);
|
||||
const notify = useNotificationStore();
|
||||
|
||||
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
const providerAliases = useMemo(
|
||||
() =>
|
||||
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
|
||||
(model as string).startsWith(`${providerStorageAlias}/`)
|
||||
),
|
||||
[modelAliases, providerStorageAlias]
|
||||
);
|
||||
|
||||
const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => ({
|
||||
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
|
||||
fullModel,
|
||||
alias,
|
||||
}));
|
||||
const allModels = useMemo(() => {
|
||||
const rows = providerAliases.map(([alias, fullModel]: [string, any]) => ({
|
||||
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
|
||||
alias,
|
||||
}));
|
||||
|
||||
const generateDefaultAlias = (modelId) => {
|
||||
const parts = modelId.split("/");
|
||||
return parts[parts.length - 1];
|
||||
};
|
||||
const seenModelIds = new Set(rows.map((row) => row.modelId));
|
||||
for (const model of fallbackModels) {
|
||||
if (!model?.id || seenModelIds.has(model.id)) continue;
|
||||
rows.push({ modelId: model.id, alias: null });
|
||||
seenModelIds.add(model.id);
|
||||
}
|
||||
|
||||
const resolveAlias = (modelId) => {
|
||||
const baseAlias = generateDefaultAlias(modelId);
|
||||
if (!modelAliases[baseAlias]) return baseAlias;
|
||||
const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`;
|
||||
if (!modelAliases[prefixedAlias]) return prefixedAlias;
|
||||
return null;
|
||||
};
|
||||
return rows;
|
||||
}, [fallbackModels, providerAliases, providerStorageAlias]);
|
||||
|
||||
const resolveAlias = useCallback(
|
||||
(modelId: string, workingAliases: Record<string, string>) =>
|
||||
resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel: `${providerStorageAlias}/${modelId}`,
|
||||
providerDisplayAlias,
|
||||
existingAliases: workingAliases,
|
||||
}),
|
||||
[providerDisplayAlias, providerStorageAlias]
|
||||
);
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!newModel.trim() || adding) return;
|
||||
const modelId = newModel.trim();
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
const resolvedAlias = resolveAlias(modelId, modelAliases);
|
||||
if (!resolvedAlias) {
|
||||
notify.error(t("allSuggestedAliasesExist"));
|
||||
return;
|
||||
@@ -3508,6 +3554,7 @@ function CompatibleModelsSection({
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const workingAliases = { ...modelAliases };
|
||||
await onImportWithProgress(
|
||||
// fetchModels callback
|
||||
async () => {
|
||||
@@ -3520,7 +3567,7 @@ function CompatibleModelsSection({
|
||||
async (model: any) => {
|
||||
const modelId = model.id || model.name || model.model;
|
||||
if (!modelId) return false;
|
||||
const resolvedAlias = resolveAlias(modelId);
|
||||
const resolvedAlias = resolveAlias(modelId, workingAliases);
|
||||
if (!resolvedAlias) return false;
|
||||
|
||||
// Save to customModels DB FIRST - only create alias if this succeeds
|
||||
@@ -3542,6 +3589,7 @@ function CompatibleModelsSection({
|
||||
|
||||
// Only create alias after customModel is saved successfully
|
||||
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
|
||||
workingAliases[resolvedAlias] = `${providerStorageAlias}/${modelId}`;
|
||||
return true;
|
||||
}
|
||||
);
|
||||
@@ -3556,7 +3604,7 @@ function CompatibleModelsSection({
|
||||
const canImport = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
// Handle delete: remove from both alias and customModels DB
|
||||
const handleDeleteModel = async (modelId: string, alias: string) => {
|
||||
const handleDeleteModel = async (modelId: string, alias?: string | null) => {
|
||||
try {
|
||||
// Remove from customModels DB
|
||||
const res = await fetch(
|
||||
@@ -3567,7 +3615,9 @@ function CompatibleModelsSection({
|
||||
throw new Error(t("failedRemoveModelFromDatabase"));
|
||||
}
|
||||
// Also delete the alias
|
||||
await onDeleteAlias(alias);
|
||||
if (alias) {
|
||||
await onDeleteAlias(alias);
|
||||
}
|
||||
notify.success(t("modelRemovedSuccess"));
|
||||
onModelsChanged?.();
|
||||
} catch (error) {
|
||||
@@ -3578,11 +3628,7 @@ function CompatibleModelsSection({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
{t("compatibleModelsDescription", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{description}</p>
|
||||
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
@@ -3590,7 +3636,7 @@ function CompatibleModelsSection({
|
||||
htmlFor="new-compatible-model-input"
|
||||
className="text-xs text-text-muted mb-1 block"
|
||||
>
|
||||
{t("modelId")}
|
||||
{inputLabel}
|
||||
</label>
|
||||
<input
|
||||
id="new-compatible-model-input"
|
||||
@@ -3598,11 +3644,7 @@ function CompatibleModelsSection({
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={
|
||||
isAnthropic
|
||||
? t("anthropicCompatibleModelPlaceholder")
|
||||
: t("openaiCompatibleModelPlaceholder")
|
||||
}
|
||||
placeholder={inputPlaceholder}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -3624,9 +3666,9 @@ function CompatibleModelsSection({
|
||||
|
||||
{allModels.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||
{allModels.map(({ modelId, alias }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel as string}
|
||||
key={`${providerStorageAlias}:${modelId}`}
|
||||
modelId={modelId}
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
copied={copied}
|
||||
@@ -3651,6 +3693,10 @@ CompatibleModelsSection.propTypes = {
|
||||
providerStorageAlias: PropTypes.string.isRequired,
|
||||
providerDisplayAlias: PropTypes.string.isRequired,
|
||||
modelAliases: PropTypes.object.isRequired,
|
||||
fallbackModels: PropTypes.array,
|
||||
description: PropTypes.string.isRequired,
|
||||
inputLabel: PropTypes.string.isRequired,
|
||||
inputPlaceholder: PropTypes.string.isRequired,
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onSetAlias: PropTypes.func.isRequired,
|
||||
|
||||
@@ -106,7 +106,10 @@ export default function AppearanceTab() {
|
||||
{ id: "cyan", color: COLOR_THEMES.cyan, label: t("themeCyan") },
|
||||
];
|
||||
|
||||
const sidebarSections = SIDEBAR_SECTIONS.map((section) => ({
|
||||
const showDebug = settings.debugMode === true;
|
||||
const sidebarSections = SIDEBAR_SECTIONS.filter(
|
||||
(section) => section.visibility !== "debug" || showDebug
|
||||
).map((section) => ({
|
||||
...section,
|
||||
title: getSidebarLabel(section.titleKey, section.titleFallback),
|
||||
items: section.items.map((item) => ({ ...item, label: tSidebar(item.i18nKey) })),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -33,19 +33,26 @@ interface CacheMetrics {
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 10_000;
|
||||
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
|
||||
|
||||
export default function CacheStatsCard() {
|
||||
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const t = useTranslations("settings");
|
||||
const t = useTranslations("cache");
|
||||
|
||||
const fetchMetrics = () => {
|
||||
const fetchMetrics = useCallback(() => {
|
||||
fetch("/api/settings/cache-metrics")
|
||||
.then((r) => r.json())
|
||||
.then(setMetrics)
|
||||
.catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(fetchMetrics, []);
|
||||
useEffect(() => {
|
||||
void fetchMetrics();
|
||||
const id = setInterval(() => void fetchMetrics(), REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchMetrics]);
|
||||
|
||||
const handleReset = async () => {
|
||||
setResetting(true);
|
||||
@@ -63,132 +70,148 @@ export default function CacheStatsCard() {
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px]">insights</span>
|
||||
Prompt Cache Metrics
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{resetting ? "Resetting..." : "Reset Metrics"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{metrics ? (
|
||||
<div className="space-y-4">
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">Total Requests</p>
|
||||
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">With Cache Control</p>
|
||||
<p className="font-mono text-lg text-text-main">{metrics.requestsWithCacheControl}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
insights
|
||||
</span>
|
||||
<h2 className="font-medium text-sm">{t("cacheMetrics")}</h2>
|
||||
</div>
|
||||
|
||||
{/* Token Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">Input Tokens</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{metrics.totalInputTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Cached Tokens (Read)</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.totalCachedTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Cache Creation (Write)</p>
|
||||
<p className="font-mono text-lg text-blue-400">
|
||||
{metrics.totalCacheCreationTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-muted">
|
||||
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={resetting}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{resetting ? t("resetting") : t("resetMetrics")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cache Ratio */}
|
||||
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">Cache Reuse Ratio</p>
|
||||
<p className="text-xs text-text-muted">Cached tokens / Total input tokens</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Savings */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">Tokens Saved</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.tokensSaved.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">Est. Cost Saved</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
${metrics.estimatedCostSaved.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Provider */}
|
||||
{Object.keys(metrics.byProvider).length > 0 && (
|
||||
<div className="pt-3 border-t border-border/30">
|
||||
<p className="text-xs font-medium text-text-muted mb-2">By Provider</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
|
||||
const providerCacheRate =
|
||||
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-main capitalize w-24">{provider}</span>
|
||||
<span className="text-text-muted">{stats.requests} reqs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
<span className="text-text-muted" title="Input tokens">
|
||||
In: {stats.inputTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-green-400" title="Cached tokens (reads)">
|
||||
Cached: {stats.cachedTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-blue-400" title="Cache creation tokens (writes)">
|
||||
Write: {stats.cacheCreationTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-green-400 w-12 text-right">
|
||||
{providerCacheRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">Loading cache metrics...</p>
|
||||
)}
|
||||
|
||||
{metrics ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Overview Stats */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("totalRequests")}</p>
|
||||
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("withCacheControl")}</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{metrics.requestsWithCacheControl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("inputTokens")}</p>
|
||||
<p className="font-mono text-lg text-text-main">
|
||||
{metrics.totalInputTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cachedTokensRead")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.totalCachedTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("cacheCreationWrite")}</p>
|
||||
<p className="font-mono text-lg text-blue-400">
|
||||
{metrics.totalCacheCreationTokens.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cache Ratio */}
|
||||
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-main">{t("cacheReuseRatio")}</p>
|
||||
<p className="text-xs text-text-muted">{t("cacheReuseRatioDesc")}</p>
|
||||
</div>
|
||||
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Savings */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-text-muted">{t("tokensSaved")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
{metrics.tokensSaved.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-text-muted">{t("estCostSaved")}</p>
|
||||
<p className="font-mono text-lg text-green-400">
|
||||
${metrics.estimatedCostSaved.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Provider */}
|
||||
{Object.keys(metrics.byProvider).length > 0 && (
|
||||
<div className="pt-3 border-t border-border/30">
|
||||
<p className="text-xs font-medium text-text-muted mb-2">{t("byProvider")}</p>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
|
||||
const providerCacheRate =
|
||||
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-main capitalize w-24">{provider}</span>
|
||||
<span className="text-text-muted">
|
||||
{stats.requests} {t("requestsShort")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mono">
|
||||
<span className="text-text-muted" title={t("inputTokens")}>
|
||||
{t("inputShort")}: {stats.inputTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-green-400" title={t("cachedTokensRead")}>
|
||||
{t("cachedShort")}: {stats.cachedTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-blue-400" title={t("cacheCreationWrite")}>
|
||||
{t("writeShort")}: {stats.cacheCreationTokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-green-400 w-12 text-right">
|
||||
{providerCacheRate.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-text-muted">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
9
src/app/api/cache/entries/route.ts
vendored
9
src/app/api/cache/entries/route.ts
vendored
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
interface CacheEntry {
|
||||
id: string;
|
||||
@@ -12,6 +13,10 @@ interface CacheEntry {
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
|
||||
@@ -71,6 +76,10 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const signature = searchParams.get("signature");
|
||||
|
||||
12
src/app/api/cache/route.ts
vendored
12
src/app/api/cache/route.ts
vendored
@@ -9,15 +9,21 @@ import {
|
||||
} from "@/lib/semanticCache";
|
||||
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
|
||||
import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const trendHours = parseInt(searchParams.get("trendHours") || "24", 10);
|
||||
const rawHours = parseInt(searchParams.get("trendHours") || "24", 10);
|
||||
const trendHours = Math.min(720, Math.max(1, Number.isNaN(rawHours) ? 24 : rawHours));
|
||||
|
||||
const cacheStats = getCacheStats();
|
||||
const idempotencyStats = getIdempotencyStats();
|
||||
@@ -36,6 +42,10 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
if (!(await isAuthenticated(req))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const model = searchParams.get("model");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { replaceCustomModels } from "@/lib/db/models";
|
||||
import {
|
||||
syncManagedAvailableModelAliases,
|
||||
usesManagedAvailableModels,
|
||||
} from "@/lib/providerModels/managedAvailableModels";
|
||||
import { saveCallLog } from "@/lib/usage/callLogs";
|
||||
import { isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import {
|
||||
@@ -77,9 +81,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
const fetchedModels = modelsData.models || [];
|
||||
|
||||
// Filter out models already in the built-in registry
|
||||
const registryIds = new Set(
|
||||
getModelsByProviderId(connection.provider).map((m: any) => m.id)
|
||||
);
|
||||
const registryIds = new Set(getModelsByProviderId(connection.provider).map((m: any) => m.id));
|
||||
|
||||
// Replace the full model list
|
||||
const models = fetchedModels
|
||||
@@ -92,6 +94,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
const replaced = await replaceCustomModels(connection.provider, models);
|
||||
|
||||
let syncedAliases = 0;
|
||||
if (usesManagedAvailableModels(connection.provider)) {
|
||||
const aliasSync = await syncManagedAvailableModelAliases(
|
||||
connection.provider,
|
||||
models.map((model: any) => model.id)
|
||||
);
|
||||
syncedAliases = aliasSync.assignedAliases.length;
|
||||
}
|
||||
|
||||
// Log the successful sync
|
||||
await saveCallLog({
|
||||
method: "GET",
|
||||
@@ -105,6 +116,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
requestType: "model-sync",
|
||||
responseBody: {
|
||||
syncedModels: models.length,
|
||||
syncedAliases,
|
||||
provider: connection.provider,
|
||||
},
|
||||
});
|
||||
@@ -113,6 +125,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
ok: true,
|
||||
provider: connection.provider,
|
||||
syncedModels: replaced.length,
|
||||
syncedAliases,
|
||||
models: replaced,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -2937,6 +2937,19 @@
|
||||
"cacheHitRate": "Cache Hit Rate",
|
||||
"cachedTokens": "Cached Tokens",
|
||||
"cacheCreationTokens": "Cache Creation Tokens",
|
||||
"cacheMetrics": "Prompt Cache Metrics",
|
||||
"withCacheControl": "With Cache Control",
|
||||
"cachedTokensRead": "Cached Tokens (Read)",
|
||||
"cacheCreationWrite": "Cache Creation (Write)",
|
||||
"cacheReuseRatio": "Cache Reuse Ratio",
|
||||
"cacheReuseRatioDesc": "Cached tokens / Total input tokens",
|
||||
"estCostSaved": "Est. Cost Saved",
|
||||
"requestsShort": "reqs",
|
||||
"inputShort": "In",
|
||||
"cachedShort": "Cached",
|
||||
"writeShort": "Write",
|
||||
"resetting": "Resetting...",
|
||||
"resetMetrics": "Reset Metrics",
|
||||
"byProvider": "Breakdown by Provider",
|
||||
"provider": "Provider",
|
||||
"requests": "Requests",
|
||||
|
||||
@@ -630,7 +630,7 @@ export async function getCacheMetrics() {
|
||||
totalCachedTokens: totalsRow?.totalCachedTokens || 0,
|
||||
totalCacheCreationTokens: totalsRow?.totalCacheCreationTokens || 0,
|
||||
tokensSaved,
|
||||
estimatedCostSaved: 0, // Would need pricing data to calculate
|
||||
estimatedCostSaved,
|
||||
byProvider,
|
||||
byStrategy,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
|
||||
@@ -75,9 +75,9 @@ const LITELLM_PRICING_URL =
|
||||
const LITELLM_PROVIDER_MAP: Record<string, string[]> = {
|
||||
openai: ["openai", "cx"],
|
||||
anthropic: ["anthropic", "cc"],
|
||||
vertex_ai: ["gemini", "gc"],
|
||||
vertex_ai: ["gemini", "gemini-cli"],
|
||||
"vertex_ai-anthropic_models": ["anthropic"],
|
||||
google: ["gemini", "gc"],
|
||||
google: ["gemini", "gemini-cli"],
|
||||
deepseek: ["if"],
|
||||
groq: ["groq"],
|
||||
together_ai: ["openrouter"],
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { analyzePrefix, shouldInjectCacheControl } from "./prefixAnalyzer";
|
||||
export { analyzePrefix, shouldInjectCacheControl, generatePromptCacheKey } from "./prefixAnalyzer";
|
||||
|
||||
@@ -75,3 +75,11 @@ export function analyzePrefix(messages: Message[]): PrefixAnalysis {
|
||||
export function shouldInjectCacheControl(analysis: PrefixAnalysis, minTokens = 1024): boolean {
|
||||
return analysis.prefixTokens >= minTokens && analysis.confidence >= 0.7;
|
||||
}
|
||||
|
||||
export function generatePromptCacheKey(messages: Message[]): string {
|
||||
const analysis = analyzePrefix(messages);
|
||||
if (analysis.prefixHash) {
|
||||
return `omni-${analysis.prefixHash.slice(0, 32)}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
91
src/lib/providerModels/managedAvailableModels.ts
Normal file
91
src/lib/providerModels/managedAvailableModels.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
deleteModelAlias,
|
||||
getModelAliases,
|
||||
getProviderNodeById,
|
||||
setModelAlias,
|
||||
} from "@/lib/localDb";
|
||||
import {
|
||||
getProviderAlias,
|
||||
isAnthropicCompatibleProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
} from "@/shared/constants/providers";
|
||||
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
|
||||
|
||||
function isCompatibleProvider(providerId: string): boolean {
|
||||
return isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
|
||||
}
|
||||
|
||||
export function usesManagedAvailableModels(providerId: string): boolean {
|
||||
return providerId === "openrouter" || isCompatibleProvider(providerId);
|
||||
}
|
||||
|
||||
function getProviderStoragePrefix(providerId: string): string {
|
||||
if (isCompatibleProvider(providerId)) return providerId;
|
||||
return getProviderAlias(providerId) || providerId;
|
||||
}
|
||||
|
||||
async function getProviderDisplayPrefix(providerId: string): Promise<string> {
|
||||
if (!isCompatibleProvider(providerId)) {
|
||||
return getProviderAlias(providerId) || providerId;
|
||||
}
|
||||
|
||||
const providerNode = await getProviderNodeById(providerId);
|
||||
const prefix = providerNode?.prefix;
|
||||
return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId;
|
||||
}
|
||||
|
||||
export async function syncManagedAvailableModelAliases(providerId: string, modelIds: string[]) {
|
||||
const storagePrefix = getProviderStoragePrefix(providerId);
|
||||
const displayPrefix = await getProviderDisplayPrefix(providerId);
|
||||
const existingAliasesRaw = await getModelAliases();
|
||||
const workingAliases = Object.fromEntries(
|
||||
Object.entries(existingAliasesRaw).filter((entry): entry is [string, string] => {
|
||||
const [, value] = entry;
|
||||
return typeof value === "string";
|
||||
})
|
||||
);
|
||||
|
||||
const targetModelIds = Array.from(
|
||||
new Set(
|
||||
modelIds.map((modelId) => (typeof modelId === "string" ? modelId.trim() : "")).filter(Boolean)
|
||||
)
|
||||
);
|
||||
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
|
||||
const removedAliases: string[] = [];
|
||||
|
||||
for (const [alias, value] of Object.entries(workingAliases)) {
|
||||
if (!value.startsWith(`${storagePrefix}/`)) continue;
|
||||
if (targetFullModels.has(value)) continue;
|
||||
|
||||
await deleteModelAlias(alias);
|
||||
delete workingAliases[alias];
|
||||
removedAliases.push(alias);
|
||||
}
|
||||
|
||||
const assignedAliases: string[] = [];
|
||||
|
||||
for (const modelId of targetModelIds) {
|
||||
const fullModel = `${storagePrefix}/${modelId}`;
|
||||
const alias = resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel,
|
||||
providerDisplayAlias: displayPrefix,
|
||||
existingAliases: workingAliases,
|
||||
});
|
||||
|
||||
if (!alias) continue;
|
||||
|
||||
if (workingAliases[alias] !== fullModel) {
|
||||
await setModelAlias(alias, fullModel);
|
||||
workingAliases[alias] = fullModel;
|
||||
}
|
||||
|
||||
assignedAliases.push(alias);
|
||||
}
|
||||
|
||||
return {
|
||||
assignedAliases,
|
||||
removedAliases,
|
||||
storagePrefix,
|
||||
};
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function Sidebar({
|
||||
|
||||
useEffect(() => {
|
||||
const applySettings = (data) => {
|
||||
setShowDebug(data?.enableRequestLogs === true);
|
||||
setShowDebug(data?.debugMode === true);
|
||||
setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]));
|
||||
};
|
||||
|
||||
@@ -52,8 +52,8 @@ export default function Sidebar({
|
||||
const handleSettingsUpdated = (event: Event) => {
|
||||
const detail = (event as CustomEvent<Record<string, unknown>>).detail || {};
|
||||
|
||||
if ("enableRequestLogs" in detail) {
|
||||
setShowDebug(detail.enableRequestLogs === true);
|
||||
if ("debugMode" in detail) {
|
||||
setShowDebug(detail.debugMode === true);
|
||||
}
|
||||
|
||||
if (HIDDEN_SIDEBAR_ITEMS_SETTING_KEY in detail) {
|
||||
|
||||
@@ -189,8 +189,8 @@ export const DEFAULT_PRICING = {
|
||||
},
|
||||
},
|
||||
|
||||
// Gemini CLI (gc)
|
||||
gc: {
|
||||
// Gemini CLI
|
||||
"gemini-cli": {
|
||||
"gemini-3-flash-preview": {
|
||||
input: 0.5,
|
||||
output: 3.0,
|
||||
@@ -1299,7 +1299,7 @@ type TokenUsage = Record<string, number | undefined>;
|
||||
|
||||
/**
|
||||
* Get pricing for a specific provider and model
|
||||
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gc")
|
||||
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gemini-cli")
|
||||
* @param {string} model - Model ID
|
||||
* @returns {object|null} Pricing object or null if not found
|
||||
*/
|
||||
|
||||
@@ -6,7 +6,7 @@ export const FREE_PROVIDERS = {
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
|
||||
"gemini-cli": {
|
||||
id: "gemini-cli",
|
||||
alias: "gc",
|
||||
alias: "gemini-cli",
|
||||
name: "Gemini CLI",
|
||||
icon: "terminal",
|
||||
color: "#4285F4",
|
||||
|
||||
68
src/shared/utils/providerModelAliases.ts
Normal file
68
src/shared/utils/providerModelAliases.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
type AliasMap = Record<string, string>;
|
||||
|
||||
export function getDefaultModelAliasBase(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) return "";
|
||||
|
||||
const segments = trimmed
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
return segments[segments.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
export function resolveManagedModelAlias({
|
||||
modelId,
|
||||
fullModel,
|
||||
providerDisplayAlias,
|
||||
existingAliases,
|
||||
}: {
|
||||
modelId: string;
|
||||
fullModel: string;
|
||||
providerDisplayAlias: string;
|
||||
existingAliases: AliasMap;
|
||||
}): string | null {
|
||||
const baseAlias = getDefaultModelAliasBase(modelId);
|
||||
if (!baseAlias) return null;
|
||||
|
||||
for (const [alias, value] of Object.entries(existingAliases)) {
|
||||
if (value === fullModel) return alias;
|
||||
}
|
||||
|
||||
const displayAlias = providerDisplayAlias.trim();
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const pushCandidate = (candidate: string) => {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || seen.has(trimmed)) return;
|
||||
seen.add(trimmed);
|
||||
candidates.push(trimmed);
|
||||
};
|
||||
|
||||
pushCandidate(baseAlias);
|
||||
if (displayAlias) {
|
||||
pushCandidate(`${displayAlias}-${baseAlias}`);
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!(candidate in existingAliases) || existingAliases[candidate] === fullModel) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
for (let suffix = 2; suffix <= 5000; suffix += 1) {
|
||||
if (displayAlias) {
|
||||
const prefixed = `${displayAlias}-${baseAlias}-${suffix}`;
|
||||
if (!(prefixed in existingAliases) || existingAliases[prefixed] === fullModel) {
|
||||
return prefixed;
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = `${baseAlias}-${suffix}`;
|
||||
if (!(fallback in existingAliases) || existingAliases[fallback] === fullModel) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
69
tests/unit/managed-available-models.test.mjs
Normal file
69
tests/unit/managed-available-models.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
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-managed-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
const managedModels = await import("../../src/lib/providerModels/managedAvailableModels.ts");
|
||||
const aliasUtils = await import("../../src/shared/utils/providerModelAliases.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("resolveManagedModelAlias preserves existing aliases and falls back to provider-prefixed suffixes", () => {
|
||||
const first = aliasUtils.resolveManagedModelAlias({
|
||||
modelId: "anthropic/claude-3.7-sonnet",
|
||||
fullModel: "openrouter/anthropic/claude-3.7-sonnet",
|
||||
providerDisplayAlias: "openrouter",
|
||||
existingAliases: {
|
||||
"claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
|
||||
"openrouter-claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
|
||||
},
|
||||
});
|
||||
assert.equal(first, "openrouter-claude-3.7-sonnet-2");
|
||||
|
||||
const preserved = aliasUtils.resolveManagedModelAlias({
|
||||
modelId: "openai/gpt-4.1",
|
||||
fullModel: "openrouter/openai/gpt-4.1",
|
||||
providerDisplayAlias: "openrouter",
|
||||
existingAliases: {
|
||||
kept: "openrouter/openai/gpt-4.1",
|
||||
"gpt-4.1": "other-provider/gpt-4.1",
|
||||
},
|
||||
});
|
||||
assert.equal(preserved, "kept");
|
||||
});
|
||||
|
||||
test("syncManagedAvailableModelAliases backfills openrouter aliases and removes stale entries", async () => {
|
||||
await modelsDb.setModelAlias("kept", "openrouter/openai/gpt-4.1");
|
||||
await modelsDb.setModelAlias("claude-3.7-sonnet", "other-provider/claude-3.7-sonnet");
|
||||
await modelsDb.setModelAlias("stale-model", "openrouter/legacy/stale-model");
|
||||
|
||||
const result = await managedModels.syncManagedAvailableModelAliases("openrouter", [
|
||||
"openai/gpt-4.1",
|
||||
"anthropic/claude-3.7-sonnet",
|
||||
]);
|
||||
const aliases = await modelsDb.getModelAliases();
|
||||
|
||||
assert.deepEqual(result.removedAliases, ["stale-model"]);
|
||||
assert.equal(aliases.kept, "openrouter/openai/gpt-4.1");
|
||||
assert.equal(aliases["openrouter-claude-3.7-sonnet"], "openrouter/anthropic/claude-3.7-sonnet");
|
||||
assert.equal(aliases["stale-model"], undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user