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
models.json Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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 || "/";

View file

@ -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>
);
}

View file

@ -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>

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);
}

View file

@ -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>
);
}

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";