Add catalog-backed Claude Code model metadata

This commit is contained in:
musistudio
2026-06-20 16:17:09 +08:00
parent a75c6de7d1
commit e558a7c4ad
8 changed files with 492315 additions and 39 deletions
+490722
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+410 -12
View File
@@ -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>
+26
View File
@@ -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>
);
}
+27
View File
@@ -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";