Compare commits

..

20 Commits

46 changed files with 4458 additions and 195 deletions
+29 -18
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { HashRouter, Route, Routes } from 'react-router-dom';
import { Outlet, RouterProvider, createHashRouter } from 'react-router-dom';
import { LoginPage } from '@/pages/LoginPage';
import { NotificationContainer } from '@/components/common/NotificationContainer';
import { ConfirmationModal } from '@/components/common/ConfirmationModal';
@@ -7,6 +7,33 @@ import { MainLayout } from '@/components/layout/MainLayout';
import { ProtectedRoute } from '@/router/ProtectedRoute';
import { useLanguageStore, useThemeStore } from '@/stores';
function RootShell() {
return (
<>
<NotificationContainer />
<ConfirmationModal />
<Outlet />
</>
);
}
const router = createHashRouter([
{
element: <RootShell />,
children: [
{ path: '/login', element: <LoginPage /> },
{
path: '/*',
element: (
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
),
},
],
},
]);
function App() {
const initializeTheme = useThemeStore((state) => state.initializeTheme);
const language = useLanguageStore((state) => state.language);
@@ -26,23 +53,7 @@ function App() {
document.documentElement.lang = language;
}, [language]);
return (
<HashRouter>
<NotificationContainer />
<ConfirmationModal />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/*"
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
/>
</Routes>
</HashRouter>
);
return <RouterProvider router={router} />;
}
export default App;
@@ -111,6 +111,12 @@ export function ClaudeSection({
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
@@ -129,6 +135,32 @@ export function ClaudeSection({
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{item.cloak && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.claude_cloak_mode_label')}:</span>
<span className={styles.fieldValue}>
{(() => {
const raw = (item.cloak?.mode ?? '').trim().toLowerCase();
const key = raw === 'always' || raw === 'never' ? raw : 'auto';
return t(`ai_providers.claude_cloak_mode_${key}`);
})()}
</span>
</div>
)}
{item.cloak?.strictMode ? (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.claude_cloak_strict_label')}:</span>
<span className={styles.fieldValue}>{t('common.yes')}</span>
</div>
) : null}
{item.cloak?.sensitiveWords?.length ? (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>
{t('ai_providers.claude_cloak_sensitive_words_count')}:
</span>
<span className={styles.fieldValue}>{item.cloak.sensitiveWords.length}</span>
</div>
) : null}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
@@ -118,6 +118,12 @@ export function CodexSection({
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
@@ -136,6 +142,12 @@ export function CodexSection({
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{item.websockets !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.codex_websockets_label')}:</span>
<span className={styles.fieldValue}>{item.websockets ? t('common.yes') : t('common.no')}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
@@ -150,6 +162,21 @@ export function CodexSection({
{t('ai_providers.config_disabled_badge')}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.codex_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
@@ -113,6 +113,12 @@ export function GeminiSection({
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
@@ -145,6 +151,21 @@ export function GeminiSection({
{t('ai_providers.config_disabled_badge')}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.gemini_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={model.name} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && model.alias !== model.name && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
@@ -102,6 +102,12 @@ export function OpenAISection({
return (
<Fragment>
<div className="item-title">{item.name}</div>
{item.priority !== undefined && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.priority')}:</span>
<span className={styles.fieldValue}>{item.priority}</span>
</div>
)}
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
@@ -1,37 +1,26 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback } from 'react';
import { useInterval } from '@/hooks/useInterval';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
const EMPTY_STATS: KeyStats = { bySource: {}, byAuthIndex: {} };
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
export const useProviderStats = () => {
const [keyStats, setKeyStats] = useState<KeyStats>(EMPTY_STATS);
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const [isLoading, setIsLoading] = useState(false);
const loadingRef = useRef(false);
const keyStats = useUsageStatsStore((state) => state.keyStats);
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
const isLoading = useUsageStatsStore((state) => state.loading);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
// 加载 key 统计和 usage 明细(API 层已有60秒超时)
// 首次进入页面优先复用缓存,避免跨页面重复拉取 /usage。
const loadKeyStats = useCallback(async () => {
if (loadingRef.current) return;
loadingRef.current = true;
setIsLoading(true);
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
setUsageDetails(collectUsageDetails(usageData));
} catch {
// 静默失败
} finally {
loadingRef.current = false;
setIsLoading(false);
}
}, []);
await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
// 定时刷新状态数据(每240秒)
useInterval(loadKeyStats, 240_000);
// 定时器触发时强制刷新共享 usage。
const refreshKeyStats = useCallback(async () => {
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
return { keyStats, usageDetails, loadKeyStats, isLoading };
useInterval(() => {
void refreshKeyStats();
}, 240_000);
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats, isLoading };
};
+3 -1
View File
@@ -9,6 +9,7 @@ export interface ModelEntry {
export interface OpenAIFormState {
name: string;
priority?: number;
prefix: string;
baseUrl: string;
headers: HeaderEntry[];
@@ -24,8 +25,9 @@ export interface AmpcodeFormState {
mappingEntries: ModelEntry[];
}
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
export type GeminiFormState = Omit<GeminiKeyConfig, 'headers' | 'models'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
excludedText: string;
};
@@ -0,0 +1,553 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { Select } from '@/components/ui/Select';
import { authFilesApi } from '@/services/api/authFiles';
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from '@/types';
import type { AuthFileItem } from '@/types/authFile';
import {
buildCandidateUsageSourceIds,
collectUsageDetails,
extractTotalTokens
} from '@/utils/usage';
import styles from '@/pages/UsagePage.module.scss';
const ALL_FILTER = '__all__';
const MAX_RENDERED_EVENTS = 500;
type RequestEventRow = {
id: string;
timestamp: string;
timestampMs: number;
timestampLabel: string;
model: string;
sourceRaw: string;
source: string;
sourceType: string;
authIndex: string;
failed: boolean;
inputTokens: number;
outputTokens: number;
reasoningTokens: number;
cachedTokens: number;
totalTokens: number;
};
export interface RequestEventsDetailsCardProps {
usage: unknown;
loading: boolean;
geminiKeys: GeminiKeyConfig[];
claudeConfigs: ProviderKeyConfig[];
codexConfigs: ProviderKeyConfig[];
vertexConfigs: ProviderKeyConfig[];
openaiProviders: OpenAIProviderConfig[];
}
const toNumber = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return parsed;
};
const encodeCsv = (value: string | number): string => {
const text = String(value ?? '');
return `"${text.replace(/"/g, '""')}"`;
};
const downloadFile = (filename: string, content: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
};
type CredentialInfo = {
name: string;
type: string;
};
type SourceInfo = {
displayName: string;
type: string;
};
function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
}
export function RequestEventsDetailsCard({
usage,
loading,
geminiKeys,
claudeConfigs,
codexConfigs,
vertexConfigs,
openaiProviders
}: RequestEventsDetailsCardProps) {
const { t, i18n } = useTranslation();
const [modelFilter, setModelFilter] = useState(ALL_FILTER);
const [sourceFilter, setSourceFilter] = useState(ALL_FILTER);
const [authIndexFilter, setAuthIndexFilter] = useState(ALL_FILTER);
const [authFileMap, setAuthFileMap] = useState<Map<string, CredentialInfo>>(new Map());
useEffect(() => {
let cancelled = false;
authFilesApi
.list()
.then((res) => {
if (cancelled) return;
const files = Array.isArray(res) ? res : (res as { files?: AuthFileItem[] })?.files;
if (!Array.isArray(files)) return;
const map = new Map<string, CredentialInfo>();
files.forEach((file) => {
const key = normalizeAuthIndexValue(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
});
});
setAuthFileMap(map);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const sourceInfoMap = useMemo(() => {
const map = new Map<string, SourceInfo>();
const registerSource = (sourceId: string, displayName: string, type: string) => {
if (!sourceId || !displayName) return;
if (map.has(sourceId)) return;
map.set(sourceId, { displayName, type });
};
const registerCandidates = (
displayName: string,
type: string,
candidates: string[]
) => {
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
};
geminiKeys.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Gemini #${index + 1}`;
registerCandidates(
displayName,
'gemini',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
claudeConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Claude #${index + 1}`;
registerCandidates(
displayName,
'claude',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
codexConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Codex #${index + 1}`;
registerCandidates(
displayName,
'codex',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
vertexConfigs.forEach((config, index) => {
const displayName = config.prefix?.trim() || `Vertex #${index + 1}`;
registerCandidates(
displayName,
'vertex',
buildCandidateUsageSourceIds({ apiKey: config.apiKey, prefix: config.prefix })
);
});
openaiProviders.forEach((provider, providerIndex) => {
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
candidates.add(sourceId)
);
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
candidates.add(sourceId)
);
});
registerCandidates(displayName, 'openai', Array.from(candidates));
});
return map;
}, [claudeConfigs, codexConfigs, geminiKeys, openaiProviders, vertexConfigs]);
const rows = useMemo<RequestEventRow[]>(() => {
const details = collectUsageDetails(usage);
return details
.map((detail, index) => {
const timestamp = typeof detail.timestamp === 'string' ? detail.timestamp : '';
const timestampMs = Date.parse(timestamp);
const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs);
const sourceRaw = String(detail.source ?? '').trim();
const authIndexRaw = detail.auth_index as unknown;
const authIndex =
authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === ''
? '-'
: String(authIndexRaw);
const normalizedAuthIndex = normalizeAuthIndexValue(authIndexRaw);
const sourceInfo = sourceInfoMap.get(sourceRaw);
const authInfo = normalizedAuthIndex ? authFileMap.get(normalizedAuthIndex) : undefined;
const source = sourceInfo?.displayName
|| authInfo?.name
|| (sourceRaw.startsWith('t:') ? sourceRaw.slice(2) : sourceRaw || '-');
const sourceType = sourceInfo?.type || authInfo?.type || '';
const model = String(detail.__modelName ?? '').trim() || '-';
const inputTokens = Math.max(toNumber(detail.tokens?.input_tokens), 0);
const outputTokens = Math.max(toNumber(detail.tokens?.output_tokens), 0);
const reasoningTokens = Math.max(toNumber(detail.tokens?.reasoning_tokens), 0);
const cachedTokens = Math.max(
Math.max(toNumber(detail.tokens?.cached_tokens), 0),
Math.max(toNumber(detail.tokens?.cache_tokens), 0)
);
const totalTokens = Math.max(
toNumber(detail.tokens?.total_tokens),
extractTotalTokens(detail)
);
return {
id: `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`,
timestamp,
timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-',
model,
sourceRaw: sourceRaw || '-',
source,
sourceType,
authIndex,
failed: detail.failed === true,
inputTokens,
outputTokens,
reasoningTokens,
cachedTokens,
totalTokens
};
})
.sort((a, b) => b.timestampMs - a.timestampMs);
}, [authFileMap, i18n.language, sourceInfoMap, usage]);
const modelOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.model))).map((model) => ({
value: model,
label: model
}))
],
[rows, t]
);
const sourceOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.source))).map((source) => ({
value: source,
label: source
}))
],
[rows, t]
);
const authIndexOptions = useMemo(
() => [
{ value: ALL_FILTER, label: t('usage_stats.filter_all') },
...Array.from(new Set(rows.map((row) => row.authIndex))).map((authIndex) => ({
value: authIndex,
label: authIndex
}))
],
[rows, t]
);
const modelOptionSet = useMemo(
() => new Set(modelOptions.map((option) => option.value)),
[modelOptions]
);
const sourceOptionSet = useMemo(
() => new Set(sourceOptions.map((option) => option.value)),
[sourceOptions]
);
const authIndexOptionSet = useMemo(
() => new Set(authIndexOptions.map((option) => option.value)),
[authIndexOptions]
);
const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER;
const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER;
const effectiveAuthIndexFilter = authIndexOptionSet.has(authIndexFilter)
? authIndexFilter
: ALL_FILTER;
const filteredRows = useMemo(
() =>
rows.filter((row) => {
const modelMatched = effectiveModelFilter === ALL_FILTER || row.model === effectiveModelFilter;
const sourceMatched = effectiveSourceFilter === ALL_FILTER || row.source === effectiveSourceFilter;
const authIndexMatched =
effectiveAuthIndexFilter === ALL_FILTER || row.authIndex === effectiveAuthIndexFilter;
return modelMatched && sourceMatched && authIndexMatched;
}),
[effectiveAuthIndexFilter, effectiveModelFilter, effectiveSourceFilter, rows]
);
const renderedRows = useMemo(
() => filteredRows.slice(0, MAX_RENDERED_EVENTS),
[filteredRows]
);
const hasActiveFilters =
effectiveModelFilter !== ALL_FILTER ||
effectiveSourceFilter !== ALL_FILTER ||
effectiveAuthIndexFilter !== ALL_FILTER;
const handleClearFilters = () => {
setModelFilter(ALL_FILTER);
setSourceFilter(ALL_FILTER);
setAuthIndexFilter(ALL_FILTER);
};
const handleExportCsv = () => {
if (!filteredRows.length) return;
const csvHeader = [
'timestamp',
'model',
'source',
'source_raw',
'auth_index',
'result',
'input_tokens',
'output_tokens',
'reasoning_tokens',
'cached_tokens',
'total_tokens'
];
const csvRows = filteredRows.map((row) =>
[
row.timestamp,
row.model,
row.source,
row.sourceRaw,
row.authIndex,
row.failed ? 'failed' : 'success',
row.inputTokens,
row.outputTokens,
row.reasoningTokens,
row.cachedTokens,
row.totalTokens
]
.map((value) => encodeCsv(value))
.join(',')
);
const content = [csvHeader.join(','), ...csvRows].join('\n');
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.csv`, content, 'text/csv;charset=utf-8');
};
const handleExportJson = () => {
if (!filteredRows.length) return;
const payload = filteredRows.map((row) => ({
timestamp: row.timestamp,
model: row.model,
source: row.source,
source_raw: row.sourceRaw,
auth_index: row.authIndex,
failed: row.failed,
tokens: {
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
reasoning_tokens: row.reasoningTokens,
cached_tokens: row.cachedTokens,
total_tokens: row.totalTokens
}
}));
const content = JSON.stringify(payload, null, 2);
const fileTime = new Date().toISOString().replace(/[:.]/g, '-');
downloadFile(`usage-events-${fileTime}.json`, content, 'application/json;charset=utf-8');
};
return (
<Card
title={t('usage_stats.request_events_title')}
extra={
<div className={styles.requestEventsActions}>
<Button
variant="ghost"
size="sm"
onClick={handleClearFilters}
disabled={!hasActiveFilters}
>
{t('usage_stats.clear_filters')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportCsv}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_csv')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleExportJson}
disabled={filteredRows.length === 0}
>
{t('usage_stats.export_json')}
</Button>
</div>
}
>
<div className={styles.requestEventsToolbar}>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_model')}
</span>
<Select
value={effectiveModelFilter}
options={modelOptions}
onChange={setModelFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_model')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_source')}
</span>
<Select
value={effectiveSourceFilter}
options={sourceOptions}
onChange={setSourceFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_source')}
fullWidth={false}
/>
</div>
<div className={styles.requestEventsFilterItem}>
<span className={styles.requestEventsFilterLabel}>
{t('usage_stats.request_events_filter_auth_index')}
</span>
<Select
value={effectiveAuthIndexFilter}
options={authIndexOptions}
onChange={setAuthIndexFilter}
className={styles.requestEventsSelect}
ariaLabel={t('usage_stats.request_events_filter_auth_index')}
fullWidth={false}
/>
</div>
</div>
{loading && rows.length === 0 ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : rows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_empty_title')}
description={t('usage_stats.request_events_empty_desc')}
/>
) : filteredRows.length === 0 ? (
<EmptyState
title={t('usage_stats.request_events_no_result_title')}
description={t('usage_stats.request_events_no_result_desc')}
/>
) : (
<>
<div className={styles.requestEventsMeta}>
<span>{t('usage_stats.request_events_count', { count: filteredRows.length })}</span>
{filteredRows.length > MAX_RENDERED_EVENTS && (
<span className={styles.requestEventsLimitHint}>
{t('usage_stats.request_events_limit_hint', {
shown: MAX_RENDERED_EVENTS,
total: filteredRows.length
})}
</span>
)}
</div>
<div className={styles.requestEventsTableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>{t('usage_stats.request_events_timestamp')}</th>
<th>{t('usage_stats.model_name')}</th>
<th>{t('usage_stats.request_events_source')}</th>
<th>{t('usage_stats.request_events_auth_index')}</th>
<th>{t('usage_stats.request_events_result')}</th>
<th>{t('usage_stats.input_tokens')}</th>
<th>{t('usage_stats.output_tokens')}</th>
<th>{t('usage_stats.reasoning_tokens')}</th>
<th>{t('usage_stats.cached_tokens')}</th>
<th>{t('usage_stats.total_tokens')}</th>
</tr>
</thead>
<tbody>
{renderedRows.map((row) => (
<tr key={row.id}>
<td title={row.timestamp} className={styles.requestEventsTimestamp}>
{row.timestampLabel}
</td>
<td className={styles.modelCell}>{row.model}</td>
<td className={styles.requestEventsSourceCell} title={row.source}>
<span>{row.source}</span>
{row.sourceType && (
<span className={styles.credentialType}>{row.sourceType}</span>
)}
</td>
<td className={styles.requestEventsAuthIndex} title={row.authIndex}>
{row.authIndex}
</td>
<td>
<span
className={row.failed ? styles.requestEventsResultFailed : styles.requestEventsResultSuccess}
>
{row.failed ? t('stats.failure') : t('stats.success')}
</span>
</td>
<td>{row.inputTokens.toLocaleString()}</td>
<td>{row.outputTokens.toLocaleString()}</td>
<td>{row.reasoningTokens.toLocaleString()}</td>
<td>{row.cachedTokens.toLocaleString()}</td>
<td>{row.totalTokens.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
);
}
+15 -22
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNotificationStore } from '@/stores';
import { USAGE_STATS_STALE_TIME_MS, useNotificationStore, useUsageStatsStore } from '@/stores';
import { usageApi } from '@/services/api/usage';
import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage';
@@ -32,36 +32,25 @@ export interface UseUsageDataReturn {
export function useUsageData(): UseUsageDataReturn {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const usageSnapshot = useUsageStatsStore((state) => state.usage);
const loading = useUsageStatsStore((state) => state.loading);
const storeError = useUsageStatsStore((state) => state.error);
const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
const [usage, setUsage] = useState<UsagePayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [modelPrices, setModelPrices] = useState<Record<string, ModelPrice>>({});
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
const [lastRefreshedAt, setLastRefreshedAt] = useState<Date | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const loadUsage = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await usageApi.getUsage();
const payload = (data?.usage ?? data) as unknown;
setUsage(payload && typeof payload === 'object' ? (payload as UsagePayload) : null);
setLastRefreshedAt(new Date());
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('usage_stats.loading_error');
setError(message);
} finally {
setLoading(false);
}
}, [t]);
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
useEffect(() => {
loadUsage();
void loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
setModelPrices(loadModelPrices());
}, [loadUsage]);
}, [loadUsageStats]);
const handleExport = async () => {
setExporting(true);
@@ -122,7 +111,7 @@ export function useUsageData(): UseUsageDataReturn {
}),
'success'
);
await loadUsage();
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
showNotification(
@@ -139,6 +128,10 @@ export function useUsageData(): UseUsageDataReturn {
saveModelPrices(prices);
}, []);
const usage = usageSnapshot as UsagePayload | null;
const error = storeError || '';
const lastRefreshedAt = lastRefreshedAtTs ? new Date(lastRefreshedAtTs) : null;
return {
usage,
loading,
+3
View File
@@ -38,3 +38,6 @@ export type { CostTrendChartProps } from './CostTrendChart';
export { ServiceHealthCard } from './ServiceHealthCard';
export type { ServiceHealthCardProps } from './ServiceHealthCard';
export { RequestEventsDetailsCard } from './RequestEventsDetailsCard';
export type { RequestEventsDetailsCardProps } from './RequestEventsDetailsCard';
@@ -9,6 +9,7 @@ import { resolveAuthProvider } from '@/utils/quota';
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
import { formatFileSize } from '@/utils/format';
import {
AUTH_FILE_REFRESH_WARNING_MS,
QUOTA_PROVIDER_TYPES,
formatModified,
getTypeColor,
@@ -23,6 +24,21 @@ import type { AuthFileStatusBarData } from '@/features/authFiles/hooks/useAuthFi
import { AuthFileQuotaSection } from '@/features/authFiles/components/AuthFileQuotaSection';
import styles from '@/pages/AuthFilesPage.module.scss';
type AuthFileHealthStatus = 'healthy' | 'warning' | 'disabled' | 'unknown';
const HEALTHY_STATUS_MESSAGES = new Set(['ok', 'healthy', 'ready', 'success', 'available']);
const GOOD_STATUS_VALUES = new Set(['', 'ok', 'ready', 'healthy', 'available']);
const parseDateFromUnknown = (value: unknown): Date | null => {
if (value === null || value === undefined || value === '') return null;
const asNumber = Number(value);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(Math.abs(asNumber) < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(value));
return Number.isNaN(date.getTime()) ? null : date;
};
export type AuthFileCardProps = {
file: AuthFileItem;
selected: boolean;
@@ -33,6 +49,7 @@ export type AuthFileCardProps = {
quotaFilterType: QuotaProviderType | null;
keyStats: KeyStats;
statusBarCache: Map<string, AuthFileStatusBarData>;
nowMs: number;
onShowModels: (file: AuthFileItem) => void;
onShowDetails: (file: AuthFileItem) => void;
onDownload: (name: string) => void;
@@ -49,7 +66,7 @@ const resolveQuotaType = (file: AuthFileItem): QuotaProviderType | null => {
};
export function AuthFileCard(props: AuthFileCardProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const {
file,
selected,
@@ -60,6 +77,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
quotaFilterType,
keyStats,
statusBarCache,
nowMs,
onShowModels,
onShowDetails,
onDownload,
@@ -93,6 +111,63 @@ export function AuthFileCard(props: AuthFileCardProps) {
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
const statusData =
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
const rawStatus = String(file.status ?? file['status'] ?? '')
.trim()
.toLowerCase();
const rawStatusMessage = String(file['status_message'] ?? file.statusMessage ?? '').trim();
const normalizedStatusMessage = rawStatusMessage.toLowerCase();
const isFileDisabled = file.disabled === true || rawStatus === 'disabled';
const isUnavailable = file.unavailable === true || rawStatus === 'unavailable';
const lastRefreshDate = parseDateFromUnknown(file['last_refresh'] ?? file.lastRefresh);
const isRefreshStale = lastRefreshDate
? nowMs - lastRefreshDate.getTime() > AUTH_FILE_REFRESH_WARNING_MS
: false;
const hasStatusWarning =
Boolean(rawStatusMessage) && !HEALTHY_STATUS_MESSAGES.has(normalizedStatusMessage);
const hasStatusFailure = rawStatus === 'error' || rawStatus === 'failed' || rawStatus === 'warning';
const healthStatus: AuthFileHealthStatus = isFileDisabled
? 'disabled'
: hasStatusWarning || hasStatusFailure || isUnavailable || isRefreshStale
? 'warning'
: lastRefreshDate && !isRefreshStale && GOOD_STATUS_VALUES.has(rawStatus)
? 'healthy'
: 'unknown';
const healthStatusClass =
healthStatus === 'healthy'
? styles.healthStatusHealthy
: healthStatus === 'warning'
? styles.healthStatusWarning
: healthStatus === 'disabled'
? styles.healthStatusDisabled
: styles.healthStatusUnknown;
const healthStatusLabel = t(`auth_files.health_status_${healthStatus}`);
const lastRefreshText = (() => {
if (!lastRefreshDate) return t('auth_files.refresh_not_available');
const diffMs = lastRefreshDate.getTime() - nowMs;
const absMs = Math.abs(diffMs);
if (absMs < 30 * 1000) {
return t('auth_files.refresh_just_now');
}
const units: ReadonlyArray<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
{ unit: 'hour', ms: 60 * 60 * 1000 },
{ unit: 'minute', ms: 60 * 1000 },
{ unit: 'second', ms: 1000 }
];
const matched = units.find(({ ms }) => absMs >= ms) || units[units.length - 1];
const value = Math.round(diffMs / matched.ms);
if (typeof Intl === 'undefined' || typeof Intl.RelativeTimeFormat !== 'function') {
return lastRefreshDate.toLocaleString(i18n.language);
}
const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' });
return formatter.format(value, matched.unit);
})();
const lastRefreshTitle = lastRefreshDate
? lastRefreshDate.toLocaleString(i18n.language)
: t('auth_files.refresh_not_available');
const healthStatusTitle = rawStatusMessage || t('auth_files.health_status_no_message');
return (
<div
@@ -135,6 +210,23 @@ export function AuthFileCard(props: AuthFileCardProps) {
</span>
</div>
<div className={styles.cardHealthRow}>
<span className={`${styles.healthStatusBadge} ${healthStatusClass}`} title={healthStatusTitle}>
{t('auth_files.health_status_label')}: {healthStatusLabel}
</span>
<span
className={`${styles.lastRefreshText} ${isRefreshStale ? styles.lastRefreshStale : ''}`}
title={lastRefreshTitle}
>
{t('auth_files.last_refresh_label')}: {lastRefreshText}
</span>
</div>
{rawStatusMessage && hasStatusWarning && (
<div className={styles.healthStatusMessage} title={rawStatusMessage}>
{rawStatusMessage}
</div>
)}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {fileStats.success}
+1
View File
@@ -17,6 +17,7 @@ export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', '
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
export const AUTH_FILE_REFRESH_WARNING_MS = 24 * 60 * 60 * 1000;
export const INTEGER_STRING_PATTERN = /^[+-]?\d+$/;
export const TRUTHY_TEXT_VALUES = new Set(['true', '1', 'yes', 'y', 'on']);
@@ -1,35 +1,26 @@
import { useCallback, useRef, useState } from 'react';
import { usageApi } from '@/services/api';
import { collectUsageDetails, type KeyStats, type UsageDetail } from '@/utils/usage';
import { useCallback } from 'react';
import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export type UseAuthFilesStatsResult = {
keyStats: KeyStats;
usageDetails: UsageDetail[];
loadKeyStats: () => Promise<void>;
refreshKeyStats: () => Promise<void>;
};
export function useAuthFilesStats(): UseAuthFilesStatsResult {
const [keyStats, setKeyStats] = useState<KeyStats>({ bySource: {}, byAuthIndex: {} });
const [usageDetails, setUsageDetails] = useState<UsageDetail[]>([]);
const loadingKeyStatsRef = useRef(false);
const keyStats = useUsageStatsStore((state) => state.keyStats);
const usageDetails = useUsageStatsStore((state) => state.usageDetails);
const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats);
const loadKeyStats = useCallback(async () => {
if (loadingKeyStatsRef.current) return;
loadingKeyStatsRef.current = true;
try {
const usageResponse = await usageApi.getUsage();
const usageData = usageResponse?.usage ?? usageResponse;
const stats = await usageApi.getKeyStats(usageData);
setKeyStats(stats);
const details = collectUsageDetails(usageData);
setUsageDetails(details);
} catch {
// 静默失败
} finally {
loadingKeyStatsRef.current = false;
}
}, []);
await loadUsageStats({ staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
return { keyStats, usageDetails, loadKeyStats };
const refreshKeyStats = useCallback(async () => {
await loadUsageStats({ force: true, staleTimeMs: USAGE_STATS_STALE_TIME_MS });
}, [loadUsageStats]);
return { keyStats, usageDetails, loadKeyStats, refreshKeyStats };
}
+81
View File
@@ -0,0 +1,81 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { BlockerFunction } from 'react-router';
import { useBlocker, useLocation } from 'react-router';
import { useNotificationStore } from '@/stores';
type ConfirmationVariant = 'danger' | 'primary' | 'secondary';
export type UnsavedChangesDialog = {
title: string;
message: string;
confirmText: string;
cancelText: string;
variant?: ConfirmationVariant;
};
export type UseUnsavedChangesGuardOptions = {
enabled?: boolean;
shouldBlock: boolean | BlockerFunction;
dialog: UnsavedChangesDialog;
};
export function useUnsavedChangesGuard(options: UseUnsavedChangesGuardOptions) {
const { enabled = true, shouldBlock, dialog } = options;
const { showConfirmation } = useNotificationStore();
const lastBlockedRef = useRef<string>('');
const allowNextNavigationUntilRef = useRef(0);
const location = useLocation();
const allowNextNavigation = useCallback(() => {
// Allow a short window for programmatic navigations after successful save.
// This avoids stale "allow" flags lingering when no navigation happens.
allowNextNavigationUntilRef.current = Date.now() + 2_000;
}, []);
const shouldBlockFunction = useCallback<BlockerFunction>(
(args) => {
if (!enabled) return false;
if (allowNextNavigationUntilRef.current > Date.now()) {
return false;
}
return typeof shouldBlock === 'function' ? shouldBlock(args) : shouldBlock;
},
[enabled, shouldBlock]
);
const blocker = useBlocker(shouldBlockFunction);
useEffect(() => {
if (allowNextNavigationUntilRef.current === 0) return;
allowNextNavigationUntilRef.current = 0;
}, [location.key]);
const blockedKey = useMemo(() => {
if (blocker.state !== 'blocked' || !blocker.location) return '';
return `${blocker.location.pathname}${blocker.location.search}${blocker.location.hash}`;
}, [blocker.location, blocker.state]);
useEffect(() => {
if (blocker.state !== 'blocked') {
lastBlockedRef.current = '';
return;
}
if (!blockedKey || lastBlockedRef.current === blockedKey) {
return;
}
lastBlockedRef.current = blockedKey;
showConfirmation({
title: dialog.title,
message: dialog.message,
confirmText: dialog.confirmText,
cancelText: dialog.cancelText,
variant: dialog.variant ?? 'danger',
onConfirm: () => blocker.proceed(),
onCancel: () => blocker.reset(),
});
}, [blockedKey, blocker, dialog, showConfirmation]);
return { allowNextNavigation };
}
+123 -1
View File
@@ -5,6 +5,8 @@
"back": "Back",
"cancel": "Cancel",
"confirm": "Confirm",
"leave": "Leave",
"stay": "Stay",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
@@ -32,6 +34,7 @@
"base_url": "Address",
"prefix": "Prefix",
"proxy_url": "Proxy",
"priority": "Priority",
"alias": "Alias",
"failure": "Failure",
"unknown_error": "Unknown error",
@@ -47,7 +50,9 @@
"custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)",
"invalid_provider_index": "Invalid provider index."
"invalid_provider_index": "Invalid provider index.",
"unsaved_changes_title": "Unsaved changes",
"unsaved_changes_message": "You have unsaved changes. Leaving now will discard them. Do you want to leave?"
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -131,6 +136,7 @@
"no_usage_data": "No usage data available",
"view_detailed_usage": "View Detailed Stats",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
"available_models": "Available Models",
"available_models_desc": "Total models from all providers"
},
@@ -200,6 +206,23 @@
"gemini_base_url_placeholder": "e.g.: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "Proxy URL (Optional):",
"gemini_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"gemini_models_label": "Custom Models (Optional):",
"gemini_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them for this key.",
"gemini_models_add_btn": "Add Model",
"gemini_models_fetch_button": "Fetch via /v1beta/models",
"gemini_models_fetch_title": "Pick Models from Gemini /v1beta/models",
"gemini_models_fetch_hint": "Call GET /v1beta/models per the Gemini API. By default, it sends x-goog-api-key from the API Key field, merged with your custom headers.",
"gemini_models_fetch_url_label": "Request URL",
"gemini_models_fetch_refresh": "Refresh",
"gemini_models_fetch_loading": "Fetching models from Gemini /v1beta/models...",
"gemini_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
"gemini_models_fetch_error": "Failed to fetch Gemini models",
"gemini_models_fetch_apply": "Add selected models",
"gemini_models_search_label": "Search models",
"gemini_models_search_placeholder": "Filter by name, alias, or description",
"gemini_models_search_empty": "No models match your search. Try a different keyword.",
"gemini_models_fetch_added": "{{count}} new models added",
"gemini_models_count": "Models Count",
"gemini_edit_modal_title": "Edit Gemini API Key",
"gemini_edit_modal_key_label": "API Key:",
"gemini_delete_confirm": "Are you sure you want to delete this Gemini key?",
@@ -210,6 +233,8 @@
"prefix_label": "Prefix (Optional):",
"prefix_placeholder": "e.g.: team-a",
"prefix_hint": "When set, call models as prefix/<model> to target this entry.",
"priority_label": "Priority (Optional):",
"priority_hint": "Higher values are preferred. Leave empty for default (0).",
"config_toggle_label": "Enabled",
"config_disabled_badge": "Disabled",
"codex_title": "Codex API Configuration",
@@ -224,6 +249,25 @@
"codex_add_modal_url_placeholder": "e.g.: https://api.example.com",
"codex_add_modal_proxy_label": "Proxy URL (Optional):",
"codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"codex_websockets_label": "Websockets",
"codex_websockets_hint": "Enable Responses API websocket transport for this key.",
"codex_models_label": "Custom Models (Optional):",
"codex_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"codex_models_add_btn": "Add Model",
"codex_models_fetch_button": "Fetch via /v1/models",
"codex_models_fetch_title": "Pick Models from /v1/models",
"codex_models_fetch_hint": "Call GET /v1/models using the Base URL above, sending the API key as Bearer (if provided) and merging your custom headers.",
"codex_models_fetch_url_label": "Request URL",
"codex_models_fetch_refresh": "Refresh",
"codex_models_fetch_loading": "Fetching models from /v1/models...",
"codex_models_fetch_empty": "No models returned. Please check Base URL, API key, or headers.",
"codex_models_fetch_error": "Failed to fetch models",
"codex_models_fetch_apply": "Add selected models",
"codex_models_search_label": "Search models",
"codex_models_search_placeholder": "Filter by name, alias, or description",
"codex_models_search_empty": "No models match your search. Try a different keyword.",
"codex_models_fetch_added": "{{count}} new models added",
"codex_models_count": "Models Count",
"codex_edit_modal_title": "Edit Codex API Configuration",
"codex_edit_modal_key_label": "API Key:",
"codex_edit_modal_url_label": "Base URL (Required):",
@@ -275,6 +319,21 @@
"claude_test_key_required": "Please provide a Claude API key or set x-api-key in custom headers",
"claude_test_model_required": "Please select a model to test",
"claude_test_endpoint_invalid": "Unable to build a valid Claude /v1/messages endpoint",
"claude_cloak_title": "Request Cloaking (Optional):",
"claude_cloak_toggle_label": "Enable",
"claude_cloak_toggle_aria": "Toggle request cloaking",
"claude_cloak_hint": "Cloaking disguises requests to appear as coming from the official Claude Code CLI for non-Claude-Code clients.",
"claude_cloak_mode_label": "Mode:",
"claude_cloak_mode_hint": "auto: cloak only when client is not Claude Code; always: always cloak; never: never cloak.",
"claude_cloak_mode_auto": "Auto (non-Claude-Code only)",
"claude_cloak_mode_always": "Always",
"claude_cloak_mode_never": "Never",
"claude_cloak_strict_label": "Strict mode:",
"claude_cloak_strict_hint": "When enabled, strip user system messages and keep only the Claude Code prompt.",
"claude_cloak_sensitive_words_label": "Sensitive words (optional):",
"claude_cloak_sensitive_words_placeholder": "Comma or newline separated, e.g. internal-project",
"claude_cloak_sensitive_words_hint": "These words are obfuscated with zero-width characters.",
"claude_cloak_sensitive_words_count": "Sensitive words",
"vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration",
@@ -387,6 +446,15 @@
"search_empty_desc": "Try changing the filters or clearing the search box.",
"file_size": "Size",
"file_modified": "Modified",
"health_status_label": "Health",
"health_status_healthy": "Healthy",
"health_status_warning": "Warning",
"health_status_disabled": "Disabled",
"health_status_unknown": "Unknown",
"health_status_no_message": "No status message",
"last_refresh_label": "Last Refresh",
"refresh_not_available": "N/A",
"refresh_just_now": "Just now",
"download_button": "Download",
"delete_button": "Delete",
"delete_confirm": "Are you sure you want to delete file",
@@ -807,9 +875,13 @@
"range_7h": "Last 7 Hours",
"range_24h": "Last 24 Hours",
"range_7d": "Last 7 Days",
"filter_all": "All",
"clear_filters": "Clear Filters",
"refresh": "Refresh",
"export": "Export",
"import": "Import",
"export_csv": "Export CSV",
"export_json": "Export JSON",
"export_success": "Usage export downloaded",
"import_success": "Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}",
"import_invalid": "Invalid usage export file",
@@ -858,6 +930,20 @@
"credential_stats": "Credential Statistics",
"credential_name": "Credential",
"token_breakdown": "Token Type Breakdown",
"request_events_title": "Request Events",
"request_events_filter_model": "Model",
"request_events_filter_source": "Source",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Timestamp",
"request_events_source": "Source",
"request_events_auth_index": "Auth Index",
"request_events_result": "Result",
"request_events_empty_title": "No request events",
"request_events_empty_desc": "No request details are available for the selected time range.",
"request_events_no_result_title": "No matching events",
"request_events_no_result_desc": "Try different filters or clear the current filters.",
"request_events_count": "{{count}} events",
"request_events_limit_hint": "Showing {{shown}} of {{total}} events to keep rendering responsive.",
"input_tokens": "Input Tokens",
"output_tokens": "Output Tokens",
"last_updated": "Updated"
@@ -914,6 +1000,42 @@
"show_raw_logs": "Show Raw Logs",
"show_raw_logs_hint": "Show original log text for easier multi-line copy",
"search_placeholder": "Search logs by content or keyword",
"filter_method": "Method",
"filter_status": "Status",
"filter_path": "Path",
"filter_path_empty": "No path candidates",
"filter_status_2xx": "2xx",
"filter_status_3xx": "3xx",
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "Clear Filters",
"trace_button": "Trace",
"trace_title": "Request Trace (V1)",
"trace_notice": "V1 uses heuristic correlation between logs and usage events. Results are estimates, not strict request_id matches.",
"trace_log_info": "Log Event",
"trace_candidates_title": "Usage Match Candidates",
"trace_loading": "Matching usage events...",
"trace_no_match": "No candidate matches found.",
"trace_usage_load_error": "Failed to load usage details for tracing",
"trace_download_request_log": "Download Request Log",
"trace_confidence_high": "High",
"trace_confidence_medium": "Medium",
"trace_confidence_low": "Low",
"trace_score": "Score {{score}}",
"trace_delta_seconds": "Δt {{seconds}}s",
"trace_request_id": "Request ID",
"trace_method": "Method",
"trace_path": "Path",
"trace_status_code": "Status",
"trace_latency": "Latency",
"trace_ip": "IP",
"trace_timestamp": "Timestamp",
"trace_message": "Message",
"trace_endpoint": "Usage Endpoint",
"trace_model": "Model",
"trace_source": "Source",
"trace_auth_index": "Auth Index",
"trace_result": "Result",
"search_empty_title": "No matching logs found",
"search_empty_desc": "Try a different keyword or clear the filters.",
"double_click_copy_hint": "Double-click to copy raw log line",
+123 -1
View File
@@ -5,6 +5,8 @@
"back": "Назад",
"cancel": "Отмена",
"confirm": "Подтвердить",
"leave": "Уйти",
"stay": "Остаться",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
@@ -32,6 +34,7 @@
"base_url": "Адрес",
"prefix": "Префикс",
"proxy_url": "Прокси",
"priority": "Приоритет",
"alias": "Псевдоним",
"failure": "Сбой",
"unknown_error": "Неизвестная ошибка",
@@ -47,7 +50,9 @@
"custom_headers_value_placeholder": "Значение заголовка",
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Псевдоним модели (необязательно)",
"invalid_provider_index": "Неверный индекс провайдера."
"invalid_provider_index": "Неверный индекс провайдера.",
"unsaved_changes_title": "Несохранённые изменения",
"unsaved_changes_message": "У вас есть несохранённые изменения. Если вы уйдёте, они будут потеряны. Выйти?"
},
"title": {
"main": "Центр управления CLI Proxy API",
@@ -131,6 +136,7 @@
"no_usage_data": "Данные об использовании отсутствуют",
"view_detailed_usage": "Просмотреть детальную статистику",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
"available_models": "Доступные модели",
"available_models_desc": "Всего моделей от всех провайдеров"
},
@@ -200,6 +206,23 @@
"gemini_base_url_placeholder": "например: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "URL прокси (необязательно):",
"gemini_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
"gemini_models_label": "Пользовательские модели (необязательно):",
"gemini_models_hint": "Оставьте пустым, чтобы разрешить все модели, или добавьте записи name[, alias], чтобы ограничить/переименовать их для этого ключа.",
"gemini_models_add_btn": "Добавить модель",
"gemini_models_fetch_button": "Получить через /v1beta/models",
"gemini_models_fetch_title": "Выбор моделей из Gemini /v1beta/models",
"gemini_models_fetch_hint": "Вызывает GET /v1beta/models по спецификации Gemini API. По умолчанию отправляется x-goog-api-key из поля API-ключа, объединённый с вашими пользовательскими заголовками.",
"gemini_models_fetch_url_label": "URL запроса",
"gemini_models_fetch_refresh": "Обновить",
"gemini_models_fetch_loading": "Получение моделей из Gemini /v1beta/models...",
"gemini_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
"gemini_models_fetch_error": "Не удалось получить модели Gemini",
"gemini_models_fetch_apply": "Добавить выбранные модели",
"gemini_models_search_label": "Поиск моделей",
"gemini_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
"gemini_models_search_empty": "Нет подходящих моделей. Попробуйте другой запрос.",
"gemini_models_fetch_added": "Добавлено новых моделей: {{count}}",
"gemini_models_count": "Количество моделей",
"gemini_edit_modal_title": "Редактирование API-ключа Gemini",
"gemini_edit_modal_key_label": "API-ключ:",
"gemini_delete_confirm": "Удалить этот ключ Gemini?",
@@ -210,6 +233,8 @@
"prefix_label": "Префикс (необязательно):",
"prefix_placeholder": "например: team-a",
"prefix_hint": "Если задано, обращайтесь к моделям как prefix/<model>, чтобы выбрать эту запись.",
"priority_label": "Приоритет (необязательно):",
"priority_hint": "Чем больше значение, тем выше приоритет. Оставьте пустым для значения по умолчанию (0).",
"config_toggle_label": "Включено",
"config_disabled_badge": "Отключено",
"codex_title": "Конфигурация Codex API",
@@ -224,6 +249,25 @@
"codex_add_modal_url_placeholder": "например: https://api.example.com",
"codex_add_modal_proxy_label": "URL прокси (необязательно):",
"codex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
"codex_websockets_label": "Websockets",
"codex_websockets_hint": "Включает websocket-транспорт Responses API для этого ключа.",
"codex_models_label": "Пользовательские модели (необязательно):",
"codex_models_hint": "Оставьте пустым, чтобы разрешить все модели, или добавьте записи name[, alias], чтобы ограничить/переименовать их.",
"codex_models_add_btn": "Добавить модель",
"codex_models_fetch_button": "Получить через /v1/models",
"codex_models_fetch_title": "Выбор моделей из Codex /v1/models",
"codex_models_fetch_hint": "Вызывает GET /v1/models по спецификации OpenAI. По умолчанию отправляет API-ключ как Bearer (если указан) и объединяет его с пользовательскими заголовками.",
"codex_models_fetch_url_label": "URL запроса",
"codex_models_fetch_refresh": "Обновить",
"codex_models_fetch_loading": "Получение моделей из /v1/models...",
"codex_models_fetch_empty": "Модели не получены. Проверьте Base URL, API-ключ или заголовки.",
"codex_models_fetch_error": "Не удалось получить модели",
"codex_models_fetch_apply": "Добавить выбранные модели",
"codex_models_search_label": "Поиск моделей",
"codex_models_search_placeholder": "Фильтр по имени, псевдониму или описанию",
"codex_models_search_empty": "Модели по запросу не найдены. Попробуйте другой запрос.",
"codex_models_fetch_added": "Добавлено новых моделей: {{count}}",
"codex_models_count": "Количество моделей",
"codex_edit_modal_title": "Редактирование конфигурации Codex API",
"codex_edit_modal_key_label": "API-ключ:",
"codex_edit_modal_url_label": "Базовый URL (обязательно):",
@@ -275,6 +319,21 @@
"claude_test_key_required": "Укажите Claude API-ключ или задайте x-api-key в пользовательских заголовках",
"claude_test_model_required": "Выберите модель для теста",
"claude_test_endpoint_invalid": "Не удалось сформировать корректный endpoint Claude /v1/messages",
"claude_cloak_title": "Маскировка запросов (необязательно):",
"claude_cloak_toggle_label": "Включить",
"claude_cloak_toggle_aria": "Переключить маскировку запросов",
"claude_cloak_hint": "Маскировка делает запросы похожими на запросы официального CLI Claude Code для клиентов, которые не являются Claude Code.",
"claude_cloak_mode_label": "Режим:",
"claude_cloak_mode_hint": "auto: маскировать только если клиент не Claude Code; always: всегда; never: никогда.",
"claude_cloak_mode_auto": "Auto (только не-Claude Code)",
"claude_cloak_mode_always": "Всегда",
"claude_cloak_mode_never": "Никогда",
"claude_cloak_strict_label": "Строгий режим:",
"claude_cloak_strict_hint": "Если включено, пользовательские system-сообщения удаляются, остаётся только промпт Claude Code.",
"claude_cloak_sensitive_words_label": "Чувствительные слова (необязательно):",
"claude_cloak_sensitive_words_placeholder": "Через запятую или с новой строки, например internal-project",
"claude_cloak_sensitive_words_hint": "Эти слова будут обфусцированы с помощью символов нулевой ширины.",
"claude_cloak_sensitive_words_count": "Чувствительные слова",
"vertex_title": "Конфигурация Vertex API",
"vertex_add_button": "Добавить конфигурацию",
"vertex_empty_title": "Конфигурации Vertex отсутствуют",
@@ -387,6 +446,15 @@
"search_empty_desc": "Попробуйте изменить фильтры или очистить строку поиска.",
"file_size": "Размер",
"file_modified": "Изменён",
"health_status_label": "Состояние",
"health_status_healthy": "Нормально",
"health_status_warning": "Предупреждение",
"health_status_disabled": "Отключено",
"health_status_unknown": "Неизвестно",
"health_status_no_message": "Нет сообщения о статусе",
"last_refresh_label": "Последнее обновление",
"refresh_not_available": "Н/Д",
"refresh_just_now": "Только что",
"download_button": "Скачать",
"delete_button": "Удалить",
"delete_confirm": "Удалить файл",
@@ -810,9 +878,13 @@
"range_7h": "Последние 7 часов",
"range_24h": "Последние 24 часа",
"range_7d": "Последние 7 дней",
"filter_all": "Все",
"clear_filters": "Сбросить фильтры",
"refresh": "Обновить",
"export": "Экспорт",
"import": "Импорт",
"export_csv": "Экспорт CSV",
"export_json": "Экспорт JSON",
"export_success": "Экспорт использования скачан",
"import_success": "Импорт завершён: добавлено {{added}}, пропущено {{skipped}}, всего {{total}}, ошибок {{failed}}",
"import_invalid": "Недопустимый файл экспорта использования",
@@ -861,6 +933,20 @@
"credential_stats": "Статистика учётных данных",
"credential_name": "Учётные данные",
"token_breakdown": "Распределение типов токенов",
"request_events_title": "События запросов",
"request_events_filter_model": "Модель",
"request_events_filter_source": "Источник",
"request_events_filter_auth_index": "Auth Index",
"request_events_timestamp": "Время",
"request_events_source": "Источник",
"request_events_auth_index": "Auth Index",
"request_events_result": "Результат",
"request_events_empty_title": "События запросов отсутствуют",
"request_events_empty_desc": "Нет деталей запросов для выбранного диапазона времени.",
"request_events_no_result_title": "Совпадений не найдено",
"request_events_no_result_desc": "Попробуйте другие фильтры или очистите текущие.",
"request_events_count": "{{count}} событий",
"request_events_limit_hint": "Показано {{shown}} из {{total}} событий для стабильной производительности.",
"input_tokens": "Входные токены",
"output_tokens": "Выходные токены",
"last_updated": "Обновлено"
@@ -917,6 +1003,42 @@
"show_raw_logs": "Показать исходные журналы",
"show_raw_logs_hint": "Показать текст журнала без обработки для удобного копирования в несколько строк",
"search_placeholder": "Искать по содержимому или ключевым словам",
"filter_method": "Метод",
"filter_status": "Статус",
"filter_path": "Путь",
"filter_path_empty": "Нет доступных путей",
"filter_status_2xx": "2xx",
"filter_status_3xx": "3xx",
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "Очистить фильтры",
"trace_button": "Трассировка",
"trace_title": "Трассировка запроса (V1)",
"trace_notice": "V1 использует эвристическую корреляцию между логами и usage-событиями. Результаты оценочные и не являются точным сопоставлением по request_id.",
"trace_log_info": "Событие лога",
"trace_candidates_title": "Кандидаты из usage",
"trace_loading": "Сопоставление usage-событий...",
"trace_no_match": "Подходящие кандидаты не найдены.",
"trace_usage_load_error": "Не удалось загрузить usage-детали для трассировки",
"trace_download_request_log": "Скачать журнал запроса",
"trace_confidence_high": "Высокая",
"trace_confidence_medium": "Средняя",
"trace_confidence_low": "Низкая",
"trace_score": "Оценка {{score}}",
"trace_delta_seconds": "Δt {{seconds}}с",
"trace_request_id": "Request ID",
"trace_method": "Метод",
"trace_path": "Путь",
"trace_status_code": "Статус",
"trace_latency": "Задержка",
"trace_ip": "IP",
"trace_timestamp": "Время",
"trace_message": "Сообщение",
"trace_endpoint": "Usage-эндпоинт",
"trace_model": "Модель",
"trace_source": "Источник",
"trace_auth_index": "Auth Index",
"trace_result": "Результат",
"search_empty_title": "Подходящих журналов не найдено",
"search_empty_desc": "Попробуйте другой запрос или сбросьте фильтры.",
"double_click_copy_hint": "Дважды нажмите, чтобы скопировать строку журнала",
+123 -1
View File
@@ -5,6 +5,8 @@
"back": "返回",
"cancel": "取消",
"confirm": "确认",
"leave": "离开",
"stay": "继续编辑",
"save": "保存",
"delete": "删除",
"edit": "编辑",
@@ -32,6 +34,7 @@
"base_url": "地址",
"prefix": "前缀",
"proxy_url": "代理",
"priority": "优先级",
"alias": "别名",
"failure": "失败",
"unknown_error": "未知错误",
@@ -47,7 +50,9 @@
"custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)",
"invalid_provider_index": "无效的提供商索引。"
"invalid_provider_index": "无效的提供商索引。",
"unsaved_changes_title": "未保存的更改",
"unsaved_changes_message": "你有未保存的更改,离开后将丢失这些更改。确定要离开吗?"
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -131,6 +136,7 @@
"no_usage_data": "暂无使用数据",
"view_detailed_usage": "查看详细统计",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
"available_models": "可用模型",
"available_models_desc": "所有提供商的模型总数"
},
@@ -200,6 +206,23 @@
"gemini_base_url_placeholder": "例如: https://generativelanguage.googleapis.com",
"gemini_add_modal_proxy_label": "代理 URL (可选):",
"gemini_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"gemini_models_label": "自定义模型 (可选):",
"gemini_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"gemini_models_add_btn": "添加模型",
"gemini_models_fetch_button": "从 /v1beta/models 获取",
"gemini_models_fetch_title": "从 Gemini /v1beta/models 选择模型",
"gemini_models_fetch_hint": "按 Gemini API 规范请求 GET /v1beta/models;默认附带 x-goog-api-key(来自 API Key 字段)并合并你配置的自定义请求头。",
"gemini_models_fetch_url_label": "请求地址",
"gemini_models_fetch_refresh": "重新获取",
"gemini_models_fetch_loading": "正在从 Gemini /v1beta/models 获取模型列表...",
"gemini_models_fetch_empty": "未获取到模型,请检查 Base URL、API Key 或请求头。",
"gemini_models_fetch_error": "获取 Gemini 模型失败",
"gemini_models_fetch_apply": "添加所选模型",
"gemini_models_search_label": "搜索模型",
"gemini_models_search_placeholder": "按名称、别名或描述筛选",
"gemini_models_search_empty": "没有匹配的模型,请更换关键字试试。",
"gemini_models_fetch_added": "已添加 {{count}} 个新模型",
"gemini_models_count": "模型数量",
"gemini_edit_modal_title": "编辑Gemini API密钥",
"gemini_edit_modal_key_label": "API密钥:",
"gemini_delete_confirm": "确定要删除这个Gemini密钥吗?",
@@ -210,6 +233,8 @@
"prefix_label": "前缀 (可选):",
"prefix_placeholder": "例如: team-a",
"prefix_hint": "设置后可用 prefix/<model> 选择该条目。",
"priority_label": "优先级 (可选):",
"priority_hint": "数值越大优先级越高;留空表示默认(0)。",
"config_toggle_label": "启用",
"config_disabled_badge": "已停用",
"codex_title": "Codex API 配置",
@@ -224,6 +249,25 @@
"codex_add_modal_url_placeholder": "例如: https://api.example.com",
"codex_add_modal_proxy_label": "代理 URL (可选):",
"codex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"codex_websockets_label": "Websockets",
"codex_websockets_hint": "开启 Responses API 的 websocket 传输。",
"codex_models_label": "自定义模型 (可选):",
"codex_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"codex_models_add_btn": "添加模型",
"codex_models_fetch_button": "从 /v1/models 获取",
"codex_models_fetch_title": "从 /v1/models 选择模型",
"codex_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带 API KeyBearer)与自定义请求头。",
"codex_models_fetch_url_label": "请求地址",
"codex_models_fetch_refresh": "重新获取",
"codex_models_fetch_loading": "正在从 /v1/models 获取模型列表...",
"codex_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
"codex_models_fetch_error": "获取模型失败",
"codex_models_fetch_apply": "添加所选模型",
"codex_models_search_label": "搜索模型",
"codex_models_search_placeholder": "按名称、别名或描述筛选",
"codex_models_search_empty": "没有匹配的模型,请更换关键字试试。",
"codex_models_fetch_added": "已添加 {{count}} 个新模型",
"codex_models_count": "模型数量",
"codex_edit_modal_title": "编辑Codex API配置",
"codex_edit_modal_key_label": "API密钥:",
"codex_edit_modal_url_label": "Base URL (必填):",
@@ -275,6 +319,21 @@
"claude_test_key_required": "请先填写 Claude API Key 或在自定义请求头中设置 x-api-key",
"claude_test_model_required": "请选择要测试的模型",
"claude_test_endpoint_invalid": "无法构造有效的 Claude /v1/messages 请求地址",
"claude_cloak_title": "请求伪装 (可选):",
"claude_cloak_toggle_label": "启用",
"claude_cloak_toggle_aria": "切换请求伪装",
"claude_cloak_hint": "对非 Claude Code 客户端,将请求伪装成 Claude Code CLI 的格式。",
"claude_cloak_mode_label": "模式:",
"claude_cloak_mode_hint": "auto: 仅对非 Claude Code 客户端伪装;always: 总是伪装;never: 从不伪装。",
"claude_cloak_mode_auto": "自动(仅非 Claude Code",
"claude_cloak_mode_always": "总是",
"claude_cloak_mode_never": "从不",
"claude_cloak_strict_label": "严格模式:",
"claude_cloak_strict_hint": "启用后会移除用户 system 消息,仅保留 Claude Code 提示词。",
"claude_cloak_sensitive_words_label": "敏感词 (可选):",
"claude_cloak_sensitive_words_placeholder": "用逗号或换行分隔,例如: internal-project",
"claude_cloak_sensitive_words_hint": "这些词会被插入零宽字符进行混淆。",
"claude_cloak_sensitive_words_count": "敏感词",
"vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置",
@@ -387,6 +446,15 @@
"search_empty_desc": "请调整筛选条件或清空搜索关键字再试一次。",
"file_size": "大小",
"file_modified": "修改时间",
"health_status_label": "健康状态",
"health_status_healthy": "健康",
"health_status_warning": "警告",
"health_status_disabled": "已停用",
"health_status_unknown": "未知",
"health_status_no_message": "暂无状态说明",
"last_refresh_label": "最近刷新",
"refresh_not_available": "暂无",
"refresh_just_now": "刚刚",
"download_button": "下载",
"delete_button": "删除",
"delete_confirm": "确定要删除文件",
@@ -807,9 +875,13 @@
"range_7h": "最近7小时",
"range_24h": "最近24小时",
"range_7d": "最近7天",
"filter_all": "全部",
"clear_filters": "清空筛选",
"refresh": "刷新",
"export": "导出数据",
"import": "导入数据",
"export_csv": "导出 CSV",
"export_json": "导出 JSON",
"export_success": "使用统计已导出",
"import_success": "导入完成:新增 {{added}},跳过 {{skipped}},总请求 {{total}},失败 {{failed}}",
"import_invalid": "导入文件格式不正确",
@@ -858,6 +930,20 @@
"credential_stats": "凭证统计",
"credential_name": "凭证",
"token_breakdown": "Token 类型分布",
"request_events_title": "请求事件明细",
"request_events_filter_model": "模型",
"request_events_filter_source": "来源",
"request_events_filter_auth_index": "认证索引",
"request_events_timestamp": "时间",
"request_events_source": "来源",
"request_events_auth_index": "认证索引",
"request_events_result": "结果",
"request_events_empty_title": "暂无请求事件",
"request_events_empty_desc": "当前时间范围内暂无可用的请求明细数据。",
"request_events_no_result_title": "没有匹配结果",
"request_events_no_result_desc": "请尝试调整筛选条件,或清空当前筛选。",
"request_events_count": "{{count}} 条事件",
"request_events_limit_hint": "为保证渲染性能,仅展示 {{shown}} / {{total}} 条事件。",
"input_tokens": "输入 Tokens",
"output_tokens": "输出 Tokens",
"last_updated": "更新于"
@@ -914,6 +1000,42 @@
"show_raw_logs": "显示原始日志",
"show_raw_logs_hint": "直接显示原始日志文本,方便多行复制",
"search_placeholder": "搜索日志内容或关键字",
"filter_method": "请求方法",
"filter_status": "状态码",
"filter_path": "路径",
"filter_path_empty": "暂无可筛选路径",
"filter_status_2xx": "2xx",
"filter_status_3xx": "3xx",
"filter_status_4xx": "4xx",
"filter_status_5xx": "5xx",
"clear_filters": "清空筛选",
"trace_button": "请求追踪",
"trace_title": "请求追踪 (V1)",
"trace_notice": "V1 通过日志与 usage 事件做启发式关联,结果为估算,不是严格 request_id 级精准匹配。",
"trace_log_info": "日志事件",
"trace_candidates_title": "Usage 候选匹配",
"trace_loading": "正在匹配 usage 事件...",
"trace_no_match": "未找到候选匹配结果。",
"trace_usage_load_error": "加载请求追踪所需 usage 明细失败",
"trace_download_request_log": "下载请求日志",
"trace_confidence_high": "高",
"trace_confidence_medium": "中",
"trace_confidence_low": "低",
"trace_score": "分数 {{score}}",
"trace_delta_seconds": "时间差 {{seconds}} 秒",
"trace_request_id": "请求 ID",
"trace_method": "请求方法",
"trace_path": "路径",
"trace_status_code": "状态码",
"trace_latency": "耗时",
"trace_ip": "IP",
"trace_timestamp": "时间",
"trace_message": "消息",
"trace_endpoint": "Usage 接口",
"trace_model": "模型",
"trace_source": "来源",
"trace_auth_index": "认证索引",
"trace_result": "结果",
"search_empty_title": "未找到匹配的日志",
"search_empty_desc": "尝试更换关键字或清空筛选条件。",
"double_click_copy_hint": "双击复制日志原文",
+46 -2
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/Input';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { ampcodeApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
@@ -24,6 +25,23 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ from: string; to: string }>>((acc, entry) => {
const from = String(entry?.name ?? '').trim();
const to = String(entry?.alias ?? '').trim();
if (!from && !to) return acc;
acc.push({ from, to });
return acc;
}, []);
const buildAmpcodeSignature = (form: AmpcodeFormState) =>
JSON.stringify({
upstreamUrl: String(form.upstreamUrl ?? '').trim(),
upstreamApiKey: String(form.upstreamApiKey ?? '').trim(),
forceModelMappings: Boolean(form.forceModelMappings),
modelMappings: normalizeMappingEntries(form.mappingEntries),
});
export function AiProvidersAmpcodeEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -42,6 +60,9 @@ export function AiProvidersAmpcodeEditPage() {
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const [baselineSignature, setBaselineSignature] = useState(() =>
buildAmpcodeSignature(buildAmpcodeFormState(null))
);
const initializedRef = useRef(false);
const mountedRef = useRef(false);
@@ -83,7 +104,9 @@ export function AiProvidersAmpcodeEditPage() {
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null));
const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null);
setForm(initialForm);
setBaselineSignature(buildAmpcodeSignature(initialForm));
void (async () => {
try {
@@ -93,7 +116,9 @@ export function AiProvidersAmpcodeEditPage() {
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
const nextForm = buildAmpcodeFormState(ampcode);
setForm(nextForm);
setBaselineSignature(buildAmpcodeSignature(nextForm));
} catch (err: unknown) {
if (!mountedRef.current) return;
setError(getErrorMessage(err) || t('notification.refresh_failed'));
@@ -105,6 +130,23 @@ export function AiProvidersAmpcodeEditPage() {
})();
}, [clearCache, t, updateConfigValue]);
const currentSignature = useMemo(() => buildAmpcodeSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const clearAmpcodeUpstreamApiKey = async () => {
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', {
@@ -192,6 +234,8 @@ export function AiProvidersAmpcodeEditPage() {
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
allowNextNavigation();
setBaselineSignature(buildAmpcodeSignature(form));
handleBack();
} catch (err: unknown) {
const message = getErrorMessage(err);
+174 -15
View File
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { useAuthStore, useClaudeEditDraftStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import type { ModelEntry, ProviderFormState } from '@/components/providers/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
@@ -39,6 +40,7 @@ export type ClaudeEditOutletContext = {
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
priority: undefined,
prefix: '',
baseUrl: '',
proxyUrl: '',
@@ -61,6 +63,59 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeClaudeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name) {
alias = alias || name;
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const normalizeCloakConfig = (cloak: ProviderFormState['cloak']) => {
if (!cloak) return null;
const mode = String(cloak.mode ?? '').trim().toLowerCase() || 'auto';
const strictMode = Boolean(cloak.strictMode);
const sensitiveWords = Array.isArray(cloak.sensitiveWords)
? cloak.sensitiveWords.map((word) => String(word ?? '').trim()).filter(Boolean)
: [];
return {
mode,
strictMode,
sensitiveWords: sensitiveWords.length ? sensitiveWords : null,
};
};
const buildClaudeSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeClaudeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
cloak: normalizeCloakConfig(form.cloak),
});
export function AiProvidersClaudeEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -84,10 +139,55 @@ export function AiProvidersClaudeEditLayout() {
const [configs, setConfigs] = useState<ProviderKeyConfig[]>(() => config?.claudeApiKeys ?? []);
const [loading, setLoading] = useState(() => !isCacheValid('claude-api-key'));
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<TestStatus>('idle');
const [testMessage, setTestMessage] = useState('');
const draftKey = useMemo(() => {
if (invalidIndexParam) return `claude:invalid:${params.index ?? 'unknown'}`;
if (editIndex === null) return 'claude:new';
return `claude:${editIndex}`;
}, [editIndex, invalidIndexParam, params.index]);
const draft = useClaudeEditDraftStore((state) => state.drafts[draftKey]);
const acquireDraft = useClaudeEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useClaudeEditDraftStore((state) => state.releaseDraft);
const initDraft = useClaudeEditDraftStore((state) => state.initDraft);
const setDraftBaselineSignature = useClaudeEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftForm = useClaudeEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useClaudeEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useClaudeEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useClaudeEditDraftStore((state) => state.setDraftTestMessage);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const setForm: Dispatch<SetStateAction<ProviderFormState>> = useCallback(
(action) => {
setDraftForm(draftKey, action);
},
[draftKey, setDraftForm]
);
const setTestModel: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestModel(draftKey, action);
},
[draftKey, setDraftTestModel]
);
const setTestStatus: Dispatch<SetStateAction<TestStatus>> = useCallback(
(action) => {
setDraftTestStatus(draftKey, action);
},
[draftKey, setDraftTestStatus]
);
const setTestMessage: Dispatch<SetStateAction<string>> = useCallback(
(action) => {
setDraftTestMessage(draftKey, action);
},
[draftKey, setDraftTestMessage]
);
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
@@ -101,6 +201,11 @@ export function AiProvidersClaudeEditLayout() {
[form.modelEntries]
);
useEffect(() => {
acquireDraft(draftKey);
return () => releaseDraft(draftKey);
}, [acquireDraft, draftKey, releaseDraft]);
const handleBack = useCallback(() => {
const state = location.state as LocationState;
if (state?.fromAiProviders) {
@@ -139,22 +244,68 @@ export function AiProvidersClaudeEditLayout() {
useEffect(() => {
if (loading) return;
if (draft?.initialized) return;
if (initialData) {
setForm({
const seededForm: ProviderFormState = {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
};
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const baselineSignature = buildClaudeSignature(seededForm);
initDraft(draftKey, {
baselineSignature,
form: seededForm,
testModel: available[0] || '',
testStatus: 'idle',
testMessage: '',
});
return;
}
setForm(buildEmptyForm());
}, [initialData, loading]);
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
baselineSignature: buildClaudeSignature(emptyForm),
form: emptyForm,
testModel: '',
testStatus: 'idle',
testMessage: '',
});
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildClaudeSignature(form), [form]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/claude/${params.index ?? ''}`;
}
return '/ai-providers/claude/new';
}, [hasIndexParam, params.index]);
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ nextLocation }) => {
const nextPath = nextLocation.pathname;
const isWithinRoot =
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
return isDirty && !isWithinRoot;
},
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
useEffect(() => {
if (loading) return;
if (resolvedLoading) return;
if (availableModels.length === 0) {
if (testModel) {
@@ -170,7 +321,7 @@ export function AiProvidersClaudeEditLayout() {
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, loading, testModel]);
}, [availableModels, resolvedLoading, setTestMessage, setTestModel, setTestStatus, testModel]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
@@ -203,17 +354,19 @@ export function AiProvidersClaudeEditLayout() {
showNotification(t('ai_providers.claude_models_fetch_added', { count: addedCount }), 'success');
}
},
[showNotification, t]
[setForm, showNotification, t]
);
const handleSave = useCallback(async () => {
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canSave =
!disableControls && !saving && !resolvedLoading && !invalidIndexParam && !invalidIndex;
if (!canSave) return;
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
prefix: form.prefix?.trim() || undefined,
baseUrl: (form.baseUrl ?? '').trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
@@ -227,6 +380,7 @@ export function AiProvidersClaudeEditLayout() {
})
.filter(Boolean) as ProviderKeyConfig['models'],
excludedModels: parseExcludedModels(form.excludedText),
cloak: form.cloak,
};
const nextList =
@@ -242,6 +396,8 @@ export function AiProvidersClaudeEditLayout() {
editIndex !== null ? t('notification.claude_config_updated') : t('notification.claude_config_added'),
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildClaudeSignature(form));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -249,15 +405,18 @@ export function AiProvidersClaudeEditLayout() {
setSaving(false);
}
}, [
allowNextNavigation,
clearCache,
configs,
draftKey,
disableControls,
editIndex,
form,
handleBack,
invalidIndex,
invalidIndexParam,
loading,
resolvedLoading,
setDraftBaselineSignature,
saving,
showNotification,
t,
@@ -272,7 +431,7 @@ export function AiProvidersClaudeEditLayout() {
invalidIndexParam,
invalidIndex,
disableControls,
loading,
loading: resolvedLoading,
saving,
form,
setForm,
+127 -1
View File
@@ -7,12 +7,13 @@ import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import { useNotificationStore } from '@/stores';
import { buildHeaderObject } from '@/utils/headers';
import { buildClaudeMessagesEndpoint } from '@/components/providers/utils';
import { buildClaudeMessagesEndpoint, parseExcludedModels } from '@/components/providers/utils';
import type { ClaudeEditOutletContext } from './AiProvidersClaudeEditLayout';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -99,6 +100,23 @@ export function AiProvidersClaudeEditPage() {
}, []);
}, [form.modelEntries]);
const cloakModeOptions = useMemo(
() => [
{ value: 'auto', label: t('ai_providers.claude_cloak_mode_auto') },
{ value: 'always', label: t('ai_providers.claude_cloak_mode_always') },
{ value: 'never', label: t('ai_providers.claude_cloak_mode_never') },
],
[t]
);
const resolvedCloakMode = useMemo(() => {
const mode = (form.cloak?.mode ?? '').trim().toLowerCase();
if (!mode) return 'auto';
if (mode === 'provider') return 'auto';
if (mode === 'auto' || mode === 'always' || mode === 'never') return mode;
return 'auto';
}, [form.cloak?.mode]);
const connectivityConfigSignature = useMemo(() => {
const headersSignature = form.headers
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
@@ -267,6 +285,22 @@ export function AiProvidersClaudeEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
disabled={saving || disableControls || isTesting}
/>
<Input
label={t('ai_providers.priority_label')}
hint={t('ai_providers.priority_hint')}
type="number"
step={1}
value={form.priority ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed = raw.trim() === '' ? undefined : Number(raw);
setForm((prev) => ({
...prev,
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
}));
}}
disabled={saving || disableControls || isTesting}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
@@ -418,6 +452,98 @@ export function AiProvidersClaudeEditPage() {
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.claude_cloak_title')}</label>
<div className={styles.modelConfigToolbar}>
<ToggleSwitch
checked={Boolean(form.cloak)}
onChange={(enabled) =>
setForm((prev) => ({
...prev,
cloak: enabled
? {
mode: (prev.cloak?.mode ?? 'auto').trim() || 'auto',
strictMode: prev.cloak?.strictMode ?? false,
sensitiveWords: prev.cloak?.sensitiveWords ?? [],
}
: undefined,
}))
}
disabled={saving || disableControls || isTesting}
ariaLabel={t('ai_providers.claude_cloak_toggle_aria')}
label={t('ai_providers.claude_cloak_toggle_label')}
/>
</div>
</div>
<div className={styles.sectionHint}>{t('ai_providers.claude_cloak_hint')}</div>
{form.cloak ? (
<>
<div className="form-group">
<label>{t('ai_providers.claude_cloak_mode_label')}</label>
<Select
value={resolvedCloakMode}
options={cloakModeOptions}
onChange={(value) =>
setForm((prev) => ({
...prev,
cloak: {
...(prev.cloak ?? {}),
mode: value,
},
}))
}
ariaLabel={t('ai_providers.claude_cloak_mode_label')}
disabled={saving || disableControls || isTesting}
/>
<div className="hint">{t('ai_providers.claude_cloak_mode_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.claude_cloak_strict_label')}</label>
<ToggleSwitch
checked={Boolean(form.cloak.strictMode)}
onChange={(value) =>
setForm((prev) => ({
...prev,
cloak: {
...(prev.cloak ?? {}),
strictMode: value,
},
}))
}
disabled={saving || disableControls || isTesting}
ariaLabel={t('ai_providers.claude_cloak_strict_label')}
/>
<div className="hint">{t('ai_providers.claude_cloak_strict_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.claude_cloak_sensitive_words_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.claude_cloak_sensitive_words_placeholder')}
value={(form.cloak.sensitiveWords ?? []).join('\n')}
onChange={(e) => {
const nextWords = parseExcludedModels(e.target.value);
setForm((prev) => ({
...prev,
cloak: {
...(prev.cloak ?? {}),
sensitiveWords: nextWords.length ? nextWords : undefined,
},
}));
}}
rows={3}
disabled={saving || disableControls || isTesting}
/>
<div className="hint">{t('ai_providers.claude_cloak_sensitive_words_hint')}</div>
</div>
</>
) : null}
</div>
</div>
)}
</Card>
+402 -11
View File
@@ -1,27 +1,35 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { entriesToModels } from '@/components/ui/modelInputListUtils';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { ProviderFormState } from '@/components/providers';
import type { ModelInfo } from '@/utils/models';
import layoutStyles from './AiProvidersEditLayout.module.scss';
import styles from './AiProvidersPage.module.scss';
type LocationState = { fromAiProviders?: boolean } | null;
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
priority: undefined,
prefix: '',
baseUrl: '',
websockets: false,
proxyUrl: '',
headers: [],
models: [],
@@ -36,6 +44,51 @@ const parseIndexParam = (value: string | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && alias === name) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildCodexSignature = (form: ProviderFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
websockets: Boolean(form.websockets),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersCodexEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -55,6 +108,16 @@ export function AiProvidersCodexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<ProviderFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildCodexSignature(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[]>([]);
const [modelDiscoveryFetching, setModelDiscoveryFetching] = useState(false);
const [modelDiscoveryError, setModelDiscoveryError] = useState('');
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
const autoFetchSignatureRef = useRef<string>('');
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -120,22 +183,168 @@ export function AiProvidersCodexEditPage() {
if (loading) return;
if (initialData) {
setForm({
const nextForm: ProviderFormState = {
...initialData,
websockets: Boolean(initialData.websockets),
headers: headersToEntries(initialData.headers),
modelEntries: (initialData.models || []).map((model) => ({
name: model.name,
alias: model.alias ?? '',
})),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
};
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildCodexSignature(nextForm));
}, [initialData, loading]);
const currentSignature = useMemo(() => buildCodexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const discoveredModelsFiltered = useMemo(() => {
const filter = modelDiscoverySearch.trim().toLowerCase();
if (!filter) return discoveredModels;
return discoveredModels.filter((model) => {
const name = (model.name || '').toLowerCase();
const alias = (model.alias || '').toLowerCase();
const description = (model.description || '').toLowerCase();
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
if (!selectedModels.length) return;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, { name: string; alias: string }>();
prev.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name.toLowerCase(), { name, alias: entry.alias?.trim() || '' });
});
selectedModels.forEach((model) => {
const name = String(model.name ?? '').trim();
if (!name) return;
const key = name.toLowerCase();
if (mergedMap.has(key)) return;
mergedMap.set(key, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.codex_models_fetch_added', { count: addedCount }), 'success');
}
},
[setForm, showNotification, t]
);
const fetchCodexModelDiscovery = useCallback(async () => {
setModelDiscoveryFetching(true);
setModelDiscoveryError('');
try {
const headerObject = buildHeaderObject(form.headers);
const hasCustomAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const apiKey = form.apiKey.trim() || undefined;
const list = await modelsApi.fetchV1ModelsViaApiCall(
form.baseUrl ?? '',
hasCustomAuthorization ? undefined : apiKey,
headerObject
);
setDiscoveredModels(list);
} catch (err: unknown) {
setDiscoveredModels([]);
const message = getErrorMessage(err);
setModelDiscoveryError(`${t('ai_providers.codex_models_fetch_error')}: ${message}`);
} finally {
setModelDiscoveryFetching(false);
}
}, [form.apiKey, form.baseUrl, form.headers, t]);
useEffect(() => {
if (!modelDiscoveryOpen) {
autoFetchSignatureRef.current = '';
return;
}
const nextEndpoint = modelsApi.buildV1ModelsEndpoint(form.baseUrl ?? '');
setModelDiscoveryEndpoint(nextEndpoint);
setDiscoveredModels([]);
setModelDiscoverySearch('');
setModelDiscoverySelected(new Set());
setModelDiscoveryError('');
if (!nextEndpoint) return;
const headerObject = buildHeaderObject(form.headers);
const hasCustomAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const hasApiKeyField = Boolean(form.apiKey.trim());
const canAutoFetch = hasApiKeyField || hasCustomAuthorization;
if (!canAutoFetch) return;
const headerSignature = Object.entries(headerObject)
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(([key, value]) => `${key}:${value}`)
.join('|');
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
if (autoFetchSignatureRef.current === signature) return;
autoFetchSignatureRef.current = signature;
void fetchCodexModelDiscovery();
}, [fetchCodexModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
setModelDiscoveryOpen(false);
};
const handleSave = useCallback(async () => {
if (!canSave) return;
@@ -151,8 +360,10 @@ export function AiProvidersCodexEditPage() {
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
prefix: form.prefix?.trim() || undefined,
baseUrl,
websockets: Boolean(form.websockets),
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: entriesToModels(form.modelEntries),
@@ -171,6 +382,8 @@ export function AiProvidersCodexEditPage() {
editIndex !== null ? t('notification.codex_config_updated') : t('notification.codex_config_added'),
'success'
);
allowNextNavigation();
setBaselineSignature(buildCodexSignature(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
@@ -180,6 +393,7 @@ export function AiProvidersCodexEditPage() {
setSaving(false);
}
}, [
allowNextNavigation,
canSave,
clearCache,
configs,
@@ -191,6 +405,15 @@ export function AiProvidersCodexEditPage() {
updateConfigValue,
]);
const canOpenModelDiscovery =
!disableControls &&
!saving &&
!loading &&
!invalidIndexParam &&
!invalidIndex &&
Boolean((form.baseUrl ?? '').trim());
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
return (
<SecondaryScreenShell
ref={swipeRef}
@@ -219,6 +442,22 @@ export function AiProvidersCodexEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.priority_label')}
hint={t('ai_providers.priority_hint')}
type="number"
step={1}
value={form.priority ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed = raw.trim() === '' ? undefined : Number(raw);
setForm((prev) => ({
...prev,
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
}));
}}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
@@ -233,6 +472,16 @@ export function AiProvidersCodexEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={disableControls || saving}
/>
<div className="form-group">
<label>{t('ai_providers.codex_websockets_label')}</label>
<ToggleSwitch
checked={Boolean(form.websockets)}
onChange={(value) => setForm((prev) => ({ ...prev, websockets: value }))}
disabled={disableControls || saving}
ariaLabel={t('ai_providers.codex_websockets_label')}
/>
<div className="hint">{t('ai_providers.codex_websockets_hint')}</div>
</div>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
@@ -249,6 +498,51 @@ export function AiProvidersCodexEditPage() {
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.codex_models_label')}</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() =>
setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
}))
}
disabled={disableControls || saving}
>
{t('ai_providers.codex_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setModelDiscoveryOpen(true)}
disabled={!canOpenModelDiscovery}
>
{t('ai_providers.codex_models_fetch_button')}
</Button>
</div>
</div>
<div className={styles.sectionHint}>{t('ai_providers.codex_models_hint')}</div>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={disableControls || saving}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
@@ -261,6 +555,103 @@ export function AiProvidersCodexEditPage() {
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
<Modal
open={modelDiscoveryOpen}
title={t('ai_providers.codex_models_fetch_title')}
onClose={() => setModelDiscoveryOpen(false)}
width={720}
footer={
<>
<Button
variant="secondary"
size="sm"
onClick={() => setModelDiscoveryOpen(false)}
disabled={modelDiscoveryFetching}
>
{t('common.cancel')}
</Button>
<Button
size="sm"
onClick={handleApplyDiscoveredModels}
disabled={!canApplyModelDiscovery}
>
{t('ai_providers.codex_models_fetch_apply')}
</Button>
</>
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_hint')}</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.codex_models_fetch_url_label')}
</label>
<div className={styles.openaiModelsEndpointControls}>
<input
className={`input ${styles.openaiModelsEndpointInput}`}
readOnly
value={modelDiscoveryEndpoint}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void fetchCodexModelDiscovery()}
loading={modelDiscoveryFetching}
disabled={disableControls || saving}
>
{t('ai_providers.codex_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.codex_models_search_label')}
placeholder={t('ai_providers.codex_models_search_placeholder')}
value={modelDiscoverySearch}
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_loading')}</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_fetch_empty')}</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.codex_models_search_empty')}</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
key={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
);
})}
</div>
)}
</div>
</Modal>
</>
)}
</Card>
+395 -8
View File
@@ -1,28 +1,36 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { Modal } from '@/components/ui/Modal';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { modelsApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import type { GeminiFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
import styles from './AiProvidersPage.module.scss';
type LocationState = { fromAiProviders?: boolean } | null;
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
priority: undefined,
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
modelEntries: [{ name: '', alias: '' }],
excludedModels: [],
excludedText: '',
});
@@ -33,6 +41,48 @@ const parseIndexParam = (value: string | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
const stripGeminiModelResourceName = (value: string) => {
return String(value ?? '').trim().replace(/^\/?models\//i, '');
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = stripGeminiModelResourceName(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && alias === name) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildGeminiSignature = (form: GeminiFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersGeminiEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -52,6 +102,16 @@ export function AiProvidersGeminiEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<GeminiFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildGeminiSignature(buildEmptyForm()));
const [modelDiscoveryOpen, setModelDiscoveryOpen] = useState(false);
const [modelDiscoveryEndpoint, setModelDiscoveryEndpoint] = useState('');
const [discoveredModels, setDiscoveredModels] = useState<ModelInfo[]>([]);
const [modelDiscoveryFetching, setModelDiscoveryFetching] = useState(false);
const [modelDiscoveryError, setModelDiscoveryError] = useState('');
const [modelDiscoverySearch, setModelDiscoverySearch] = useState('');
const [modelDiscoverySelected, setModelDiscoverySelected] = useState<Set<string>>(new Set());
const autoFetchSignatureRef = useRef<string>('');
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -117,30 +177,196 @@ export function AiProvidersGeminiEditPage() {
if (loading) return;
if (initialData) {
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
const { headers, models, ...rest } = initialData;
const nextForm: GeminiFormState = {
...rest,
headers: headersToEntries(headers),
modelEntries: modelsToEntries(models).map((entry) => ({
...entry,
name: stripGeminiModelResourceName(entry.name),
})),
excludedText: excludedModelsToText(initialData.excludedModels),
});
};
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildGeminiSignature(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const discoveredModelsFiltered = useMemo(() => {
const filter = modelDiscoverySearch.trim().toLowerCase();
if (!filter) return discoveredModels;
return discoveredModels.filter((model) => {
const name = (model.name || '').toLowerCase();
const alias = (model.alias || '').toLowerCase();
const description = (model.description || '').toLowerCase();
return name.includes(filter) || alias.includes(filter) || description.includes(filter);
});
}, [discoveredModels, modelDiscoverySearch]);
const mergeDiscoveredModels = useCallback(
(selectedModels: ModelInfo[]) => {
if (!selectedModels.length) return;
let addedCount = 0;
setForm((prev) => {
const mergedMap = new Map<string, { name: string; alias: string }>();
prev.modelEntries.forEach((entry) => {
const name = stripGeminiModelResourceName(entry.name);
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
selectedModels.forEach((model) => {
const name = stripGeminiModelResourceName(model.name);
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
return {
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
};
});
if (addedCount > 0) {
showNotification(t('ai_providers.gemini_models_fetch_added', { count: addedCount }), 'success');
}
},
[setForm, showNotification, t]
);
const fetchGeminiModelDiscovery = useCallback(async () => {
setModelDiscoveryFetching(true);
setModelDiscoveryError('');
const headerObject = buildHeaderObject(form.headers);
try {
const list = await modelsApi.fetchGeminiModelsViaApiCall(
form.baseUrl ?? '',
form.apiKey.trim() || undefined,
headerObject
);
setDiscoveredModels(list);
} catch (err: unknown) {
setDiscoveredModels([]);
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : '';
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'x-goog-api-key'
);
const hasAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const shouldAttachDiag = message.toLowerCase().includes('api key') || message.includes('401');
const diag = shouldAttachDiag
? ` [diag: apiKeyField=${form.apiKey.trim() ? 'yes' : 'no'}, customXGoogApiKey=${
hasCustomXGoogApiKey ? 'yes' : 'no'
}, customAuthorization=${hasAuthorization ? 'yes' : 'no'}]`
: '';
setModelDiscoveryError(`${t('ai_providers.gemini_models_fetch_error')}: ${message}${diag}`);
} finally {
setModelDiscoveryFetching(false);
}
}, [form.apiKey, form.baseUrl, form.headers, t]);
useEffect(() => {
if (!modelDiscoveryOpen) {
autoFetchSignatureRef.current = '';
return;
}
const nextEndpoint = modelsApi.buildGeminiModelsEndpoint(form.baseUrl ?? '');
setModelDiscoveryEndpoint(nextEndpoint);
setDiscoveredModels([]);
setModelDiscoverySearch('');
setModelDiscoverySelected(new Set());
setModelDiscoveryError('');
const headerObject = buildHeaderObject(form.headers);
const hasCustomXGoogApiKey = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'x-goog-api-key'
);
const hasAuthorization = Object.keys(headerObject).some(
(key) => key.toLowerCase() === 'authorization'
);
const hasApiKeyField = Boolean(form.apiKey.trim());
const canAutoFetch = hasApiKeyField || hasCustomXGoogApiKey || hasAuthorization;
if (!canAutoFetch) return;
const headerSignature = Object.entries(headerObject)
.sort(([a], [b]) => a.toLowerCase().localeCompare(b.toLowerCase()))
.map(([key, value]) => `${key}:${value}`)
.join('|');
const signature = `${nextEndpoint}||${form.apiKey.trim()}||${headerSignature}`;
if (autoFetchSignatureRef.current === signature) return;
autoFetchSignatureRef.current = signature;
void fetchGeminiModelDiscovery();
}, [fetchGeminiModelDiscovery, form.apiKey, form.baseUrl, form.headers, modelDiscoveryOpen]);
const toggleModelDiscoverySelection = (name: string) => {
setModelDiscoverySelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const handleApplyDiscoveredModels = () => {
const selectedModels = discoveredModels.filter((model) => modelDiscoverySelected.has(model.name));
if (selectedModels.length) {
mergeDiscoveredModels(selectedModels);
}
setModelDiscoveryOpen(false);
};
const currentSignature = useMemo(() => buildGeminiSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const handleSave = useCallback(async () => {
if (!canSave) return;
setSaving(true);
setError('');
try {
const normalizedModelEntries = form.modelEntries.map((entry) => ({
...entry,
name: stripGeminiModelResourceName(entry.name),
}));
const payload: GeminiKeyConfig = {
apiKey: form.apiKey.trim(),
priority: form.priority !== undefined ? Math.trunc(form.priority) : undefined,
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: entriesToModels(normalizedModelEntries),
excludedModels: parseExcludedModels(form.excludedText),
};
@@ -156,6 +382,8 @@ export function AiProvidersGeminiEditPage() {
editIndex !== null ? t('notification.gemini_key_updated') : t('notification.gemini_key_added'),
'success'
);
allowNextNavigation();
setBaselineSignature(buildGeminiSignature(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
@@ -165,6 +393,7 @@ export function AiProvidersGeminiEditPage() {
setSaving(false);
}
}, [
allowNextNavigation,
canSave,
clearCache,
configs,
@@ -176,6 +405,9 @@ export function AiProvidersGeminiEditPage() {
updateConfigValue,
]);
const canOpenModelDiscovery = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const canApplyModelDiscovery = !disableControls && !saving && !modelDiscoveryFetching;
return (
<SecondaryScreenShell
ref={swipeRef}
@@ -205,6 +437,22 @@ export function AiProvidersGeminiEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.priority_label')}
hint={t('ai_providers.priority_hint')}
type="number"
step={1}
value={form.priority ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed = raw.trim() === '' ? undefined : Number(raw);
setForm((prev) => ({
...prev,
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
}));
}}
disabled={disableControls || saving}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
@@ -237,6 +485,52 @@ export function AiProvidersGeminiEditPage() {
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className={styles.modelConfigSection}>
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>{t('ai_providers.gemini_models_label')}</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() =>
setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }],
}))
}
disabled={disableControls || saving}
>
{t('ai_providers.gemini_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setModelDiscoveryOpen(true)}
disabled={!canOpenModelDiscovery}
>
{t('ai_providers.gemini_models_fetch_button')}
</Button>
</div>
</div>
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_hint')}</div>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={disableControls || saving}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
@@ -249,6 +543,99 @@ export function AiProvidersGeminiEditPage() {
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
<Modal
open={modelDiscoveryOpen}
title={t('ai_providers.gemini_models_fetch_title')}
onClose={() => setModelDiscoveryOpen(false)}
width={720}
footer={
<>
<Button
variant="secondary"
size="sm"
onClick={() => setModelDiscoveryOpen(false)}
disabled={modelDiscoveryFetching}
>
{t('common.cancel')}
</Button>
<Button size="sm" onClick={handleApplyDiscoveredModels} disabled={!canApplyModelDiscovery}>
{t('ai_providers.gemini_models_fetch_apply')}
</Button>
</>
}
>
<div className={styles.openaiModelsContent}>
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_hint')}</div>
<div className={styles.openaiModelsEndpointSection}>
<label className={styles.openaiModelsEndpointLabel}>
{t('ai_providers.gemini_models_fetch_url_label')}
</label>
<div className={styles.openaiModelsEndpointControls}>
<input
className={`input ${styles.openaiModelsEndpointInput}`}
readOnly
value={modelDiscoveryEndpoint}
/>
<Button
variant="secondary"
size="sm"
onClick={() => void fetchGeminiModelDiscovery()}
loading={modelDiscoveryFetching}
disabled={disableControls || saving}
>
{t('ai_providers.gemini_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.gemini_models_search_label')}
placeholder={t('ai_providers.gemini_models_search_placeholder')}
value={modelDiscoverySearch}
onChange={(e) => setModelDiscoverySearch(e.target.value)}
disabled={modelDiscoveryFetching}
/>
{modelDiscoveryError && <div className="error-box">{modelDiscoveryError}</div>}
{modelDiscoveryFetching ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_loading')}</div>
) : discoveredModels.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_fetch_empty')}</div>
) : discoveredModelsFiltered.length === 0 ? (
<div className={styles.sectionHint}>{t('ai_providers.gemini_models_search_empty')}</div>
) : (
<div className={styles.modelDiscoveryList}>
{discoveredModelsFiltered.map((model) => {
const checked = modelDiscoverySelected.has(model.name);
return (
<label
key={model.name}
className={`${styles.modelDiscoveryRow} ${
checked ? styles.modelDiscoveryRowSelected : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelDiscoverySelection(model.name)}
/>
<div className={styles.modelDiscoveryMeta}>
<div className={styles.modelDiscoveryName}>
{model.name}
{model.alias && (
<span className={styles.modelDiscoveryAlias}>{model.alias}</span>
)}
</div>
{model.description && (
<div className={styles.modelDiscoveryDesc}>{model.description}</div>
)}
</div>
</label>
);
})}
</div>
)}
</div>
</Modal>
</>
)}
</Card>
+120 -12
View File
@@ -2,12 +2,13 @@ import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useOpenAIEditDraftStore } from '@/stores';
import { entriesToModels, modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ApiKeyEntry, OpenAIProviderConfig } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
@@ -41,6 +42,7 @@ export type OpenAIEditOutletContext = {
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
priority: undefined,
prefix: '',
baseUrl: '',
headers: [],
@@ -61,6 +63,72 @@ const getErrorMessage = (err: unknown) => {
return '';
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: ModelEntry[]) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
let alias = String(entry?.alias ?? '').trim();
if (name && (alias === '' || alias === name)) {
alias = '';
}
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const normalizeKeyHeaders = (headers: ApiKeyEntry['headers']) => {
if (!headers || typeof headers !== 'object') return [];
return Object.entries(headers)
.map(([key, value]) => ({ key: String(key ?? '').trim(), value: String(value ?? '').trim() }))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
};
const normalizeApiKeyEntries = (entries: ApiKeyEntry[]) =>
(entries ?? []).reduce<
Array<{
apiKey: string;
proxyUrl: string;
headers: Array<{ key: string; value: string }>;
}>
>((acc, entry) => {
const apiKey = String(entry?.apiKey ?? '').trim();
const proxyUrl = String(entry?.proxyUrl ?? '').trim();
const headers = normalizeKeyHeaders(entry?.headers);
if (!apiKey && !proxyUrl && headers.length === 0) return acc;
acc.push({ apiKey, proxyUrl, headers });
return acc;
}, []);
const buildOpenAISignature = (form: OpenAIFormState, testModel: string) =>
JSON.stringify({
name: String(form.name ?? '').trim(),
priority:
form.priority !== undefined && Number.isFinite(form.priority) ? Math.trunc(form.priority) : null,
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
apiKeyEntries: normalizeApiKeyEntries(form.apiKeyEntries),
models: normalizeModelEntries(form.modelEntries),
testModel: String(testModel ?? '').trim(),
});
export function AiProvidersOpenAIEditLayout() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -94,9 +162,10 @@ export function AiProvidersOpenAIEditLayout() {
}, [editIndex, invalidIndexParam, params.index]);
const draft = useOpenAIEditDraftStore((state) => state.drafts[draftKey]);
const ensureDraft = useOpenAIEditDraftStore((state) => state.ensureDraft);
const acquireDraft = useOpenAIEditDraftStore((state) => state.acquireDraft);
const releaseDraft = useOpenAIEditDraftStore((state) => state.releaseDraft);
const initDraft = useOpenAIEditDraftStore((state) => state.initDraft);
const clearDraft = useOpenAIEditDraftStore((state) => state.clearDraft);
const setDraftBaselineSignature = useOpenAIEditDraftStore((state) => state.setDraftBaselineSignature);
const setDraftForm = useOpenAIEditDraftStore((state) => state.setDraftForm);
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
@@ -166,18 +235,18 @@ export function AiProvidersOpenAIEditLayout() {
);
useEffect(() => {
ensureDraft(draftKey);
}, [draftKey, ensureDraft]);
acquireDraft(draftKey);
return () => releaseDraft(draftKey);
}, [acquireDraft, draftKey, releaseDraft]);
const handleBack = useCallback(() => {
clearDraft(draftKey);
const state = location.state as LocationState;
if (state?.fromAiProviders) {
navigate(-1);
return;
}
navigate('/ai-providers', { replace: true });
}, [clearDraft, draftKey, location.state, navigate]);
}, [location.state, navigate]);
useEffect(() => {
let cancelled = false;
@@ -214,6 +283,7 @@ export function AiProvidersOpenAIEditLayout() {
const modelEntries = modelsToEntries(initialData.models);
const seededForm: OpenAIFormState = {
name: initialData.name,
priority: initialData.priority,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
headers: headersToEntries(initialData.headers),
@@ -229,7 +299,9 @@ export function AiProvidersOpenAIEditLayout() {
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
const baselineSignature = buildOpenAISignature(seededForm, initialTestModel);
initDraft(draftKey, {
baselineSignature,
form: seededForm,
testModel: initialTestModel,
testStatus: 'idle',
@@ -237,8 +309,10 @@ export function AiProvidersOpenAIEditLayout() {
keyTestStatuses: [],
});
} else {
const emptyForm = buildEmptyForm();
initDraft(draftKey, {
form: buildEmptyForm(),
baselineSignature: buildOpenAISignature(emptyForm, ''),
form: emptyForm,
testModel: '',
testStatus: 'idle',
testMessage: '',
@@ -300,6 +374,35 @@ export function AiProvidersOpenAIEditLayout() {
[setForm, showNotification, t]
);
const resolvedLoading = !draft?.initialized;
const currentSignature = useMemo(() => buildOpenAISignature(form, testModel), [form, testModel]);
const baselineSignature = draft?.baselineSignature ?? '';
const isDirty = Boolean(draft?.initialized) && baselineSignature !== currentSignature;
const editorRootPath = useMemo(() => {
if (hasIndexParam) {
return `/ai-providers/openai/${params.index ?? ''}`;
}
return '/ai-providers/openai/new';
}, [hasIndexParam, params.index]);
const canGuard = !resolvedLoading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ nextLocation }) => {
const nextPath = nextLocation.pathname;
const isWithinRoot =
nextPath === editorRootPath || nextPath.startsWith(`${editorRootPath}/`);
return isDirty && !isWithinRoot;
},
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const handleSave = useCallback(async () => {
const name = form.name.trim();
const baseUrl = form.baseUrl.trim();
@@ -322,6 +425,9 @@ export function AiProvidersOpenAIEditLayout() {
headers: entry.headers,
})),
};
if (form.priority !== undefined && Number.isFinite(form.priority)) {
payload.priority = Math.trunc(form.priority);
}
const resolvedTestModel = testModel.trim();
if (resolvedTestModel) payload.testModel = resolvedTestModel;
const models = entriesToModels(form.modelEntries);
@@ -351,6 +457,8 @@ export function AiProvidersOpenAIEditLayout() {
: t('notification.openai_provider_added'),
'success'
);
allowNextNavigation();
setDraftBaselineSignature(draftKey, buildOpenAISignature(form, testModel));
handleBack();
} catch (err: unknown) {
showNotification(`${t('notification.update_failed')}: ${getErrorMessage(err)}`, 'error');
@@ -358,18 +466,19 @@ export function AiProvidersOpenAIEditLayout() {
setSaving(false);
}
}, [
allowNextNavigation,
draftKey,
editIndex,
fetchConfig,
form,
handleBack,
providers,
testModel,
setDraftBaselineSignature,
showNotification,
t,
testModel,
]);
const resolvedLoading = !draft?.initialized;
return (
<Outlet
context={{
@@ -399,4 +508,3 @@ export function AiProvidersOpenAIEditLayout() {
/>
);
}
+16
View File
@@ -520,6 +520,22 @@ export function AiProvidersOpenAIEditPage() {
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.priority_label')}
hint={t('ai_providers.priority_hint')}
type="number"
step={1}
value={form.priority ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed = raw.trim() === '' ? undefined : Number(raw);
setForm((prev) => ({
...prev,
priority: parsed !== undefined && Number.isFinite(parsed) ? parsed : undefined,
}));
}}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
+7 -4
View File
@@ -570,6 +570,10 @@
margin-bottom: 0;
}
.modelConfigSection {
margin-bottom: 0;
}
:global(.status-badge) {
margin-bottom: 0;
align-self: flex-start;
@@ -623,17 +627,17 @@
}
.modelConfigSection {
margin-bottom: 0;
margin-bottom: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
gap: $spacing-sm;
}
.modelConfigHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
gap: $spacing-sm;
flex-wrap: wrap;
@include mobile {
@@ -645,7 +649,6 @@
margin: 0;
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
}
+4 -1
View File
@@ -15,6 +15,7 @@ import {
withDisableAllModelsRule,
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
@@ -58,7 +59,7 @@ export function AiProvidersPage() {
const disableControls = connectionStatus !== 'connected';
const isSwitching = Boolean(configSwitchingKey);
const { keyStats, usageDetails, loadKeyStats } = useProviderStats();
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useProviderStats();
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
@@ -129,6 +130,8 @@ export function AiProvidersPage() {
config?.openaiCompatibility,
]);
useHeaderRefresh(refreshKeyStats);
const openEditor = useCallback(
(path: string) => {
navigate(path, { state: { fromAiProviders: true } });
+62 -4
View File
@@ -8,11 +8,12 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useEdgeSwipeBack } from '@/hooks/useEdgeSwipeBack';
import { useUnsavedChangesGuard } from '@/hooks/useUnsavedChangesGuard';
import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildHeaderObject, headersToEntries, type HeaderEntry } from '@/utils/headers';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -34,6 +35,38 @@ const parseIndexParam = (value: string | undefined) => {
return Number.isFinite(parsed) ? parsed : null;
};
const normalizeHeaderEntries = (entries: HeaderEntry[]) =>
(entries ?? [])
.map((entry) => ({
key: String(entry?.key ?? '').trim(),
value: String(entry?.value ?? '').trim(),
}))
.filter((entry) => entry.key || entry.value)
.sort((a, b) => {
const byKey = a.key.toLowerCase().localeCompare(b.key.toLowerCase());
if (byKey !== 0) return byKey;
return a.value.localeCompare(b.value);
});
const normalizeModelEntries = (entries: Array<{ name: string; alias: string }>) =>
(entries ?? []).reduce<Array<{ name: string; alias: string }>>((acc, entry) => {
const name = String(entry?.name ?? '').trim();
const alias = String(entry?.alias ?? '').trim();
if (!name && !alias) return acc;
acc.push({ name, alias });
return acc;
}, []);
const buildVertexSignature = (form: VertexFormState) =>
JSON.stringify({
apiKey: String(form.apiKey ?? '').trim(),
prefix: String(form.prefix ?? '').trim(),
baseUrl: String(form.baseUrl ?? '').trim(),
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
});
export function AiProvidersVertexEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -53,6 +86,7 @@ export function AiProvidersVertexEditPage() {
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<VertexFormState>(() => buildEmptyForm());
const [baselineSignature, setBaselineSignature] = useState(() => buildVertexSignature(buildEmptyForm()));
const hasIndexParam = typeof params.index === 'string';
const editIndex = useMemo(() => parseIndexParam(params.index), [params.index]);
@@ -126,18 +160,39 @@ export function AiProvidersVertexEditPage() {
if (loading) return;
if (initialData) {
setForm({
const nextForm: VertexFormState = {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
return;
}
setForm(buildEmptyForm());
const nextForm = buildEmptyForm();
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
}, [initialData, loading]);
const canSave = !disableControls && !saving && !loading && !invalidIndexParam && !invalidIndex;
const currentSignature = useMemo(() => buildVertexSignature(form), [form]);
const isDirty = baselineSignature !== currentSignature;
const canGuard = !loading && !saving && !invalidIndexParam && !invalidIndex;
const { allowNextNavigation } = useUnsavedChangesGuard({
enabled: canGuard,
shouldBlock: ({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname,
dialog: {
title: t('common.unsaved_changes_title'),
message: t('common.unsaved_changes_message'),
confirmText: t('common.leave'),
cancelText: t('common.stay'),
variant: 'danger',
},
});
const handleSave = useCallback(async () => {
if (!canSave) return;
@@ -179,6 +234,8 @@ export function AiProvidersVertexEditPage() {
editIndex !== null ? t('notification.vertex_config_updated') : t('notification.vertex_config_added'),
'success'
);
allowNextNavigation();
setBaselineSignature(buildVertexSignature(form));
handleBack();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '';
@@ -188,6 +245,7 @@ export function AiProvidersVertexEditPage() {
setSaving(false);
}
}, [
allowNextNavigation,
canSave,
clearCache,
configs,
+63
View File
@@ -605,6 +605,69 @@
border-bottom: 1px solid var(--border-color);
}
.cardHealthRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.healthStatusBadge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
}
.healthStatusHealthy {
color: var(--success-badge-text, #065f46);
background-color: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.healthStatusWarning {
color: var(--warning-text);
background-color: var(--warning-bg);
border-color: var(--warning-border);
}
.healthStatusDisabled {
color: var(--text-secondary);
background-color: var(--bg-tertiary);
border-color: var(--border-color);
}
.healthStatusUnknown {
color: var(--text-secondary);
background-color: var(--bg-secondary);
border-style: dashed;
}
.lastRefreshText {
font-size: 12px;
color: var(--text-secondary);
}
.lastRefreshStale {
color: var(--warning-text);
}
.healthStatusMessage {
font-size: 12px;
color: var(--warning-text);
background-color: var(--warning-bg);
border: 1px solid var(--warning-border);
border-radius: $radius-sm;
padding: 5px 8px;
word-break: break-word;
}
.cardStats {
display: flex;
flex-wrap: wrap;
+14 -6
View File
@@ -58,11 +58,12 @@ export function AuthFilesPage() {
const [selectedFile, setSelectedFile] = useState<AuthFileItem | null>(null);
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const [nowMs, setNowMs] = useState(() => Date.now());
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
const { keyStats, usageDetails, loadKeyStats } = useAuthFilesStats();
const { keyStats, usageDetails, loadKeyStats, refreshKeyStats } = useAuthFilesStats();
const {
files,
selectedFiles,
@@ -86,7 +87,7 @@ export function AuthFilesPage() {
deselectAll,
batchSetStatus,
batchDelete
} = useAuthFilesData({ refreshKeyStats: loadKeyStats });
} = useAuthFilesData({ refreshKeyStats });
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
@@ -129,7 +130,7 @@ export function AuthFilesPage() {
} = useAuthFilesPrefixProxyEditor({
disableControls: connectionStatus !== 'connected',
loadFiles,
loadKeyStats
loadKeyStats: refreshKeyStats
});
const disableControls = connectionStatus !== 'connected';
@@ -203,8 +204,8 @@ export function AuthFilesPage() {
};
const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelAlias()]);
}, [loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
await Promise.all([loadFiles(), refreshKeyStats(), loadExcluded(), loadModelAlias()]);
}, [loadFiles, refreshKeyStats, loadExcluded, loadModelAlias]);
useHeaderRefresh(handleHeaderRefresh);
@@ -216,7 +217,13 @@ export function AuthFilesPage() {
loadModelAlias();
}, [isCurrentLayer, loadFiles, loadKeyStats, loadExcluded, loadModelAlias]);
useInterval(loadKeyStats, isCurrentLayer ? 240_000 : null);
useInterval(
() => {
void refreshKeyStats();
},
isCurrentLayer ? 240_000 : null
);
useInterval(() => setNowMs(Date.now()), isCurrentLayer ? 60_000 : null);
const existingTypes = useMemo(() => {
const types = new Set<string>(['all']);
@@ -512,6 +519,7 @@ export function AuthFilesPage() {
quotaFilterType={quotaFilterType}
keyStats={keyStats}
statusBarCache={statusBarCache}
nowMs={nowMs}
onShowModels={showModels}
onShowDetails={showDetails}
onDownload={handleDownload}
+33
View File
@@ -261,6 +261,39 @@
word-break: break-all;
}
.configBadge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.configBadgeRoundRobin {
color: var(--primary-color);
border-color: rgba($primary-color, 0.24);
background: rgba($primary-color, 0.12);
}
.configBadgeFillFirst {
color: $success-color;
border-color: rgba($success-color, 0.3);
background: rgba($success-color, 0.12);
}
.configBadgeUnknown {
color: var(--text-secondary);
background: var(--bg-primary);
}
.configItemFull {
grid-column: 1 / -1;
}
+22
View File
@@ -221,6 +221,22 @@ export function DashboardPage() {
}
];
const routingStrategyRaw = config?.routingStrategy?.trim() || '';
const routingStrategyDisplay = !routingStrategyRaw
? '-'
: routingStrategyRaw === 'round-robin'
? t('basic_settings.routing_strategy_round_robin')
: routingStrategyRaw === 'fill-first'
? t('basic_settings.routing_strategy_fill_first')
: routingStrategyRaw;
const routingStrategyBadgeClass = !routingStrategyRaw
? styles.configBadgeUnknown
: routingStrategyRaw === 'round-robin'
? styles.configBadgeRoundRobin
: routingStrategyRaw === 'fill-first'
? styles.configBadgeFillFirst
: styles.configBadgeUnknown;
return (
<div className={styles.dashboard}>
<div className={styles.header}>
@@ -311,6 +327,12 @@ export function DashboardPage() {
{config.wsAuth ? t('common.yes') : t('common.no')}
</span>
</div>
<div className={styles.configItem}>
<span className={styles.configLabel}>{t('dashboard.routing_strategy')}</span>
<span className={`${styles.configBadge} ${routingStrategyBadgeClass}`}>
{routingStrategyDisplay}
</span>
</div>
{config.proxyUrl && (
<div className={`${styles.configItem} ${styles.configItemFull}`}>
<span className={styles.configLabel}>{t('basic_settings.proxy_url_label')}</span>
+252
View File
@@ -121,6 +121,96 @@
max-width: 420px;
}
.structuredFilters {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1 1 100%;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.filterChipGroup {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.filterChipLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 700;
line-height: 28px;
min-width: 68px;
}
.filterChipList {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
flex: 1;
}
.filterChip {
@include button-reset;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.3;
cursor: pointer;
max-width: 280px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition:
border-color 0.15s ease,
color 0.15s ease,
background-color 0.15s ease;
&:hover:not(:disabled) {
color: var(--text-primary);
border-color: var(--primary-color);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.filterChipActive {
color: var(--primary-color);
border-color: rgba($primary-color, 0.45);
background: rgba($primary-color, 0.14);
}
.filterChipHint {
font-size: 12px;
color: var(--text-tertiary);
}
@include mobile {
.filterChipGroup {
flex-direction: column;
gap: 6px;
}
.filterChipLabel {
min-width: 0;
line-height: 1.2;
}
}
.searchInput {
padding-right: 44px !important;
}
@@ -477,6 +567,168 @@
}
}
.traceButton {
@include button-reset;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid rgba($primary-color, 0.3);
background: rgba($primary-color, 0.1);
color: var(--primary-color);
font-size: 11px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
&:hover {
background: rgba($primary-color, 0.16);
border-color: rgba($primary-color, 0.45);
}
}
.tracePanel {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.traceNotice {
padding: 8px 10px;
border-radius: $radius-md;
border: 1px solid var(--warning-border);
background: var(--warning-bg);
color: var(--warning-text);
font-size: 12px;
}
.traceSectionTitle {
margin: 4px 0 0;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.traceInfoGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
@include mobile {
grid-template-columns: 1fr;
}
}
.traceInfoItem {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
}
.traceInfoItemWide {
grid-column: 1 / -1;
}
.traceInfoLabel {
font-size: 11px;
color: var(--text-secondary);
font-weight: 700;
}
.traceInfoValue {
font-size: 12px;
color: var(--text-primary);
word-break: break-word;
}
.traceSourceType {
display: inline-flex;
align-items: center;
margin-left: 6px;
padding: 0 6px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-primary);
}
.traceCandidates {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow: auto;
}
.traceCandidate {
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: 10px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
gap: 8px;
}
.traceCandidateHeader {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.traceConfidenceBadge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
border: 1px solid var(--border-color);
font-size: 11px;
font-weight: 700;
}
.traceConfidenceHigh {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
border-color: var(--success-badge-border, #6ee7b7);
}
.traceConfidenceMedium {
color: var(--warning-text);
background: var(--warning-bg);
border-color: var(--warning-border);
}
.traceConfidenceLow {
color: var(--text-secondary);
background: var(--bg-primary);
}
.traceScore,
.traceDelta {
font-size: 11px;
color: var(--text-secondary);
}
.traceCandidateGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
@include mobile {
grid-template-columns: 1fr;
}
}
@media (max-height: 820px) {
.pageTitle {
font-size: 24px;
+679 -27
View File
@@ -19,10 +19,18 @@ import {
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { authFilesApi } from '@/services/api/authFiles';
import { logsApi } from '@/services/api/logs';
import { usageApi } from '@/services/api/usage';
import type { AuthFileItem } from '@/types';
import { copyToClipboard } from '@/utils/clipboard';
import { MANAGEMENT_API_PREFIX } from '@/utils/constants';
import { formatUnixTimestamp } from '@/utils/format';
import {
buildCandidateUsageSourceIds,
collectUsageDetailsWithEndpoint,
type UsageDetailWithEndpoint
} from '@/utils/usage';
import styles from './LogsPage.module.scss';
interface ErrorLogItem {
@@ -49,6 +57,9 @@ const LONG_PRESS_MOVE_THRESHOLD = 10;
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const;
type HttpMethod = (typeof HTTP_METHODS)[number];
const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`);
const STATUS_GROUPS = ['2xx', '3xx', '4xx', '5xx'] as const;
type StatusGroup = (typeof STATUS_GROUPS)[number];
const PATH_FILTER_LIMIT = 12;
const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/;
const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\s*\]?(?=\s|\[|$)\s*/i;
@@ -81,6 +92,15 @@ const detectHttpStatusCode = (text: string): number | undefined => {
return undefined;
};
const resolveStatusGroup = (statusCode?: number): StatusGroup | undefined => {
if (typeof statusCode !== 'number') return undefined;
if (statusCode >= 200 && statusCode < 300) return '2xx';
if (statusCode >= 300 && statusCode < 400) return '3xx';
if (statusCode >= 400 && statusCode < 500) return '4xx';
if (statusCode >= 500 && statusCode < 600) return '5xx';
return undefined;
};
const extractIp = (text: string): string | undefined => {
const ipv4Match = text.match(LOG_IPV4_REGEX);
if (ipv4Match) return ipv4Match[0];
@@ -126,6 +146,124 @@ type ParsedLogLine = {
message: string;
};
type TraceConfidence = 'high' | 'medium' | 'low';
type TraceCandidate = {
detail: UsageDetailWithEndpoint;
score: number;
confidence: TraceConfidence;
timeDeltaMs: number | null;
};
type TraceCredentialInfo = {
name: string;
type: string;
};
type TraceSourceInfo = {
displayName: string;
type: string;
};
const TRACE_USAGE_CACHE_MS = 60 * 1000;
const TRACE_MATCH_STRONG_WINDOW_MS = 3 * 1000;
const TRACE_MATCH_WINDOW_MS = 10 * 1000;
const TRACE_MATCH_MAX_WINDOW_MS = 30 * 1000;
const normalizeTraceAuthIndex = (value: unknown): string | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toString();
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
return null;
};
const normalizeTracePath = (value?: string) =>
String(value ?? '')
.replace(/^"+|"+$/g, '')
.split('?')[0]
.trim();
const TRACEABLE_EXACT_PATHS = new Set(['/v1/chat/completions', '/v1/messages', '/v1/responses']);
const TRACEABLE_PREFIX_PATHS = ['/v1beta/models'];
const normalizeTraceablePath = (value?: string): string => {
const normalized = normalizeTracePath(value);
if (!normalized || normalized === '/') return normalized;
return normalized.replace(/\/+$/, '');
};
const isTraceableRequestPath = (value?: string): boolean => {
const normalizedPath = normalizeTraceablePath(value);
if (!normalizedPath) return false;
if (TRACEABLE_EXACT_PATHS.has(normalizedPath)) return true;
return TRACEABLE_PREFIX_PATHS.some((prefix) => normalizedPath.startsWith(prefix));
};
const scoreTraceCandidate = (
line: ParsedLogLine,
detail: UsageDetailWithEndpoint
): TraceCandidate | null => {
let score = 0;
let timeDeltaMs: number | null = null;
const logTimestampMs = line.timestamp ? Date.parse(line.timestamp) : Number.NaN;
const detailTimestampMs = detail.__timestampMs;
if (!Number.isNaN(logTimestampMs) && detailTimestampMs > 0) {
timeDeltaMs = Math.abs(logTimestampMs - detailTimestampMs);
if (timeDeltaMs <= TRACE_MATCH_STRONG_WINDOW_MS) {
score += 42;
} else if (timeDeltaMs <= TRACE_MATCH_WINDOW_MS) {
score += 30;
} else if (timeDeltaMs <= TRACE_MATCH_MAX_WINDOW_MS) {
score += 12;
} else {
score -= 12;
}
}
let methodMatched = false;
if (line.method && detail.__endpointMethod) {
if (line.method.toUpperCase() === detail.__endpointMethod.toUpperCase()) {
score += 18;
methodMatched = true;
} else {
score -= 8;
}
}
const logPath = normalizeTracePath(line.path);
const detailPath = normalizeTracePath(detail.__endpointPath);
let pathMatched = false;
if (logPath && detailPath) {
if (logPath === detailPath) {
score += 24;
pathMatched = true;
} else if (logPath.startsWith(detailPath) || detailPath.startsWith(logPath)) {
score += 12;
pathMatched = true;
} else {
score -= 8;
}
}
if (typeof line.statusCode === 'number') {
const logFailed = line.statusCode >= 400;
score += logFailed === detail.failed ? 10 : -6;
}
if (timeDeltaMs !== null && timeDeltaMs > TRACE_MATCH_MAX_WINDOW_MS && !methodMatched && !pathMatched) {
return null;
}
if (score <= 0) return null;
const confidence: TraceConfidence = score >= 70 ? 'high' : score >= 45 ? 'medium' : 'low';
return { detail, score, confidence, timeDeltaMs };
};
const extractLogLevel = (value: string): LogLevel | undefined => {
const normalized = value.trim().toLowerCase();
if (normalized === 'warning') return 'warn';
@@ -351,7 +489,8 @@ export function LogsPage() {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
const config = useConfigStore((state) => state.config);
const requestLogEnabled = config?.requestLog ?? false;
const [activeTab, setActiveTab] = useState<TabType>('logs');
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
@@ -362,11 +501,19 @@ export function LogsPage() {
const deferredSearchQuery = useDeferredValue(searchQuery);
const [hideManagementLogs, setHideManagementLogs] = useState(true);
const [showRawLogs, setShowRawLogs] = useState(false);
const [methodFilters, setMethodFilters] = useState<HttpMethod[]>([]);
const [statusFilters, setStatusFilters] = useState<StatusGroup[]>([]);
const [pathFilters, setPathFilters] = useState<string[]>([]);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const [errorLogsError, setErrorLogsError] = useState('');
const [requestLogId, setRequestLogId] = useState<string | null>(null);
const [requestLogDownloading, setRequestLogDownloading] = useState(false);
const [traceLogLine, setTraceLogLine] = useState<ParsedLogLine | null>(null);
const [traceUsageDetails, setTraceUsageDetails] = useState<UsageDetailWithEndpoint[]>([]);
const [traceAuthFileMap, setTraceAuthFileMap] = useState<Map<string, TraceCredentialInfo>>(new Map());
const [traceLoading, setTraceLoading] = useState(false);
const [traceError, setTraceError] = useState('');
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
@@ -379,11 +526,77 @@ export function LogsPage() {
} | null>(null);
const logRequestInFlightRef = useRef(false);
const pendingFullReloadRef = useRef(false);
const traceUsageLoadedAtRef = useRef(0);
const traceAuthLoadedAtRef = useRef(0);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(0);
const disableControls = connectionStatus !== 'connected';
const traceSourceInfoMap = useMemo(() => {
const map = new Map<string, TraceSourceInfo>();
const registerSource = (sourceId: string, displayName: string, type: string) => {
if (!sourceId || !displayName || map.has(sourceId)) return;
map.set(sourceId, { displayName, type });
};
const registerCandidates = (displayName: string, type: string, candidates: string[]) => {
candidates.forEach((sourceId) => registerSource(sourceId, displayName, type));
};
(config?.geminiApiKeys || []).forEach((item, index) => {
const displayName = item.prefix?.trim() || `Gemini #${index + 1}`;
registerCandidates(
displayName,
'gemini',
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
(config?.claudeApiKeys || []).forEach((item, index) => {
const displayName = item.prefix?.trim() || `Claude #${index + 1}`;
registerCandidates(
displayName,
'claude',
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
(config?.codexApiKeys || []).forEach((item, index) => {
const displayName = item.prefix?.trim() || `Codex #${index + 1}`;
registerCandidates(
displayName,
'codex',
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
(config?.vertexApiKeys || []).forEach((item, index) => {
const displayName = item.prefix?.trim() || `Vertex #${index + 1}`;
registerCandidates(
displayName,
'vertex',
buildCandidateUsageSourceIds({ apiKey: item.apiKey, prefix: item.prefix })
);
});
(config?.openaiCompatibility || []).forEach((provider, providerIndex) => {
const displayName = provider.prefix?.trim() || provider.name || `OpenAI #${providerIndex + 1}`;
const candidates = new Set<string>();
buildCandidateUsageSourceIds({ prefix: provider.prefix }).forEach((sourceId) =>
candidates.add(sourceId)
);
(provider.apiKeyEntries || []).forEach((entry) => {
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((sourceId) =>
candidates.add(sourceId)
);
});
registerCandidates(displayName, 'openai', Array.from(candidates));
});
return map;
}, [config]);
const isNearBottom = (node: HTMLDivElement | null) => {
if (!node) return true;
@@ -551,6 +764,56 @@ export function LogsPage() {
}
};
const loadTraceUsageDetails = useCallback(async () => {
if (traceLoading) return;
const now = Date.now();
const usageFresh =
traceUsageLoadedAtRef.current > 0 && now - traceUsageLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
const authFresh =
traceAuthLoadedAtRef.current > 0 && now - traceAuthLoadedAtRef.current < TRACE_USAGE_CACHE_MS;
if (usageFresh && authFresh) return;
setTraceLoading(true);
setTraceError('');
try {
const [usageResponse, authFilesResponse] = await Promise.all([
usageFresh ? Promise.resolve(null) : usageApi.getUsage(),
authFresh ? Promise.resolve(null) : authFilesApi.list().catch(() => null)
]);
if (usageResponse !== null) {
const usageData = usageResponse?.usage ?? usageResponse;
const details = collectUsageDetailsWithEndpoint(usageData);
setTraceUsageDetails(details);
traceUsageLoadedAtRef.current = now;
}
if (authFilesResponse !== null) {
const files = Array.isArray(authFilesResponse)
? authFilesResponse
: (authFilesResponse as { files?: AuthFileItem[] })?.files;
if (Array.isArray(files)) {
const map = new Map<string, TraceCredentialInfo>();
files.forEach((file) => {
const key = normalizeTraceAuthIndex(file['auth_index'] ?? file.authIndex);
if (!key) return;
map.set(key, {
name: file.name || key,
type: (file.type || file.provider || '').toString()
});
});
setTraceAuthFileMap(map);
traceAuthLoadedAtRef.current = now;
}
}
} catch (err: unknown) {
setTraceError(getErrorMessage(err) || t('logs.trace_usage_load_error'));
} finally {
setTraceLoading(false);
}
}, [t, traceLoading]);
useEffect(() => {
if (connectionStatus === 'connected') {
latestTimestampRef.current = 0;
@@ -595,47 +858,173 @@ export function LogsPage() {
const isSearching = trimmedSearchQuery.length > 0;
const baseLines = isSearching ? logState.buffer : visibleLines;
const { filteredLines, removedCount } = useMemo(() => {
const methodFilterSet = useMemo(() => new Set(methodFilters), [methodFilters]);
const statusFilterSet = useMemo(() => new Set(statusFilters), [statusFilters]);
const pathFilterSet = useMemo(() => new Set(pathFilters), [pathFilters]);
const hasStructuredFilters = methodFilters.length > 0 || statusFilters.length > 0 || pathFilters.length > 0;
const {
parsedSearchLines,
filteredParsedLines,
filteredLines,
removedCount,
} = useMemo(() => {
let working = baseLines;
let removed = 0;
if (hideManagementLogs) {
const next: string[] = [];
for (const line of working) {
if (line.includes(MANAGEMENT_API_PREFIX)) {
removed += 1;
} else {
next.push(line);
}
}
working = next;
working = working.filter((line) => !line.includes(MANAGEMENT_API_PREFIX));
}
if (trimmedSearchQuery) {
const queryLowered = trimmedSearchQuery.toLowerCase();
const next: string[] = [];
for (const line of working) {
if (line.toLowerCase().includes(queryLowered)) {
next.push(line);
} else {
removed += 1;
}
}
working = next;
working = working.filter((line) => line.toLowerCase().includes(queryLowered));
}
return { filteredLines: working, removedCount: removed };
}, [baseLines, hideManagementLogs, trimmedSearchQuery]);
const parsed = working.map((line) => parseLogLine(line));
const filteredParsed = parsed.filter((line) => {
if (methodFilterSet.size > 0 && (!line.method || !methodFilterSet.has(line.method))) {
return false;
}
const parsedVisibleLines = useMemo(() => {
if (showRawLogs) return [];
return filteredLines.map((line) => parseLogLine(line));
}, [filteredLines, showRawLogs]);
const statusGroup = resolveStatusGroup(line.statusCode);
if (statusFilterSet.size > 0 && (!statusGroup || !statusFilterSet.has(statusGroup))) {
return false;
}
if (pathFilterSet.size > 0 && (!line.path || !pathFilterSet.has(line.path))) {
return false;
}
return true;
});
return {
parsedSearchLines: parsed,
filteredParsedLines: filteredParsed,
filteredLines: filteredParsed.map((line) => line.raw),
removedCount: Math.max(baseLines.length - filteredParsed.length, 0)
};
}, [
baseLines,
hideManagementLogs,
methodFilterSet,
pathFilterSet,
statusFilterSet,
trimmedSearchQuery
]);
const parsedVisibleLines = useMemo(
() => (showRawLogs ? [] : filteredParsedLines),
[filteredParsedLines, showRawLogs]
);
const rawVisibleText = useMemo(() => filteredLines.join('\n'), [filteredLines]);
const traceCandidates = useMemo(() => {
if (!traceLogLine) return [];
const scored = traceUsageDetails
.map((detail) => scoreTraceCandidate(traceLogLine, detail))
.filter((item): item is TraceCandidate => item !== null)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aDelta = a.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
const bDelta = b.timeDeltaMs ?? Number.MAX_SAFE_INTEGER;
return aDelta - bDelta;
});
return scored.slice(0, 8);
}, [traceLogLine, traceUsageDetails]);
const resolveTraceSourceInfo = useCallback(
(sourceRaw: string, authIndex: unknown): TraceSourceInfo => {
const source = sourceRaw.trim();
const matchedSource = traceSourceInfoMap.get(source);
if (matchedSource) {
return matchedSource;
}
const authIndexKey = normalizeTraceAuthIndex(authIndex);
if (authIndexKey) {
const authInfo = traceAuthFileMap.get(authIndexKey);
if (authInfo) {
return {
displayName: authInfo.name || authIndexKey,
type: authInfo.type
};
}
}
return {
displayName: source.startsWith('t:') ? source.slice(2) : source || '-',
type: ''
};
},
[traceAuthFileMap, traceSourceInfoMap]
);
const canLoadMore = !isSearching && logState.visibleFrom > 0;
const methodCounts = useMemo(() => {
const counts: Partial<Record<HttpMethod, number>> = {};
parsedSearchLines.forEach((line) => {
if (!line.method) return;
counts[line.method] = (counts[line.method] ?? 0) + 1;
});
return counts;
}, [parsedSearchLines]);
const statusCounts = useMemo(() => {
const counts: Partial<Record<StatusGroup, number>> = {};
parsedSearchLines.forEach((line) => {
const statusGroup = resolveStatusGroup(line.statusCode);
if (!statusGroup) return;
counts[statusGroup] = (counts[statusGroup] ?? 0) + 1;
});
return counts;
}, [parsedSearchLines]);
const pathOptions = useMemo(() => {
const counts = new Map<string, number>();
parsedSearchLines.forEach((line) => {
if (!line.path) return;
counts.set(line.path, (counts.get(line.path) ?? 0) + 1);
});
return Array.from(counts.entries())
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, PATH_FILTER_LIMIT)
.map(([path, count]) => ({ path, count }));
}, [parsedSearchLines]);
useEffect(() => {
if (pathFilters.length === 0) return;
const validPathSet = new Set(pathOptions.map((item) => item.path));
setPathFilters((prev) => {
const next = prev.filter((path) => validPathSet.has(path));
return next.length === prev.length ? prev : next;
});
}, [pathFilters, pathOptions]);
const toggleMethodFilter = (method: HttpMethod) => {
setMethodFilters((prev) =>
prev.includes(method) ? prev.filter((item) => item !== method) : [...prev, method]
);
};
const toggleStatusFilter = (statusGroup: StatusGroup) => {
setStatusFilters((prev) =>
prev.includes(statusGroup) ? prev.filter((item) => item !== statusGroup) : [...prev, statusGroup]
);
};
const togglePathFilter = (path: string) => {
setPathFilters((prev) =>
prev.includes(path) ? prev.filter((item) => item !== path) : [...prev, path]
);
};
const clearStructuredFilters = () => {
setMethodFilters([]);
setStatusFilters([]);
setPathFilters([]);
};
const prependVisibleLines = useCallback(() => {
const node = logViewerRef.current;
if (!node) return;
@@ -777,6 +1166,18 @@ export function LogsPage() {
}
};
const openTraceModal = (line: ParsedLogLine) => {
if (!isTraceableRequestPath(line.path)) return;
cancelLongPress();
setTraceLogLine(line);
void loadTraceUsageDetails();
};
const closeTraceModal = () => {
if (requestLogDownloading) return;
setTraceLogLine(null);
};
const closeRequestLogModal = () => {
if (requestLogDownloading) return;
setRequestLogId(null);
@@ -866,6 +1267,86 @@ export function LogsPage() {
/>
</div>
<div className={styles.structuredFilters}>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_method')}</span>
<div className={styles.filterChipList}>
{HTTP_METHODS.map((method) => {
const active = methodFilters.includes(method);
const count = methodCounts[method] ?? 0;
return (
<button
key={method}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => toggleMethodFilter(method)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{method} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_status')}</span>
<div className={styles.filterChipList}>
{STATUS_GROUPS.map((statusGroup) => {
const active = statusFilters.includes(statusGroup);
const count = statusCounts[statusGroup] ?? 0;
return (
<button
key={statusGroup}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => toggleStatusFilter(statusGroup)}
disabled={count === 0 && !active}
aria-pressed={active}
>
{t(`logs.filter_status_${statusGroup}`)} ({count})
</button>
);
})}
</div>
</div>
<div className={styles.filterChipGroup}>
<span className={styles.filterChipLabel}>{t('logs.filter_path')}</span>
<div className={styles.filterChipList}>
{pathOptions.length === 0 ? (
<span className={styles.filterChipHint}>{t('logs.filter_path_empty')}</span>
) : (
pathOptions.map(({ path, count }) => {
const active = pathFilters.includes(path);
return (
<button
key={path}
type="button"
className={`${styles.filterChip} ${active ? styles.filterChipActive : ''}`}
onClick={() => togglePathFilter(path)}
aria-pressed={active}
title={path}
>
{path} ({count})
</button>
);
})
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={clearStructuredFilters}
disabled={!hasStructuredFilters}
>
{t('logs.clear_filters')}
</Button>
</div>
<ToggleSwitch
checked={hideManagementLogs}
onChange={setHideManagementLogs}
@@ -973,6 +1454,7 @@ export function LogsPage() {
) : (
<div className={styles.logList}>
{parsedVisibleLines.map((line, index) => {
const canTraceRequest = isTraceableRequestPath(line.path);
const rowClassNames = [styles.logRow];
if (line.level === 'warn') rowClassNames.push(styles.rowWarn);
if (line.level === 'error' || line.level === 'fatal')
@@ -1063,6 +1545,20 @@ export function LogsPage() {
)}
{line.message && <span className={styles.message}>{line.message}</span>}
{canTraceRequest && (
<button
type="button"
className={styles.traceButton}
onClick={(event) => {
event.stopPropagation();
openTraceModal(line);
}}
title={t('logs.trace_button')}
>
{t('logs.trace_button')}
</button>
)}
</div>
</div>
);
@@ -1142,6 +1638,162 @@ export function LogsPage() {
)}
</div>
<Modal
open={Boolean(traceLogLine)}
onClose={closeTraceModal}
title={t('logs.trace_title')}
footer={
<>
{traceLogLine?.requestId && (
<Button
variant="secondary"
onClick={() => {
if (traceLogLine.requestId) {
void downloadRequestLog(traceLogLine.requestId);
}
}}
loading={requestLogDownloading}
>
{t('logs.trace_download_request_log')}
</Button>
)}
<Button variant="secondary" onClick={closeTraceModal} disabled={requestLogDownloading}>
{t('common.close')}
</Button>
</>
}
>
{traceLogLine && (
<div className={styles.tracePanel}>
<div className={styles.traceNotice}>{t('logs.trace_notice')}</div>
<h3 className={styles.traceSectionTitle}>{t('logs.trace_log_info')}</h3>
<div className={styles.traceInfoGrid}>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_request_id')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.requestId || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_method')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.method || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_path')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.path || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_status_code')}</span>
<span className={styles.traceInfoValue}>
{typeof traceLogLine.statusCode === 'number' ? traceLogLine.statusCode : '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_latency')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.latency || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_ip')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.ip || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.timestamp || '-'}</span>
</div>
<div className={`${styles.traceInfoItem} ${styles.traceInfoItemWide}`}>
<span className={styles.traceInfoLabel}>{t('logs.trace_message')}</span>
<span className={styles.traceInfoValue}>{traceLogLine.message || '-'}</span>
</div>
</div>
<h3 className={styles.traceSectionTitle}>{t('logs.trace_candidates_title')}</h3>
{traceLoading ? (
<div className="hint">{t('logs.trace_loading')}</div>
) : traceError ? (
<div className="error-box">{traceError}</div>
) : traceCandidates.length === 0 ? (
<div className="hint">{t('logs.trace_no_match')}</div>
) : (
<div className={styles.traceCandidates}>
{traceCandidates.map((candidate) => {
const confidenceClass =
candidate.confidence === 'high'
? styles.traceConfidenceHigh
: candidate.confidence === 'medium'
? styles.traceConfidenceMedium
: styles.traceConfidenceLow;
const sourceInfo = resolveTraceSourceInfo(
String(candidate.detail.source ?? ''),
candidate.detail.auth_index
);
return (
<div
key={`${candidate.detail.__endpoint}-${candidate.detail.__modelName}-${candidate.detail.timestamp}-${candidate.detail.source}`}
className={styles.traceCandidate}
>
<div className={styles.traceCandidateHeader}>
<span className={`${styles.traceConfidenceBadge} ${confidenceClass}`}>
{t(`logs.trace_confidence_${candidate.confidence}`)}
</span>
<span className={styles.traceScore}>
{t('logs.trace_score', { score: candidate.score })}
</span>
{candidate.timeDeltaMs !== null && (
<span className={styles.traceDelta}>
{t('logs.trace_delta_seconds', {
seconds: (candidate.timeDeltaMs / 1000).toFixed(2)
})}
</span>
)}
</div>
<div className={styles.traceCandidateGrid}>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_endpoint')}</span>
<span className={styles.traceInfoValue}>{candidate.detail.__endpoint}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_model')}</span>
<span className={styles.traceInfoValue}>{candidate.detail.__modelName || '-'}</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_source')}</span>
<span
className={styles.traceInfoValue}
title={String(candidate.detail.source || '-')}
>
<span>{sourceInfo.displayName}</span>
{sourceInfo.type && (
<span className={styles.traceSourceType}>{sourceInfo.type}</span>
)}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_auth_index')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.auth_index ?? '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_timestamp')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.timestamp || '-'}
</span>
</div>
<div className={styles.traceInfoItem}>
<span className={styles.traceInfoLabel}>{t('logs.trace_result')}</span>
<span className={styles.traceInfoValue}>
{candidate.detail.failed ? t('stats.failure') : t('stats.success')}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</Modal>
<Modal
open={Boolean(requestLogId)}
onClose={closeRequestLogModal}
+92
View File
@@ -859,6 +859,98 @@
}
}
.requestEventsActions {
display: inline-flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
}
.requestEventsToolbar {
display: flex;
gap: $spacing-sm;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: $spacing-sm;
}
.requestEventsFilterItem {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 180px;
}
.requestEventsFilterLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.requestEventsSelect {
min-width: 180px;
}
.requestEventsMeta {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
margin-bottom: $spacing-xs;
font-size: 12px;
color: var(--text-secondary);
flex-wrap: wrap;
}
.requestEventsLimitHint {
color: var(--text-tertiary);
}
.requestEventsTableWrapper {
overflow: auto;
max-height: 460px;
border: 1px solid var(--border-color);
border-radius: $radius-md;
}
.requestEventsTimestamp {
white-space: nowrap;
min-width: 160px;
}
.requestEventsSourceCell,
.requestEventsAuthIndex {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 11px;
max-width: 180px;
word-break: break-all;
}
.requestEventsResultSuccess {
color: var(--success-badge-text, #065f46);
background: var(--success-badge-bg, #d1fae5);
border: 1px solid var(--success-badge-border, #6ee7b7);
}
.requestEventsResultFailed {
color: var(--failure-badge-text);
background: var(--failure-badge-bg);
border: 1px solid var(--failure-badge-border);
}
.requestEventsResultSuccess,
.requestEventsResultFailed {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: $radius-full;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.chartLineHeader {
display: inline-flex;
align-items: center;
+11
View File
@@ -25,6 +25,7 @@ import {
ModelStatsCard,
PriceSettingsCard,
CredentialStatsCard,
RequestEventsDetailsCard,
TokenBreakdownChart,
CostTrendChart,
ServiceHealthCard,
@@ -361,6 +362,16 @@ export function UsagePage() {
<ModelStatsCard modelStats={modelStats} loading={loading} hasPrices={hasPrices} />
</div>
<RequestEventsDetailsCard
usage={filteredUsage}
loading={loading}
geminiKeys={config?.geminiApiKeys || []}
claudeConfigs={config?.claudeApiKeys || []}
codexConfigs={config?.codexApiKeys || []}
vertexConfigs={config?.vertexApiKeys || []}
openaiProviders={config?.openaiCompatibility || []}
/>
{/* Credential Stats */}
<CredentialStatsCard
usage={filteredUsage}
+140
View File
@@ -8,8 +8,13 @@ import { normalizeApiBase } from '@/utils/connection';
import { apiCallApi, getApiCallErrorMessage } from './apiCall';
const DEFAULT_CLAUDE_BASE_URL = 'https://api.anthropic.com';
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com';
const DEFAULT_ANTHROPIC_VERSION = '2023-06-01';
const CLAUDE_MODELS_IN_FLIGHT = new Map<string, Promise<ReturnType<typeof normalizeModelList>>>();
const GEMINI_MODELS_IN_FLIGHT = new Map<string, Promise<ReturnType<typeof normalizeModelList>>>();
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === 'object' && !Array.isArray(value);
const buildRequestSignature = (url: string, headers: Record<string, string>) => {
const headerSignature = Object.entries(headers)
@@ -45,6 +50,21 @@ const buildClaudeModelsEndpoint = (baseUrl: string): string => {
return `${trimmed}/v1/models`;
};
const buildGeminiModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeApiBase(baseUrl);
const fallback = normalized || DEFAULT_GEMINI_BASE_URL;
let trimmed = fallback.replace(/\/+$/g, '');
trimmed = trimmed.replace(/\/v1beta\/models$/i, '');
trimmed = trimmed.replace(/\/v1beta(?:\/.*)?$/i, '');
return `${trimmed}/v1beta/models`;
};
const stripGeminiModelResourceName = (value: string): string => {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
return trimmed.replace(/^\/?models\//i, '');
};
const hasHeader = (headers: Record<string, string>, name: string) => {
const target = name.toLowerCase();
return Object.keys(headers).some((key) => key.toLowerCase() === target);
@@ -81,6 +101,40 @@ export const modelsApi = {
return normalizeModelList(payload, { dedupe: true });
},
/**
* Fetch models from /v1/models endpoint via api-call.
* Useful when the configured baseUrl is the upstream host root (e.g. https://api.example.com).
*/
async fetchV1ModelsViaApiCall(
baseUrl: string,
apiKey?: string,
headers: Record<string, string> = {}
) {
const endpoint = buildV1ModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const resolvedHeaders = { ...headers };
const hasAuthHeader = Boolean(resolvedHeaders.Authorization || resolvedHeaders.authorization);
if (apiKey && !hasAuthHeader) {
resolvedHeaders.Authorization = `Bearer ${apiKey}`;
}
const result = await apiCallApi.request({
method: 'GET',
url: endpoint,
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const payload = result.body ?? result.bodyText;
return normalizeModelList(payload, { dedupe: true });
},
/**
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
*/
@@ -114,10 +168,18 @@ export const modelsApi = {
return normalizeModelList(payload, { dedupe: true });
},
buildV1ModelsEndpoint(baseUrl: string) {
return buildV1ModelsEndpoint(baseUrl);
},
buildClaudeModelsEndpoint(baseUrl: string) {
return buildClaudeModelsEndpoint(baseUrl);
},
buildGeminiModelsEndpoint(baseUrl: string) {
return buildGeminiModelsEndpoint(baseUrl);
},
/**
* Fetch Claude models from /v1/models via api-call.
* Anthropic requires `x-api-key` and `anthropic-version` headers.
@@ -171,4 +233,82 @@ export const modelsApi = {
CLAUDE_MODELS_IN_FLIGHT.delete(signature);
}
},
/**
* Fetch Gemini models from /v1beta/models via api-call.
* Gemini API accepts API key via query param or `x-goog-api-key` header.
*/
async fetchGeminiModelsViaApiCall(
baseUrl: string,
apiKey?: string,
headers: Record<string, string> = {}
) {
const endpoint = buildGeminiModelsEndpoint(baseUrl);
if (!endpoint) {
throw new Error('Invalid base url');
}
const resolvedHeaders = { ...headers };
const resolvedApiKey = String(apiKey ?? '').trim();
if (resolvedApiKey && !hasHeader(resolvedHeaders, 'x-goog-api-key')) {
resolvedHeaders['x-goog-api-key'] = resolvedApiKey;
}
const signature = buildRequestSignature(endpoint, resolvedHeaders);
const existing = GEMINI_MODELS_IN_FLIGHT.get(signature);
if (existing) return existing;
const request = (async () => {
const seen = new Set<string>();
const collected: ReturnType<typeof normalizeModelList> = [];
let pageToken = '';
for (let page = 0; page < 20; page += 1) {
const url = new URL(endpoint);
if (pageToken) {
url.searchParams.set('pageToken', pageToken);
}
const result = await apiCallApi.request({
method: 'GET',
url: url.toString(),
header: Object.keys(resolvedHeaders).length ? resolvedHeaders : undefined
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const payload = result.body ?? result.bodyText;
const normalized = normalizeModelList(payload, { dedupe: false });
normalized.forEach((model) => {
const name = stripGeminiModelResourceName(model.name);
const key = (name || '').toLowerCase();
if (!key || seen.has(key)) return;
seen.add(key);
const resolved = { ...model, name };
if (resolved.alias && resolved.alias.trim() === name) {
resolved.alias = undefined;
}
collected.push(resolved);
});
const nextToken =
isRecord(payload) && typeof payload.nextPageToken === 'string' ? payload.nextPageToken : '';
if (!nextToken) {
break;
}
pageToken = nextToken;
}
return collected;
})();
GEMINI_MODELS_IN_FLIGHT.set(signature, request);
try {
return await request;
} finally {
GEMINI_MODELS_IN_FLIGHT.delete(signature);
}
},
};
+18
View File
@@ -58,8 +58,10 @@ const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.websockets !== undefined) payload.websockets = config.websockets;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
@@ -68,6 +70,18 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
if (config.excludedModels && config.excludedModels.length) {
payload['excluded-models'] = config.excludedModels;
}
if (config.cloak) {
const cloakPayload: Record<string, unknown> = {};
const mode = config.cloak.mode?.trim();
if (mode) cloakPayload.mode = mode;
if (config.cloak.strictMode !== undefined) cloakPayload['strict-mode'] = config.cloak.strictMode;
if (config.cloak.sensitiveWords && config.cloak.sensitiveWords.length) {
cloakPayload['sensitive-words'] = config.cloak.sensitiveWords;
}
if (Object.keys(cloakPayload).length) {
payload.cloak = cloakPayload;
}
}
return payload;
};
@@ -85,6 +99,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -97,11 +112,14 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
const models = serializeModelAliases(config.models);
if (models && models.length) payload.models = models;
if (config.excludedModels && config.excludedModels.length) {
payload['excluded-models'] = config.excludedModels;
}
+44
View File
@@ -1,5 +1,6 @@
import type {
ApiKeyEntry,
CloakConfig,
GeminiKeyConfig,
ModelAlias,
OpenAIProviderConfig,
@@ -118,11 +119,20 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const priority = record?.priority ?? record?.['priority'];
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
const parsed = Number(priority);
if (Number.isFinite(parsed)) {
config.priority = parsed;
}
}
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = record ? record['base-url'] ?? record.baseUrl : undefined;
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
const websockets = normalizeBoolean(record?.websockets ?? record?.['websockets']);
if (websockets !== undefined) config.websockets = websockets;
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
@@ -135,6 +145,31 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
record?.excluded_models
);
if (excludedModels.length) config.excludedModels = excludedModels;
const cloakRaw = record?.cloak;
if (isRecord(cloakRaw)) {
const cloak: CloakConfig = {};
const mode = cloakRaw.mode ?? cloakRaw['mode'];
if (typeof mode === 'string' && mode.trim()) {
cloak.mode = mode.trim();
}
const strictMode = normalizeBoolean(
cloakRaw['strict-mode'] ?? cloakRaw.strictMode ?? cloakRaw.strict_mode
);
if (strictMode !== undefined) {
cloak.strictMode = strictMode;
}
const sensitiveWords = normalizeExcludedModels(
cloakRaw['sensitive-words'] ?? cloakRaw.sensitiveWords ?? cloakRaw.sensitive_words
);
if (sensitiveWords.length) {
cloak.sensitiveWords = sensitiveWords;
}
if (Object.keys(cloak).length) {
config.cloak = cloak;
}
}
return config;
};
@@ -149,12 +184,21 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const priority = record?.priority ?? record?.['priority'];
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
const parsed = Number(priority);
if (Number.isFinite(parsed)) {
config.priority = parsed;
}
}
const prefix = normalizePrefix(record?.prefix ?? record?.['prefix']);
if (prefix) config.prefix = prefix;
const baseUrl = record ? record['base-url'] ?? record.baseUrl ?? record['base_url'] : undefined;
if (baseUrl) config.baseUrl = String(baseUrl);
const proxyUrl = record ? record['proxy-url'] ?? record.proxyUrl ?? record['proxy_url'] : undefined;
if (proxyUrl) config.proxyUrl = String(proxyUrl);
const models = normalizeModelAliases(record?.models);
if (models.length) config.models = models;
const headers = normalizeHeaders(record?.headers);
if (headers) config.headers = headers;
const excludedModels = normalizeExcludedModels(record?.['excluded-models'] ?? record?.excludedModels);
+2
View File
@@ -10,3 +10,5 @@ export { useConfigStore } from './useConfigStore';
export { useModelsStore } from './useModelsStore';
export { useQuotaStore } from './useQuotaStore';
export { useOpenAIEditDraftStore } from './useOpenAIEditDraftStore';
export { useClaudeEditDraftStore } from './useClaudeEditDraftStore';
export { useUsageStatsStore, USAGE_STATS_STALE_TIME_MS } from './useUsageStatsStore';
+207
View File
@@ -0,0 +1,207 @@
/**
* Claude provider editor draft state.
*
* Why this exists:
* - The app uses `PageTransition` with iOS-style stacked routes for `/ai-providers/*`.
* - Entering `/ai-providers/claude/.../models` creates a new route layer, so component-local state
* inside the Claude edit layout is not shared between the edit screen and the model picker screen.
* - This store makes the Claude edit draft shared across route layers keyed by provider index/new.
*/
import type { SetStateAction } from 'react';
import { create } from 'zustand';
import type { ProviderFormState } from '@/components/providers/types';
export type ClaudeTestStatus = 'idle' | 'loading' | 'success' | 'error';
type ClaudeEditDraft = {
initialized: boolean;
baselineSignature: string;
form: ProviderFormState;
testModel: string;
testStatus: ClaudeTestStatus;
testMessage: string;
};
interface ClaudeEditDraftState {
drafts: Record<string, ClaudeEditDraft>;
refCounts: Record<string, number>;
acquireDraft: (key: string) => void;
releaseDraft: (key: string) => void;
ensureDraft: (key: string) => void;
initDraft: (
key: string,
draft: Omit<ClaudeEditDraft, 'initialized'>
) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftForm: (
key: string,
action: SetStateAction<ProviderFormState>
) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (
key: string,
action: SetStateAction<ClaudeTestStatus>
) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
clearDraft: (key: string) => void;
}
const resolveAction = <T,>(action: SetStateAction<T>, prev: T): T =>
typeof action === 'function' ? (action as (previous: T) => T)(prev) : action;
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
const buildEmptyDraft = (): ClaudeEditDraft => ({
initialized: false,
baselineSignature: '',
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
testMessage: '',
});
export const useClaudeEditDraftStore = create<ClaudeEditDraftState>((set, get) => ({
drafts: {},
refCounts: {},
acquireDraft: (key) => {
if (!key) return;
set((state) => {
const existingDraft = state.drafts[key];
const currentCount = state.refCounts[key] ?? 0;
return {
drafts: existingDraft ? state.drafts : { ...state.drafts, [key]: buildEmptyDraft() },
refCounts: { ...state.refCounts, [key]: currentCount + 1 },
};
});
},
releaseDraft: (key) => {
if (!key) return;
set((state) => {
const currentCount = state.refCounts[key];
if (!currentCount) return state;
if (currentCount > 1) {
return { refCounts: { ...state.refCounts, [key]: currentCount - 1 } };
}
const nextCounts = { ...state.refCounts };
delete nextCounts[key];
const nextDrafts = { ...state.drafts };
delete nextDrafts[key];
return { refCounts: nextCounts, drafts: nextDrafts };
});
},
ensureDraft: (key) => {
if (!key) return;
const existing = get().drafts[key];
if (existing) return;
set((state) => ({
drafts: { ...state.drafts, [key]: buildEmptyDraft() },
}));
},
initDraft: (key, draft) => {
if (!key) return;
const existing = get().drafts[key];
if (existing?.initialized) return;
set((state) => ({
drafts: {
...state.drafts,
[key]: { ...draft, initialized: true },
},
}));
},
setDraftBaselineSignature: (key, signature) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
},
};
});
},
setDraftForm: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextForm = resolveAction(action, existing.form);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, form: nextForm },
},
};
});
},
setDraftTestModel: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testModel);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testModel: nextValue },
},
};
});
},
setDraftTestStatus: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testStatus);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testStatus: nextValue },
},
};
});
},
setDraftTestMessage: (key, action) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
const nextValue = resolveAction(action, existing.testMessage);
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, testMessage: nextValue },
},
};
});
},
clearDraft: (key) => {
if (!key) return;
set((state) => {
if (!state.drafts[key] && !state.refCounts[key]) return state;
const nextDrafts = { ...state.drafts };
delete nextDrafts[key];
const nextCounts = { ...state.refCounts };
delete nextCounts[key];
return { drafts: nextDrafts, refCounts: nextCounts };
});
},
}));
+54 -5
View File
@@ -22,6 +22,7 @@ export type KeyTestStatus = {
export type OpenAIEditDraft = {
initialized: boolean;
baselineSignature: string;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
@@ -31,8 +32,12 @@ export type OpenAIEditDraft = {
interface OpenAIEditDraftState {
drafts: Record<string, OpenAIEditDraft>;
refCounts: Record<string, number>;
acquireDraft: (key: string) => void;
releaseDraft: (key: string) => void;
ensureDraft: (key: string) => void;
initDraft: (key: string, draft: Omit<OpenAIEditDraft, 'initialized'>) => void;
setDraftBaselineSignature: (key: string, signature: string) => void;
setDraftForm: (key: string, action: SetStateAction<OpenAIFormState>) => void;
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
@@ -57,6 +62,7 @@ const buildEmptyForm = (): OpenAIFormState => ({
const buildEmptyDraft = (): OpenAIEditDraft => ({
initialized: false,
baselineSignature: '',
form: buildEmptyForm(),
testModel: '',
testStatus: 'idle',
@@ -66,6 +72,35 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
drafts: {},
refCounts: {},
acquireDraft: (key) => {
if (!key) return;
set((state) => {
const existingDraft = state.drafts[key];
const currentCount = state.refCounts[key] ?? 0;
return {
drafts: existingDraft ? state.drafts : { ...state.drafts, [key]: buildEmptyDraft() },
refCounts: { ...state.refCounts, [key]: currentCount + 1 },
};
});
},
releaseDraft: (key) => {
if (!key) return;
set((state) => {
const currentCount = state.refCounts[key];
if (!currentCount) return state;
if (currentCount > 1) {
return { refCounts: { ...state.refCounts, [key]: currentCount - 1 } };
}
const nextCounts = { ...state.refCounts };
delete nextCounts[key];
const nextDrafts = { ...state.drafts };
delete nextDrafts[key];
return { refCounts: nextCounts, drafts: nextDrafts };
});
},
ensureDraft: (key) => {
if (!key) return;
@@ -88,6 +123,19 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
}));
},
setDraftBaselineSignature: (key, signature) => {
if (!key) return;
set((state) => {
const existing = state.drafts[key] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[key]: { ...existing, initialized: true, baselineSignature: signature },
},
};
});
},
setDraftForm: (key, action) => {
if (!key) return;
set((state) => {
@@ -179,11 +227,12 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
clearDraft: (key) => {
if (!key) return;
set((state) => {
if (!state.drafts[key]) return state;
const next = { ...state.drafts };
delete next[key];
return { drafts: next };
if (!state.drafts[key] && !state.refCounts[key]) return state;
const nextDrafts = { ...state.drafts };
delete nextDrafts[key];
const nextCounts = { ...state.refCounts };
delete nextCounts[key];
return { drafts: nextDrafts, refCounts: nextCounts };
});
},
}));
+141
View File
@@ -0,0 +1,141 @@
import { create } from 'zustand';
import { usageApi } from '@/services/api';
import { useAuthStore } from '@/stores/useAuthStore';
import { collectUsageDetails, computeKeyStats, type KeyStats, type UsageDetail } from '@/utils/usage';
import i18n from '@/i18n';
export const USAGE_STATS_STALE_TIME_MS = 240_000;
export type LoadUsageStatsOptions = {
force?: boolean;
staleTimeMs?: number;
};
type UsageStatsSnapshot = Record<string, unknown>;
type UsageStatsState = {
usage: UsageStatsSnapshot | null;
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
error: string | null;
lastRefreshedAt: number | null;
scopeKey: string;
loadUsageStats: (options?: LoadUsageStatsOptions) => Promise<void>;
clearUsageStats: () => void;
};
const createEmptyKeyStats = (): KeyStats => ({ bySource: {}, byAuthIndex: {} });
let usageRequestToken = 0;
let inFlightUsageRequest: { id: number; scopeKey: string; promise: Promise<void> } | null = null;
const getErrorMessage = (error: unknown) =>
error instanceof Error
? error.message
: typeof error === 'string'
? error
: i18n.t('usage_stats.loading_error');
export const useUsageStatsStore = create<UsageStatsState>((set, get) => ({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
loading: false,
error: null,
lastRefreshedAt: null,
scopeKey: '',
loadUsageStats: async (options = {}) => {
const force = options.force === true;
const staleTimeMs = options.staleTimeMs ?? USAGE_STATS_STALE_TIME_MS;
const { apiBase = '', managementKey = '' } = useAuthStore.getState();
const scopeKey = `${apiBase}::${managementKey}`;
const state = get();
const scopeChanged = state.scopeKey !== scopeKey;
// 先复用同源 in-flight 请求,避免多个页面同时发起重复 /usage。
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey === scopeKey) {
await inFlightUsageRequest.promise;
return;
}
// 连接目标变化时,旧请求结果必须失效。
if (inFlightUsageRequest && inFlightUsageRequest.scopeKey !== scopeKey) {
usageRequestToken += 1;
inFlightUsageRequest = null;
}
const fresh =
!scopeChanged &&
state.lastRefreshedAt !== null &&
Date.now() - state.lastRefreshedAt < staleTimeMs;
if (!force && fresh) {
return;
}
if (scopeChanged) {
set({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
error: null,
lastRefreshedAt: null,
scopeKey
});
}
const requestId = (usageRequestToken += 1);
set({ loading: true, error: null, scopeKey });
const requestPromise = (async () => {
try {
const usageResponse = await usageApi.getUsage();
const rawUsage = usageResponse?.usage ?? usageResponse;
const usage =
rawUsage && typeof rawUsage === 'object' ? (rawUsage as UsageStatsSnapshot) : null;
if (requestId !== usageRequestToken) return;
set({
usage,
keyStats: computeKeyStats(usage),
usageDetails: collectUsageDetails(usage),
loading: false,
error: null,
lastRefreshedAt: Date.now(),
scopeKey
});
} catch (error: unknown) {
if (requestId !== usageRequestToken) return;
set({
loading: false,
error: getErrorMessage(error),
scopeKey
});
} finally {
if (inFlightUsageRequest?.id === requestId) {
inFlightUsageRequest = null;
}
}
})();
inFlightUsageRequest = { id: requestId, scopeKey, promise: requestPromise };
await requestPromise;
},
clearUsageStats: () => {
usageRequestToken += 1;
inFlightUsageRequest = null;
set({
usage: null,
keyStats: createEmptyKeyStats(),
usageDetails: [],
loading: false,
error: null,
lastRefreshedAt: null,
scopeKey: ''
});
}
}));
+4
View File
@@ -25,6 +25,10 @@ export interface AuthFileItem {
authIndex?: string | number | null;
runtimeOnly?: boolean | string;
disabled?: boolean;
unavailable?: boolean;
status?: string;
statusMessage?: string;
lastRefresh?: string | number;
modified?: number;
[key: string]: unknown;
}
+11
View File
@@ -16,23 +16,34 @@ export interface ApiKeyEntry {
headers?: Record<string, string>;
}
export interface CloakConfig {
mode?: string;
strictMode?: boolean;
sensitiveWords?: string[];
}
export interface GeminiKeyConfig {
apiKey: string;
priority?: number;
prefix?: string;
baseUrl?: string;
proxyUrl?: string;
models?: ModelAlias[];
headers?: Record<string, string>;
excludedModels?: string[];
}
export interface ProviderKeyConfig {
apiKey: string;
priority?: number;
prefix?: string;
baseUrl?: string;
websockets?: boolean;
proxyUrl?: string;
headers?: Record<string, string>;
models?: ModelAlias[];
excludedModels?: string[];
cloak?: CloakConfig;
}
export interface OpenAIProviderConfig {
+2 -2
View File
@@ -134,8 +134,8 @@ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{
id: 'gemini-pro-series',
label: 'Gemini Pro Series',
preferredModelId: 'gemini-3-pro-preview',
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
preferredModelId: 'gemini-3-1-pro-preview',
modelIds: ['gemini-3-1-pro-preview', 'gemini-3-pro-preview', 'gemini-2.5-pro'],
},
];
+51
View File
@@ -51,6 +51,13 @@ export interface UsageDetail {
__modelName?: string;
}
export interface UsageDetailWithEndpoint extends UsageDetail {
__endpoint: string;
__endpointMethod?: string;
__endpointPath?: string;
__timestampMs: number;
}
export interface ApiStats {
endpoint: string;
totalRequests: number;
@@ -65,6 +72,7 @@ export type UsageTimeRange = '7h' | '24h' | '7d' | 'all';
const TOKENS_PER_PRICE_UNIT = 1_000_000;
const MODEL_PRICE_STORAGE_KEY = 'cli-proxy-model-prices-v2';
const USAGE_ENDPOINT_METHOD_REGEX = /^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s+(\S+)/i;
const USAGE_TIME_RANGE_MS: Record<Exclude<UsageTimeRange, 'all'>, number> = {
'7h': 7 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
@@ -474,6 +482,49 @@ export function collectUsageDetails(usageData: unknown): UsageDetail[] {
return details;
}
/**
* 使 endpoint/method/path
*/
export function collectUsageDetailsWithEndpoint(usageData: unknown): UsageDetailWithEndpoint[] {
const apis = getApisRecord(usageData);
if (!apis) return [];
const details: UsageDetailWithEndpoint[] = [];
Object.entries(apis).forEach(([endpoint, apiEntry]) => {
if (!isRecord(apiEntry)) return;
const modelsRaw = apiEntry.models;
const models = isRecord(modelsRaw) ? modelsRaw : null;
if (!models) return;
const endpointMatch = endpoint.match(USAGE_ENDPOINT_METHOD_REGEX);
const endpointMethod = endpointMatch?.[1]?.toUpperCase();
const endpointPath = endpointMatch?.[2];
Object.entries(models).forEach(([modelName, modelEntry]) => {
if (!isRecord(modelEntry)) return;
const modelDetailsRaw = modelEntry.details;
const modelDetails = Array.isArray(modelDetailsRaw) ? modelDetailsRaw : [];
modelDetails.forEach((detailRaw) => {
if (!isRecord(detailRaw) || typeof detailRaw.timestamp !== 'string') return;
const detail = detailRaw as unknown as UsageDetail;
const timestampMs = Date.parse(detail.timestamp);
details.push({
...detail,
source: normalizeUsageSourceId(detail.source),
__modelName: modelName,
__endpoint: endpoint,
__endpointMethod: endpointMethod,
__endpointPath: endpointPath,
__timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs,
});
});
});
});
return details;
}
/**
* tokens
*/