mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add catalog-backed Claude Code model metadata
This commit is contained in:
parent
a75c6de7d1
commit
e558a7c4ad
8 changed files with 492315 additions and 39 deletions
490722
models.json
Normal file
490722
models.json
Normal file
File diff suppressed because it is too large
Load diff
1067
scripts/generate-models-json.mjs
Normal file
1067
scripts/generate-models-json.mjs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -51,6 +51,8 @@ type CoreGatewayProvider = {
|
|||
|
||||
const defaultFusionWebSearchProvider: VirtualModelFusionWebSearchProvider = "brave";
|
||||
const fusionModelProviderName = "Fusion";
|
||||
const claudeCodeDefaultContextTokens = 200_000;
|
||||
const claudeCodeOneMillionContextSuffix = "[1m]";
|
||||
|
||||
type ApiKeyAuthorizationResult =
|
||||
| { ok: true; apiKey?: ApiKeyConfig }
|
||||
|
|
@ -111,6 +113,44 @@ type CursorOpenAICompatPreparation = {
|
|||
diagnostic: "fallback-injected" | "simplified-missing-context";
|
||||
};
|
||||
|
||||
type ModelCatalogCapabilities = Record<string, unknown>;
|
||||
|
||||
type ModelCatalogLimits = {
|
||||
contextTokens?: number;
|
||||
inputTokens?: number;
|
||||
maxTokens?: number;
|
||||
outputTokens?: number;
|
||||
supports1MContext?: boolean;
|
||||
};
|
||||
|
||||
type ModelCatalogModalities = {
|
||||
input?: string[];
|
||||
output?: string[];
|
||||
};
|
||||
|
||||
type ModelCatalogEntry = {
|
||||
aliases: string[];
|
||||
capabilities?: ModelCatalogCapabilities;
|
||||
displayName?: string;
|
||||
family?: string;
|
||||
id: string;
|
||||
limits?: ModelCatalogLimits;
|
||||
modalities?: ModelCatalogModalities;
|
||||
model?: string;
|
||||
providers?: string[];
|
||||
};
|
||||
|
||||
type ModelCatalogIndex = {
|
||||
byKey: Map<string, ModelCatalogEntry>;
|
||||
byModelKey: Map<string, ModelCatalogEntry | undefined>;
|
||||
loadedFrom?: string;
|
||||
};
|
||||
|
||||
type ClaudeCodeDiscoverableModel = {
|
||||
id: string;
|
||||
oneMillionContext: boolean;
|
||||
};
|
||||
|
||||
type UpstreamAttempt = {
|
||||
body?: Buffer;
|
||||
credentialChain?: string[];
|
||||
|
|
@ -171,6 +211,7 @@ const rawTraceSyncPath = "/__ccr/raw-trace-sync";
|
|||
const gatewayPackageCandidates = ["@the-next-ai/ai-gateway", "gateway"];
|
||||
const apiKeyLimitCounters = new Map<string, ApiKeyWindowCounter>();
|
||||
const providerCredentialCooldowns = new Map<string, { reason: string; until: number }>();
|
||||
let modelCatalogIndex: ModelCatalogIndex | undefined;
|
||||
|
||||
class GatewayService {
|
||||
private child?: ChildProcess;
|
||||
|
|
@ -2785,16 +2826,29 @@ function prepareClaudeCodeDiscoveredModelRequest(
|
|||
}
|
||||
|
||||
function createClaudeCodeModelsResponse(config: AppConfig): Record<string, unknown> {
|
||||
const ids = buildClaudeCodeDiscoverableModelIds(config);
|
||||
const data = ids.map((id) => {
|
||||
const claudeId = claudeCodeDiscoveryModelId(id);
|
||||
const models = buildClaudeCodeDiscoverableModels(config);
|
||||
const data = models.map((model) => {
|
||||
const claudeId = claudeCodeDiscoveryModelId(model.id);
|
||||
const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id);
|
||||
const catalogEntry = findModelCatalogEntry(catalogId);
|
||||
const maxInputTokens = claudeCodeEffectiveMaxInputTokens(catalogEntry, model.oneMillionContext);
|
||||
const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry);
|
||||
return {
|
||||
id: claudeId,
|
||||
capabilities: createClaudeCodeModelCapabilities(),
|
||||
capabilities: createClaudeCodeModelCapabilities(catalogEntry, {
|
||||
maxInputTokens,
|
||||
oneMillionContext: model.oneMillionContext
|
||||
}),
|
||||
catalog_id: catalogEntry?.id,
|
||||
context_window: maxInputTokens,
|
||||
created_at: "1970-01-01T00:00:00Z",
|
||||
display_name: formatClaudeCodeModelDisplayName(claudeId),
|
||||
max_input_tokens: 0,
|
||||
max_tokens: 0,
|
||||
display_name: formatClaudeCodeModelDisplayName(claudeId, catalogEntry, model.oneMillionContext),
|
||||
input_modalities: catalogEntry?.modalities?.input ?? ["text"],
|
||||
max_input_tokens: maxInputTokens,
|
||||
max_tokens: maxOutputTokens,
|
||||
one_million_context_variant: model.oneMillionContext,
|
||||
output_modalities: catalogEntry?.modalities?.output ?? ["text"],
|
||||
supports_1m_context: Boolean(catalogEntry?.limits?.supports1MContext),
|
||||
type: "model"
|
||||
};
|
||||
});
|
||||
|
|
@ -2807,6 +2861,216 @@ function createClaudeCodeModelsResponse(config: AppConfig): Record<string, unkno
|
|||
};
|
||||
}
|
||||
|
||||
function findModelCatalogEntry(model: string): ModelCatalogEntry | undefined {
|
||||
const index = loadModelCatalogIndex();
|
||||
const candidates = modelCatalogLookupKeys(model);
|
||||
for (const key of candidates) {
|
||||
const entry = index.byKey.get(key);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of candidates) {
|
||||
const modelKey = modelCatalogLastSegmentKey(key);
|
||||
if (!modelKey) {
|
||||
continue;
|
||||
}
|
||||
const entry = index.byModelKey.get(modelKey);
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadModelCatalogIndex(): ModelCatalogIndex {
|
||||
if (modelCatalogIndex) {
|
||||
return modelCatalogIndex;
|
||||
}
|
||||
|
||||
for (const candidate of modelCatalogPathCandidates()) {
|
||||
if (!existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as unknown;
|
||||
modelCatalogIndex = buildModelCatalogIndex(parsed, candidate);
|
||||
return modelCatalogIndex;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load model catalog from ${candidate}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
modelCatalogIndex = {
|
||||
byKey: new Map(),
|
||||
byModelKey: new Map()
|
||||
};
|
||||
return modelCatalogIndex;
|
||||
}
|
||||
|
||||
function modelCatalogPathCandidates(): string[] {
|
||||
return uniqueStrings([
|
||||
process.env.CCR_MODEL_CATALOG_PATH?.trim() || "",
|
||||
process.env.CCR_MODELS_JSON_PATH?.trim() || "",
|
||||
pathResolve(process.cwd(), "models.json"),
|
||||
pathResolve(__dirname, "..", "..", "..", "models.json")
|
||||
]);
|
||||
}
|
||||
|
||||
function buildModelCatalogIndex(payload: unknown, loadedFrom: string): ModelCatalogIndex {
|
||||
const byKey = new Map<string, ModelCatalogEntry>();
|
||||
const byModelKey = new Map<string, ModelCatalogEntry | undefined>();
|
||||
const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];
|
||||
|
||||
for (const item of models) {
|
||||
const entry = parseModelCatalogEntry(item);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const key of modelCatalogEntryKeys(entry)) {
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
|
||||
const shortKeys = uniqueStrings([
|
||||
entry.model ? normalizeModelCatalogToken(entry.model) : "",
|
||||
...entry.aliases.map((alias) => modelCatalogLastSegmentKey(normalizeModelCatalogKey(alias)))
|
||||
]);
|
||||
for (const key of shortKeys) {
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
if (byModelKey.has(key) && byModelKey.get(key) !== entry) {
|
||||
byModelKey.set(key, undefined);
|
||||
} else {
|
||||
byModelKey.set(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byKey, byModelKey, loadedFrom };
|
||||
}
|
||||
|
||||
function parseModelCatalogEntry(value: unknown): ModelCatalogEntry | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const id = stringValue(value.id);
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const aliases = uniqueStrings([id, ...stringListValue(value.aliases)]);
|
||||
const limits = parseModelCatalogLimits(value.limits);
|
||||
const modalities = parseModelCatalogModalities(value.modalities);
|
||||
return {
|
||||
aliases,
|
||||
capabilities: isRecord(value.capabilities) ? value.capabilities : undefined,
|
||||
displayName: stringValue(value.displayName),
|
||||
family: stringValue(value.family),
|
||||
id,
|
||||
limits,
|
||||
modalities,
|
||||
model: stringValue(value.model),
|
||||
providers: stringListValue(value.providers)
|
||||
};
|
||||
}
|
||||
|
||||
function parseModelCatalogLimits(value: unknown): ModelCatalogLimits | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const limits: ModelCatalogLimits = {
|
||||
contextTokens: readCatalogPositiveInteger(value.contextTokens),
|
||||
inputTokens: readCatalogPositiveInteger(value.inputTokens),
|
||||
maxTokens: readCatalogPositiveInteger(value.maxTokens),
|
||||
outputTokens: readCatalogPositiveInteger(value.outputTokens),
|
||||
supports1MContext: typeof value.supports1MContext === "boolean" ? value.supports1MContext : undefined
|
||||
};
|
||||
return Object.values(limits).some((item) => item !== undefined) ? limits : undefined;
|
||||
}
|
||||
|
||||
function parseModelCatalogModalities(value: unknown): ModelCatalogModalities | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const modalities: ModelCatalogModalities = {
|
||||
input: stringListValue(value.input),
|
||||
output: stringListValue(value.output)
|
||||
};
|
||||
return (modalities.input?.length || modalities.output?.length) ? modalities : undefined;
|
||||
}
|
||||
|
||||
function modelCatalogEntryKeys(entry: ModelCatalogEntry): string[] {
|
||||
return uniqueStrings([
|
||||
normalizeModelCatalogKey(entry.id),
|
||||
...entry.aliases.map(normalizeModelCatalogKey),
|
||||
...((entry.providers ?? []).map((provider) => entry.model ? normalizeModelCatalogKey(`${provider}/${entry.model}`) : ""))
|
||||
]);
|
||||
}
|
||||
|
||||
function modelCatalogLookupKeys(value: string): string[] {
|
||||
const raw = String(value || "").trim();
|
||||
const normalized = normalizeModelCatalogKey(raw);
|
||||
const withoutClaudePrefix = raw.toLowerCase().startsWith("claude-") && raw.includes("/")
|
||||
? normalizeModelCatalogKey(raw.replace(/^claude-/i, ""))
|
||||
: "";
|
||||
return uniqueStrings([normalized, withoutClaudePrefix]);
|
||||
}
|
||||
|
||||
function normalizeModelCatalogKey(value: string): string {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split("/")
|
||||
.map(normalizeModelCatalogToken)
|
||||
.filter(Boolean)
|
||||
.join("/");
|
||||
}
|
||||
|
||||
function normalizeModelCatalogToken(value: string): string {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.replace(/^hf:/i, "")
|
||||
.replace(/^@/, "")
|
||||
.replace(/[_\s]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function modelCatalogLastSegmentKey(value: string): string {
|
||||
return value.split("/").filter(Boolean).at(-1) ?? "";
|
||||
}
|
||||
|
||||
function modelCatalogMaxInputTokens(entry: ModelCatalogEntry | undefined): number {
|
||||
return Math.max(
|
||||
0,
|
||||
entry?.limits?.contextTokens ?? 0,
|
||||
entry?.limits?.inputTokens ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function claudeCodeEffectiveMaxInputTokens(entry: ModelCatalogEntry | undefined, oneMillionContext: boolean): number {
|
||||
const maxInputTokens = modelCatalogMaxInputTokens(entry);
|
||||
if (oneMillionContext) {
|
||||
return maxInputTokens || 1_000_000;
|
||||
}
|
||||
return maxInputTokens > 0 ? Math.min(maxInputTokens, claudeCodeDefaultContextTokens) : 0;
|
||||
}
|
||||
|
||||
function modelCatalogMaxOutputTokens(entry: ModelCatalogEntry | undefined): number {
|
||||
return Math.max(
|
||||
0,
|
||||
entry?.limits?.outputTokens ?? 0,
|
||||
entry?.limits?.maxTokens ?? 0
|
||||
);
|
||||
}
|
||||
|
||||
function readCatalogPositiveInteger(value: unknown): number | undefined {
|
||||
const parsed = numberValue(value);
|
||||
return parsed !== undefined && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] {
|
||||
const baseEntries: Array<{ modelName: string; providerName: string }> = [];
|
||||
for (const provider of config.Providers) {
|
||||
|
|
@ -2856,6 +3120,34 @@ function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] {
|
|||
return uniqueStrings(ids);
|
||||
}
|
||||
|
||||
function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] {
|
||||
const seen = new Set<string>();
|
||||
const models: ClaudeCodeDiscoverableModel[] = [];
|
||||
|
||||
const pushModel = (id: string, oneMillionContext: boolean) => {
|
||||
const normalized = id.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const key = normalized.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
models.push({ id: normalized, oneMillionContext });
|
||||
};
|
||||
|
||||
for (const id of buildClaudeCodeDiscoverableModelIds(config)) {
|
||||
pushModel(id, hasClaudeCodeOneMillionContextSuffix(id));
|
||||
const baseId = stripClaudeCodeOneMillionContextSuffix(id);
|
||||
if (!hasClaudeCodeOneMillionContextSuffix(id) && findModelCatalogEntry(baseId)?.limits?.supports1MContext) {
|
||||
pushModel(claudeCodeOneMillionContextModelId(baseId), true);
|
||||
}
|
||||
}
|
||||
|
||||
return models;
|
||||
}
|
||||
|
||||
function isVisibleVirtualModelProfile(profile: NonNullable<AppConfig["virtualModelProfiles"]>[number]): boolean {
|
||||
return profile.enabled !== false &&
|
||||
profile.materialization?.enabled !== false &&
|
||||
|
|
@ -2873,7 +3165,15 @@ function resolveClaudeCodeDiscoveredModelId(model: string | undefined, config: A
|
|||
}
|
||||
|
||||
const unprefixed = normalized.slice("claude-".length);
|
||||
return isConfiguredGatewayModelSelector(unprefixed, config) ? unprefixed : undefined;
|
||||
if (isConfiguredGatewayModelSelector(unprefixed, config)) {
|
||||
return unprefixed;
|
||||
}
|
||||
|
||||
const withoutOneMillionContextSuffix = stripClaudeCodeOneMillionContextSuffix(unprefixed);
|
||||
return withoutOneMillionContextSuffix !== unprefixed &&
|
||||
isConfiguredGatewayModelSelector(withoutOneMillionContextSuffix, config)
|
||||
? withoutOneMillionContextSuffix
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function isConfiguredGatewayModelSelector(model: string, config: AppConfig): boolean {
|
||||
|
|
@ -2901,18 +3201,112 @@ function claudeCodeDiscoveryModelId(value: string): string {
|
|||
return value.toLowerCase().startsWith("claude-") ? value : `claude-${value}`;
|
||||
}
|
||||
|
||||
function formatClaudeCodeModelDisplayName(id: string): string {
|
||||
const normalized = id.replace(/^claude-/i, "");
|
||||
function claudeCodeOneMillionContextModelId(id: string): string {
|
||||
return hasClaudeCodeOneMillionContextSuffix(id) ? id : `${id}${claudeCodeOneMillionContextSuffix}`;
|
||||
}
|
||||
|
||||
function hasClaudeCodeOneMillionContextSuffix(id: string): boolean {
|
||||
return id.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix);
|
||||
}
|
||||
|
||||
function stripClaudeCodeOneMillionContextSuffix(id: string): string {
|
||||
return id.trim().replace(/\[1m\]$/i, "").trim();
|
||||
}
|
||||
|
||||
function formatClaudeCodeModelDisplayName(
|
||||
id: string,
|
||||
entry?: ModelCatalogEntry,
|
||||
oneMillionContext = hasClaudeCodeOneMillionContextSuffix(id)
|
||||
): string {
|
||||
if (entry?.displayName) {
|
||||
return oneMillionContext ? `${entry.displayName} (1M context)` : entry.displayName;
|
||||
}
|
||||
|
||||
const normalized = stripClaudeCodeOneMillionContextSuffix(id.replace(/^claude-/i, ""));
|
||||
const model = normalized.includes("/") ? normalized.slice(normalized.lastIndexOf("/") + 1) : normalized;
|
||||
const words = model
|
||||
.split(/[-_]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => (/^\d+$/.test(part) ? part : part.slice(0, 1).toUpperCase() + part.slice(1)));
|
||||
return ["Claude", ...words].filter(Boolean).join(" ");
|
||||
const displayName = ["Claude", ...words].filter(Boolean).join(" ");
|
||||
return oneMillionContext ? `${displayName} (1M context)` : displayName;
|
||||
}
|
||||
|
||||
function createClaudeCodeModelCapabilities(): Record<string, unknown> {
|
||||
function createClaudeCodeModelCapabilities(
|
||||
entry?: ModelCatalogEntry,
|
||||
options: { maxInputTokens?: number; oneMillionContext?: boolean } = {}
|
||||
): Record<string, unknown> {
|
||||
if (!entry) {
|
||||
return createDefaultClaudeCodeModelCapabilities();
|
||||
}
|
||||
|
||||
const capabilities = entry.capabilities ?? {};
|
||||
const inputModalities = new Set((entry.modalities?.input ?? []).map((item) => item.toLowerCase()));
|
||||
const outputModalities = new Set((entry.modalities?.output ?? []).map((item) => item.toLowerCase()));
|
||||
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
|
||||
const supportsImageInput = readCatalogCapability(capabilities, "imageInput") || inputModalities.has("image");
|
||||
const supportsPdfInput = readCatalogCapability(capabilities, "pdfInput") || inputModalities.has("pdf");
|
||||
const supportsStructuredOutput =
|
||||
readCatalogCapability(capabilities, "structuredOutput") ||
|
||||
readCatalogCapability(capabilities, "nativeStructuredOutput") ||
|
||||
readCatalogCapability(capabilities, "responseSchema");
|
||||
const supportsCodeExecution = readCatalogCapability(capabilities, "codeExecution");
|
||||
const supportsAdaptiveThinking = readCatalogCapability(capabilities, "adaptiveThinking");
|
||||
const supportsToolUse =
|
||||
readCatalogCapability(capabilities, "toolCalling") ||
|
||||
readCatalogCapability(capabilities, "functionCalling");
|
||||
const supportsBatch = readCatalogCapability(capabilities, "batch");
|
||||
const supportsCitations = readCatalogCapability(capabilities, "citations");
|
||||
const supportsAudioInput = readCatalogCapability(capabilities, "audioInput") || inputModalities.has("audio");
|
||||
const supportsAudioOutput = readCatalogCapability(capabilities, "audioOutput") || outputModalities.has("audio");
|
||||
const supportsVideoInput = readCatalogCapability(capabilities, "videoInput") || inputModalities.has("video");
|
||||
const maxInputTokens = options.maxInputTokens ?? modelCatalogMaxInputTokens(entry);
|
||||
const supportsOneMillionContext = Boolean(entry.limits?.supports1MContext);
|
||||
|
||||
return {
|
||||
audio_input: { supported: supportsAudioInput },
|
||||
audio_output: { supported: supportsAudioOutput },
|
||||
batch: { supported: supportsBatch },
|
||||
citations: { supported: supportsCitations },
|
||||
code_execution: { supported: supportsCodeExecution },
|
||||
context_management: {
|
||||
clear_thinking_20251015: { supported: supportsReasoning },
|
||||
clear_tool_uses_20250919: { supported: supportsToolUse },
|
||||
compact_20260112: { supported: maxInputTokens > 0 },
|
||||
max_input_tokens: maxInputTokens,
|
||||
supported: maxInputTokens > 0
|
||||
},
|
||||
context_window: {
|
||||
max_input_tokens: maxInputTokens,
|
||||
supported: maxInputTokens > 0,
|
||||
supports_1m_context: supportsOneMillionContext,
|
||||
one_million_context_variant: options.oneMillionContext === true
|
||||
},
|
||||
effort: {
|
||||
high: { supported: supportsReasoning },
|
||||
low: { supported: supportsReasoning },
|
||||
max: { supported: supportsReasoning },
|
||||
medium: { supported: supportsReasoning },
|
||||
supported: supportsReasoning,
|
||||
xhigh: { supported: supportsReasoning }
|
||||
},
|
||||
image_input: { supported: supportsImageInput },
|
||||
pdf_input: { supported: supportsPdfInput },
|
||||
structured_outputs: { supported: supportsStructuredOutput },
|
||||
thinking: {
|
||||
supported: supportsReasoning,
|
||||
types: {
|
||||
adaptive: { supported: supportsAdaptiveThinking },
|
||||
enabled: { supported: supportsReasoning }
|
||||
}
|
||||
},
|
||||
tool_use: { supported: supportsToolUse },
|
||||
video_input: { supported: supportsVideoInput }
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultClaudeCodeModelCapabilities(): Record<string, unknown> {
|
||||
return {
|
||||
batch: { supported: true },
|
||||
citations: { supported: true },
|
||||
|
|
@ -2944,6 +3338,10 @@ function createClaudeCodeModelCapabilities(): Record<string, unknown> {
|
|||
};
|
||||
}
|
||||
|
||||
function readCatalogCapability(capabilities: ModelCatalogCapabilities, key: string): boolean {
|
||||
return capabilities[key] === true;
|
||||
}
|
||||
|
||||
function normalizeGatewayPathname(path: string): string {
|
||||
const normalized = path.trim().replace(/\/+$/, "");
|
||||
return normalized || "/";
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Check, ChevronLeft, ChevronRight, CircleAlert, cn, compactId,
|
||||
compactUserAgent, compareProviderAccountSnapshots, ComposedChart, CSS, DEFAULT_OVERVIEW_WIDGETS, DndContext,
|
||||
DragEndEvent, DragOverEvent, DragOverlay, DragStartEvent, Field, formatAxisNumber,
|
||||
formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterValue, formatProviderAccountReset,
|
||||
formatCompactNumber, formatDuration, formatLogDateTime, formatPercent, formatProviderAccountMeterValue, formatProviderAccountSchedule,
|
||||
formatStatusBucketDate, formatStatusCodeCounts, formatSystemStatusRange, formatToolCounts, formatUsdCost, KeyboardSensor,
|
||||
LabelList, LayoutGroup, Line, MeasuringStrategy, MetricCard, MetricTone,
|
||||
metricToneBar, metricToneStroke, motion, normalizeAgentFilterValue, normalizeOverviewWidget, normalizeOverviewWidgets,
|
||||
|
|
@ -1711,6 +1711,7 @@ function ProviderAccountMeterLine({
|
|||
}) {
|
||||
const t = useAppText();
|
||||
const progress = isProviderAccountQuotaMeter(meter) ? providerAccountMeterProgress(meter) : undefined;
|
||||
const schedule = formatProviderAccountSchedule(meter, t);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
|
|
@ -1723,8 +1724,8 @@ function ProviderAccountMeterLine({
|
|||
<div className={cn("h-full rounded-full", providerAccountProgressClass(account.status))} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{meter.resetAt && providerAccountShowReset(dimensions) ? (
|
||||
<div className="mt-1 truncate text-[10px] text-muted-foreground">{t("Resets")} {formatProviderAccountReset(meter.resetAt)}</div>
|
||||
{schedule && providerAccountShowReset(dimensions) ? (
|
||||
<div className="mt-1 truncate text-[10px] text-muted-foreground">{schedule}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -1775,15 +1776,18 @@ function ProviderAccountQuotaVisual({
|
|||
<ProviderAccountQuotaGauge account={account} dimensions={dimensions} meters={displayMeters} variant={variant} />
|
||||
{showLabels ? (
|
||||
<div className="min-w-0 space-y-2">
|
||||
{displayMeters.slice(0, variant === "nested-rings" ? 2 : 1).map((meter) => (
|
||||
<div className="min-w-0" key={meter.id}>
|
||||
<div className="truncate text-[12px] font-medium text-muted-foreground">{t(meter.label)}</div>
|
||||
<div className="truncate text-[17px] font-semibold tracking-tight">{formatProviderAccountMeterValue(meter)}</div>
|
||||
{meter.resetAt && providerAccountShowReset(dimensions) ? (
|
||||
<div className="truncate text-[10px] text-muted-foreground">{t("Resets")} {formatProviderAccountReset(meter.resetAt)}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{displayMeters.slice(0, variant === "nested-rings" ? 2 : 1).map((meter) => {
|
||||
const schedule = formatProviderAccountSchedule(meter, t);
|
||||
return (
|
||||
<div className="min-w-0" key={meter.id}>
|
||||
<div className="truncate text-[12px] font-medium text-muted-foreground">{t(meter.label)}</div>
|
||||
<div className="truncate text-[17px] font-semibold tracking-tight">{formatProviderAccountMeterValue(meter)}</div>
|
||||
{schedule && providerAccountShowReset(dimensions) ? (
|
||||
<div className="truncate text-[10px] text-muted-foreground">{schedule}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -1801,6 +1805,7 @@ function ProviderAccountQuotaGauge({
|
|||
meters: ProviderAccountMeter[];
|
||||
variant: OverviewAccountVariant;
|
||||
}) {
|
||||
const t = useAppText();
|
||||
const primary = meters[0];
|
||||
const secondary = meters[1];
|
||||
const primaryRatio = providerAccountMeterRatio(primary) ?? 0;
|
||||
|
|
@ -1838,7 +1843,7 @@ function ProviderAccountQuotaGauge({
|
|||
<svg aria-hidden="true" className={sizeClass} viewBox="0 0 120 120">
|
||||
<ProviderAccountQuotaCircle cx={60} cy={60} ratio={primaryRatio} radius={40} stroke={stroke} strokeWidth={10} />
|
||||
<text className="fill-foreground text-[20px] font-semibold" dy="0.35em" textAnchor="middle" x="60" y={dimensions.height >= 2 ? "57" : "60"}>{formatProviderAccountMeterValue(primary)}</text>
|
||||
{dimensions.height >= 2 ? <text className="fill-muted-foreground text-[10px] font-medium" dy="0.35em" textAnchor="middle" x="60" y="75">{primary.window ?? ""}</text> : null}
|
||||
{dimensions.height >= 2 ? <text className="fill-muted-foreground text-[10px] font-medium" dy="0.35em" textAnchor="middle" x="60" y="75">{formatProviderAccountSchedule(primary, t) ?? ""}</text> : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import {
|
|||
Check, Checkbox, ChevronDown, ChevronRight, CircleAlert, cn,
|
||||
Copy, copyTextToClipboard, createDefaultProviderAccountDraft, createModelCatalogItems, createProviderAccountDraftFromConfig, createProviderInstallLinkFromDraft,
|
||||
customProviderPresetId, defaultProviderAccountConfigForPreset, Dialog, DialogBody, DialogContent, DialogFooter,
|
||||
DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, GatewayProviderConfig,
|
||||
DialogHeader, DialogTitle, Field, findProviderPreset, formatProviderAccountMeterValue, formatProviderAccountSchedule, GatewayProviderConfig,
|
||||
GatewayProviderProbeResult, Globe, inferProviderNameFromBaseUrl, Input, KeyValueRowsControl, Label,
|
||||
Layers3, LoaderCircle, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion, motionEase,
|
||||
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint, providerAccountBadgeVariant,
|
||||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot, ProviderAccountTestPath,
|
||||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountMeter, ProviderAccountSnapshot, ProviderAccountTestPath,
|
||||
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerHttpJsonConnectorFromDraft,
|
||||
ProviderConnectivityCheckReport, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerPresets, providerProbeHasSupportedProtocol,
|
||||
providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, reducedMotionTransition, Search, SelectControl,
|
||||
|
|
@ -320,6 +320,7 @@ export function ModelsView({ config }: { config: AppConfig }) {
|
|||
function ProviderAccountListCell({ provider, snapshot }: { provider: GatewayProviderConfig; snapshot?: ProviderAccountSnapshot }) {
|
||||
const t = useAppText();
|
||||
const meter = snapshot ? primaryProviderAccountMeter(snapshot) : undefined;
|
||||
const displayMeters = snapshot ? providerAccountListMeters(snapshot) : [];
|
||||
|
||||
if (!provider.account?.enabled) {
|
||||
return <div className="min-w-0 truncate text-[11px] text-muted-foreground">{t("Disabled")}</div>;
|
||||
|
|
@ -335,13 +336,34 @@ function ProviderAccountListCell({ provider, snapshot }: { provider: GatewayProv
|
|||
<Badge variant={providerAccountBadgeVariant(snapshot.status)}>{snapshot.status}</Badge>
|
||||
{meter ? <span className="min-w-0 truncate text-[11px] font-medium">{formatProviderAccountMeterValue(meter)}</span> : null}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{meter?.label ?? snapshot.message ?? snapshot.errors?.[0]?.message ?? snapshot.source}
|
||||
</div>
|
||||
{displayMeters.length > 0 ? (
|
||||
<div className="mt-0.5 space-y-0.5">
|
||||
{displayMeters.map((item) => (
|
||||
<div className="truncate text-[10px] text-muted-foreground" key={item.id} title={providerAccountMeterScheduleTitle(item, t)}>
|
||||
{providerAccountMeterScheduleTitle(item, t)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||
{snapshot.message ?? snapshot.errors?.[0]?.message ?? snapshot.source}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function providerAccountListMeters(snapshot: ProviderAccountSnapshot): ProviderAccountMeter[] {
|
||||
const scheduledMeters = snapshot.meters.filter((item) => item.resetAt || item.window);
|
||||
return (scheduledMeters.length > 0 ? scheduledMeters : snapshot.meters).slice(0, 2);
|
||||
}
|
||||
|
||||
function providerAccountMeterScheduleTitle(meter: ProviderAccountMeter, translate: (value: string) => string): string {
|
||||
const label = translate(meter.label);
|
||||
const schedule = formatProviderAccountSchedule(meter, translate);
|
||||
return [label, schedule].filter(Boolean).join(" / ");
|
||||
}
|
||||
|
||||
export function DeleteProviderDialog({
|
||||
onClose,
|
||||
onConfirm,
|
||||
|
|
@ -1627,9 +1649,8 @@ function ProviderConnectivityResultGroup({
|
|||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<div className="mb-1 min-w-0 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{label}</span>
|
||||
<span className="shrink-0">{items.length}</span>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-background/70 px-2 py-2 text-center text-[11px] text-muted-foreground">{emptyLabel}</div>
|
||||
|
|
|
|||
|
|
@ -1464,6 +1464,9 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
"Refresh interval ms": "刷新间隔(毫秒)",
|
||||
"Response fields": "响应字段",
|
||||
"Reset": "重置时间",
|
||||
"Daily": "每日",
|
||||
"Weekly": "每周",
|
||||
"Monthly": "每月",
|
||||
"Select at least one protocol.": "请至少选择一个协议。",
|
||||
"Select at least one usage response field.": "请至少选择一个用量响应字段。",
|
||||
"Showing first response fields only.": "仅显示前面的响应字段。",
|
||||
|
|
@ -4767,6 +4770,29 @@ export function formatProviderAccountReset(value: string): string {
|
|||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
export function formatProviderAccountSchedule(meter: ProviderAccountMeter, translate: (value: string) => string): string | undefined {
|
||||
const windowLabel = providerAccountWindowLabel(meter.window, translate);
|
||||
const resetLabel = meter.resetAt ? formatProviderAccountReset(meter.resetAt) : undefined;
|
||||
return [windowLabel, resetLabel].filter(Boolean).join(" · ") || undefined;
|
||||
}
|
||||
|
||||
export function providerAccountWindowLabel(value: string | undefined, translate: (value: string) => string): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "daily") {
|
||||
return translate("Daily");
|
||||
}
|
||||
if (normalized === "weekly") {
|
||||
return translate("Weekly");
|
||||
}
|
||||
if (normalized === "monthly") {
|
||||
return translate("Monthly");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function formatAxisNumber(value: number): string {
|
||||
return formatCompactNumber(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {
|
||||
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountStatusClass, compareAccountSnapshots, formatAccountMeterValue,
|
||||
accountMetersForDisplay, accountProgressClass, accountProgressColor, accountStatusClass, compareAccountSnapshots, formatAccountMeterSchedule, formatAccountMeterValue,
|
||||
meterProgress, meterRemainingRatio, ProviderAccountMeter, ProviderAccountSnapshot, translateAccountMeterLabel, TrayComponentVariants,
|
||||
useTrayText
|
||||
} from "../shared";
|
||||
|
|
@ -60,12 +60,16 @@ function AccountMeters({
|
|||
if (variant === "compact") {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{meters.map((meter) => (
|
||||
<div className="min-w-0 rounded-md bg-white/[.04] px-2 py-1" key={meter.id}>
|
||||
<div className="truncate text-[9px] font-medium text-slate-400">{translateAccountMeterLabel(meter.label, t)}</div>
|
||||
<div className="truncate text-[12px] font-bold text-slate-50">{formatAccountMeterValue(meter, t)}</div>
|
||||
</div>
|
||||
))}
|
||||
{meters.map((meter) => {
|
||||
const schedule = formatAccountMeterSchedule(meter, t);
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-white/[.04] px-2 py-1" key={meter.id}>
|
||||
<div className="truncate text-[9px] font-medium text-slate-400">{translateAccountMeterLabel(meter.label, t)}</div>
|
||||
<div className="truncate text-[12px] font-bold text-slate-50">{formatAccountMeterValue(meter, t)}</div>
|
||||
{schedule ? <div className="truncate text-[8px] font-medium text-slate-500">{schedule}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,10 +89,12 @@ function AccountMeters({
|
|||
<div className="space-y-1.5">
|
||||
{meters.map((meter) => {
|
||||
const progress = meterProgress(meter);
|
||||
const schedule = formatAccountMeterSchedule(meter, t);
|
||||
return (
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_56px] items-center gap-2" key={meter.id}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[10px] font-medium text-slate-400">{translateAccountMeterLabel(meter.label, t)}</div>
|
||||
{schedule ? <div className="truncate text-[8px] font-medium text-slate-500">{schedule}</div> : null}
|
||||
{progress !== undefined ? (
|
||||
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-white/10">
|
||||
<div className={`h-full rounded-full ${progressClass}`} style={{ width: `${progress}%` }} />
|
||||
|
|
@ -107,6 +113,7 @@ function AccountMeters({
|
|||
<div className="space-y-1.5">
|
||||
{meters.map((meter) => {
|
||||
const progress = meterProgress(meter);
|
||||
const schedule = formatAccountMeterSchedule(meter, t);
|
||||
return (
|
||||
<div className="min-w-0" key={meter.id}>
|
||||
<div className="flex min-w-0 items-end justify-between gap-2">
|
||||
|
|
@ -118,6 +125,7 @@ function AccountMeters({
|
|||
<div className={`h-full rounded-full ${progressClass}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
{schedule ? <div className="mt-0.5 truncate text-[8px] font-medium text-slate-500">{schedule}</div> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
@ -137,10 +145,12 @@ function AccountMeterGauge({
|
|||
const t = useTrayText();
|
||||
const ratio = meterRemainingRatio(meter) ?? 0;
|
||||
const color = accountProgressColor(status);
|
||||
const schedule = formatAccountMeterSchedule(meter, t);
|
||||
return (
|
||||
<div className="min-w-0 text-center">
|
||||
<RadialMetric color={color} label={formatAccountMeterValue(meter, t)} value={ratio} variant={variant} />
|
||||
<div className="mt-0.5 truncate text-[9px] font-medium text-slate-400">{translateAccountMeterLabel(meter.label, t)}</div>
|
||||
{schedule ? <div className="truncate text-[8px] font-medium text-slate-500">{schedule}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,12 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
|
|||
"Cost": "成本",
|
||||
"Credit balance": "信用余额",
|
||||
"Current balance": "当前余额",
|
||||
"Daily": "每日",
|
||||
"5h quota": "5 小时额度",
|
||||
"Granted balance": "赠送余额",
|
||||
"Input": "输入",
|
||||
"Monthly budget": "月度预算",
|
||||
"Monthly": "每月",
|
||||
"Model Share": "模型占比",
|
||||
"No account data configured": "未配置账户数据",
|
||||
"No model yet": "暂无模型",
|
||||
|
|
@ -90,6 +92,8 @@ export const trayText: Record<ResolvedLanguage, Record<string, string>> = {
|
|||
"Unavailable": "不可用",
|
||||
"Updated just now": "刚刚更新",
|
||||
"Voucher balance": "代金券余额",
|
||||
"Weekly": "每周",
|
||||
"Weekly quota": "周额度",
|
||||
"Usage Detail": "用量详情",
|
||||
"Usage Overview": "用量概览",
|
||||
"Usage chart": "用量图表",
|
||||
|
|
@ -685,6 +689,29 @@ export function formatAccountReset(value: string): string {
|
|||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
export function formatAccountMeterSchedule(meter: ProviderAccountMeter, translate: (value: string) => string): string | undefined {
|
||||
const windowLabel = accountMeterWindowLabel(meter.window, translate);
|
||||
const resetLabel = meter.resetAt ? formatAccountReset(meter.resetAt) : undefined;
|
||||
return [windowLabel, resetLabel].filter(Boolean).join(" · ") || undefined;
|
||||
}
|
||||
|
||||
function accountMeterWindowLabel(value: string | undefined, translate: (value: string) => string): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "daily") {
|
||||
return translate("Daily");
|
||||
}
|
||||
if (normalized === "weekly") {
|
||||
return translate("Weekly");
|
||||
}
|
||||
if (normalized === "monthly") {
|
||||
return translate("Monthly");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function accountStatusClass(status: ProviderAccountSnapshot["status"]): string {
|
||||
if (status === "critical" || status === "error") {
|
||||
return "bg-rose-400/15 text-rose-100";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue