Compare commits

..

16 Commits

Author SHA1 Message Date
LTbinglingfeng
8b3c4189f1 fix(providers): use /chat/completions for OpenAI test requests 2026-01-28 00:17:31 +08:00
hkfires
db5fb0d125 refactor(i18n): rename model alias translation keys 2026-01-27 15:38:07 +08:00
hkfires
9515d88e3c feat(ui): add model checklist for oauth exclusions 2026-01-27 14:56:23 +08:00
hkfires
2bf721974b feat(auth): load model lists via /model-definitions/{channel} instead of per-file
model sources.
2026-01-27 14:27:26 +08:00
hkfires
0c53dcfa80 docs(i18n): rename model mappings to aliases in ui strings 2026-01-27 12:00:42 +08:00
hkfires
034c086e31 feat(usage): show per-model success/failure counts 2026-01-25 11:29:34 +08:00
LTbinglingfeng
76e9eb4aa0 feat(auth-files): add disabled state styling for file cards 2026-01-25 00:01:15 +08:00
LTbinglingfeng
f22d392b21 fix 2026-01-24 18:04:59 +08:00
LTbinglingfeng
2539710075 fix(status-bar): extend health monitor window to 200 minutes 2026-01-24 17:17:29 +08:00
LTbinglingfeng
6bdc87aed6 fix(quota): unify Gemini CLI quota groups (Flash/Pro series) 2026-01-24 16:35:59 +08:00
LTbinglingfeng
268b92c59b feat(ui): implement custom AutocompleteInput and refactor model mapping UI 2026-01-24 15:55:31 +08:00
LTbinglingfeng
c89bbd5098 feat(auth-files): add auth-file model suggestions for OAuth mappings 2026-01-24 15:30:45 +08:00
LTbinglingfeng
2715f44a5e fix(ui): use crossfade animation with subtle movement for page transitions 2026-01-24 14:16:58 +08:00
LTbinglingfeng
305ddef900 fix(ui): improve GSAP page transition smoothness 2026-01-24 14:03:15 +08:00
LTbinglingfeng
7e56d33bf0 feat(auth-files): add prefix/proxy_url modal editor 2026-01-24 01:24:05 +08:00
LTbinglingfeng
80daf03fa6 feat(auth-files): add per-file enable/disable toggle 2026-01-24 00:10:04 +08:00
22 changed files with 1714 additions and 634 deletions

View File

@@ -14,6 +14,8 @@
gap: $spacing-lg; gap: $spacing-lg;
min-height: 0; min-height: 0;
flex: 1; flex: 1;
backface-visibility: hidden;
transform: translateZ(0);
// During animation, exit layer uses absolute positioning // During animation, exit layer uses absolute positioning
&--exit { &--exit {
@@ -22,17 +24,15 @@
z-index: 1; z-index: 1;
overflow: hidden; overflow: hidden;
pointer-events: none; pointer-events: none;
will-change: transform, opacity;
} }
} }
&--animating &__layer { &--animating &__layer {
will-change: transform, opacity; will-change: transform, opacity;
backface-visibility: hidden;
transform-style: preserve-3d;
} }
// When both layers exist, current layer also needs positioning &--animating &__layer:not(.page-transition__layer--exit) {
&--animating &__layer:not(&__layer--exit) {
position: relative; position: relative;
z-index: 0; z-index: 0;
} }

View File

@@ -9,9 +9,8 @@ interface PageTransitionProps {
scrollContainerRef?: React.RefObject<HTMLElement | null>; scrollContainerRef?: React.RefObject<HTMLElement | null>;
} }
const TRANSITION_DURATION = 0.5; const TRANSITION_DURATION = 0.35;
const EXIT_DURATION = 0.45; const TRAVEL_DISTANCE = 60;
const ENTER_DELAY = 0.08;
type LayerStatus = 'current' | 'exiting'; type LayerStatus = 'current' | 'exiting';
@@ -23,18 +22,14 @@ type Layer = {
type TransitionDirection = 'forward' | 'backward'; type TransitionDirection = 'forward' | 'backward';
export function PageTransition({ export function PageTransition({ render, getRouteOrder, scrollContainerRef }: PageTransitionProps) {
render,
getRouteOrder,
scrollContainerRef,
}: PageTransitionProps) {
const location = useLocation(); const location = useLocation();
const currentLayerRef = useRef<HTMLDivElement>(null); const currentLayerRef = useRef<HTMLDivElement>(null);
const exitingLayerRef = useRef<HTMLDivElement>(null); const exitingLayerRef = useRef<HTMLDivElement>(null);
const transitionDirectionRef = useRef<TransitionDirection>('forward');
const exitScrollOffsetRef = useRef(0); const exitScrollOffsetRef = useRef(0);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const [transitionDirection, setTransitionDirection] = useState<TransitionDirection>('forward');
const [layers, setLayers] = useState<Layer[]>(() => [ const [layers, setLayers] = useState<Layer[]>(() => [
{ {
key: location.key, key: location.key,
@@ -71,11 +66,11 @@ export function PageTransition({
? 'forward' ? 'forward'
: 'backward'; : 'backward';
let cancelled = false; transitionDirectionRef.current = nextDirection;
let cancelled = false;
queueMicrotask(() => { queueMicrotask(() => {
if (cancelled) return; if (cancelled) return;
setTransitionDirection(nextDirection);
setLayers((prev) => { setLayers((prev) => {
const prevCurrent = prev[prev.length - 1]; const prevCurrent = prev[prev.length - 1];
return [ return [
@@ -106,17 +101,18 @@ export function PageTransition({
if (!currentLayerRef.current) return; if (!currentLayerRef.current) return;
const currentLayerEl = currentLayerRef.current;
const exitingLayerEl = exitingLayerRef.current;
const scrollContainer = resolveScrollContainer(); const scrollContainer = resolveScrollContainer();
const scrollOffset = exitScrollOffsetRef.current; const scrollOffset = exitScrollOffsetRef.current;
if (scrollContainer && scrollOffset > 0) { if (scrollContainer && scrollOffset > 0) {
scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' });
} }
const containerHeight = scrollContainer?.clientHeight ?? 0; const transitionDirection = transitionDirectionRef.current;
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight; const enterFromY = transitionDirection === 'forward' ? TRAVEL_DISTANCE : -TRAVEL_DISTANCE;
const travelDistance = Math.max(containerHeight, viewportHeight, 1); const exitToY = transitionDirection === 'forward' ? -TRAVEL_DISTANCE : TRAVEL_DISTANCE;
const enterFromY = transitionDirection === 'forward' ? travelDistance : -travelDistance;
const exitToY = transitionDirection === 'forward' ? -travelDistance : travelDistance;
const exitBaseY = scrollOffset ? -scrollOffset : 0; const exitBaseY = scrollOffset ? -scrollOffset : 0;
const tl = gsap.timeline({ const tl = gsap.timeline({
@@ -126,43 +122,46 @@ export function PageTransition({
}, },
}); });
// Exit animation: fly out to top (slow-to-fast) // Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerRef.current) { if (exitingLayerEl) {
gsap.set(exitingLayerRef.current, { y: exitBaseY }); gsap.set(exitingLayerEl, { y: exitBaseY });
tl.fromTo( tl.to(
exitingLayerRef.current, exitingLayerEl,
{ y: exitBaseY, opacity: 1 },
{ {
y: exitBaseY + exitToY, y: exitBaseY + exitToY,
opacity: 0, opacity: 0,
duration: EXIT_DURATION, duration: TRANSITION_DURATION,
ease: 'power2.in', // fast finish to clear screen ease: 'circ.out',
force3D: true, force3D: true,
}, },
0 0
); );
} }
// Enter animation: slide in from bottom (slow-to-fast) // Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo( tl.fromTo(
currentLayerRef.current, currentLayerEl,
{ y: enterFromY, opacity: 0 }, { y: enterFromY, opacity: 0 },
{ {
y: 0, y: 0,
opacity: 1, opacity: 1,
duration: TRANSITION_DURATION, duration: TRANSITION_DURATION,
ease: 'power2.out', // smooth settle ease: 'circ.out',
clearProps: 'transform,opacity',
force3D: true, force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
},
}, },
ENTER_DELAY 0
); );
return () => { return () => {
tl.kill(); tl.kill();
gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
}; };
}, [isAnimating, transitionDirection, resolveScrollContainer]); }, [isAnimating, resolveScrollContainer]);
return ( return (
<div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}> <div className={`page-transition${isAnimating ? ' page-transition--animating' : ''}`}>

View File

@@ -21,7 +21,7 @@ interface AmpcodeModalProps {
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) { export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue); const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache); const clearCache = useConfigStore((state) => state.clearCache);
@@ -81,32 +81,34 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]); }, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => { const clearAmpcodeUpstreamApiKey = async () => {
if (!window.confirm(t('ai_providers.ampcode_clear_upstream_api_key_confirm'))) return; showConfirmation({
setSaving(true); title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
setError(''); message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
try { variant: 'danger',
await ampcodeApi.clearUpstreamApiKey(); confirmText: t('common.confirm'),
const previous = config?.ampcode ?? {}; onConfirm: async () => {
const next: AmpcodeConfig = { ...previous }; setSaving(true);
delete next.upstreamApiKey; setError('');
updateConfigValue('ampcode', next); try {
clearCache('ampcode'); await ampcodeApi.clearUpstreamApiKey();
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success'); const previous = config?.ampcode ?? {};
} catch (err: unknown) { const next: AmpcodeConfig = { ...previous };
const message = getErrorMessage(err); delete next.upstreamApiKey;
setError(message); updateConfigValue('ampcode', next);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error'); clearCache('ampcode');
} finally { showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
setSaving(false); } catch (err: unknown) {
} const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
},
});
}; };
const saveAmpcode = async () => { const performSaveAmpcode = async () => {
if (!loaded && mappingsDirty) {
const confirmed = window.confirm(t('ai_providers.ampcode_mappings_overwrite_confirm'));
if (!confirmed) return;
}
setSaving(true); setSaving(true);
setError(''); setError('');
try { try {
@@ -173,6 +175,21 @@ export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }:
} }
}; };
const saveAmpcode = async () => {
if (!loaded && mappingsDirty) {
showConfirmation({
title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }),
message: t('ai_providers.ampcode_mappings_overwrite_confirm'),
variant: 'secondary', // Not dangerous, just a warning
confirmText: t('common.confirm'),
onConfirm: performSaveAmpcode,
});
return;
}
await performSaveAmpcode();
};
return ( return (
<Modal <Modal
open={isOpen} open={isOpen}

View File

@@ -46,7 +46,7 @@ export const normalizeOpenAIBaseUrl = (baseUrl: string): string => {
export const buildOpenAIModelsEndpoint = (baseUrl: string): string => { export const buildOpenAIModelsEndpoint = (baseUrl: string): string => {
const trimmed = normalizeOpenAIBaseUrl(baseUrl); const trimmed = normalizeOpenAIBaseUrl(baseUrl);
if (!trimmed) return ''; if (!trimmed) return '';
return trimmed.endsWith('/v1') ? `${trimmed}/models` : `${trimmed}/v1/models`; return `${trimmed}/models`;
}; };
export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => { export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
@@ -55,7 +55,10 @@ export const buildOpenAIChatCompletionsEndpoint = (baseUrl: string): string => {
if (trimmed.endsWith('/chat/completions')) { if (trimmed.endsWith('/chat/completions')) {
return trimmed; return trimmed;
} }
return trimmed.endsWith('/v1') ? `${trimmed}/chat/completions` : `${trimmed}/v1/chat/completions`; if (trimmed.endsWith('/v1')) {
return `${trimmed.slice(0, -3)}/chat/completions`;
}
return `${trimmed}/chat/completions`;
}; };
// 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致 // 根据 source (apiKey) 获取统计数据 - 与旧版逻辑一致

View File

@@ -0,0 +1,175 @@
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode } from 'react';
import { IconChevronDown } from './icons';
interface AutocompleteInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
options: string[] | { value: string; label?: string }[];
placeholder?: string;
disabled?: boolean;
hint?: string;
error?: string;
className?: string;
wrapperClassName?: string;
wrapperStyle?: React.CSSProperties;
id?: string;
rightElement?: ReactNode;
}
export function AutocompleteInput({
label,
value,
onChange,
options,
placeholder,
disabled,
hint,
error,
className = '',
wrapperClassName = '',
wrapperStyle,
id,
rightElement
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const normalizedOptions = options.map(opt =>
typeof opt === 'string' ? { value: opt, label: opt } : { value: opt.value, label: opt.label || opt.value }
);
const filteredOptions = normalizedOptions.filter(opt => {
const v = value.toLowerCase();
return opt.value.toLowerCase().includes(v) || (opt.label && opt.label.toLowerCase().includes(v));
});
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
};
const handleSelect = (selectedValue: string) => {
onChange(selectedValue);
setIsOpen(false);
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
return;
}
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : prev
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : 0);
} else if (e.key === 'Enter') {
if (isOpen && highlightedIndex >= 0 && highlightedIndex < filteredOptions.length) {
e.preventDefault();
handleSelect(filteredOptions[highlightedIndex].value);
} else if (isOpen) {
e.preventDefault();
setIsOpen(false);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
} else if (e.key === 'Tab') {
setIsOpen(false);
}
};
return (
<div className={`form-group ${wrapperClassName}`} ref={containerRef} style={wrapperStyle}>
{label && <label htmlFor={id}>{label}</label>}
<div style={{ position: 'relative' }}>
<input
id={id}
className={`input ${className}`.trim()}
value={value}
onChange={handleInputChange}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
autoComplete="off"
style={{ paddingRight: 32 }}
/>
<div
style={{
position: 'absolute',
right: 8,
top: '50%',
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
pointerEvents: disabled ? 'none' : 'auto',
cursor: 'pointer',
height: '100%'
}}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
{rightElement}
<IconChevronDown size={16} style={{ opacity: 0.5, marginLeft: 4 }} />
</div>
{isOpen && filteredOptions.length > 0 && !disabled && (
<div className="autocomplete-dropdown" style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
right: 0,
zIndex: 1000,
backgroundColor: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 'var(--radius-md)',
maxHeight: 200,
overflowY: 'auto',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
}}>
{filteredOptions.map((opt, index) => (
<div
key={`${opt.value}-${index}`}
onClick={() => handleSelect(opt.value)}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: index === highlightedIndex ? 'var(--bg-tertiary)' : 'transparent',
color: 'var(--text-primary)',
display: 'flex',
flexDirection: 'column',
fontSize: '0.9rem'
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<span style={{ fontWeight: 500 }}>{opt.value}</span>
{opt.label && opt.label !== opt.value && (
<span style={{ fontSize: '0.85em', color: 'var(--text-secondary)' }}>{opt.label}</span>
)}
</div>
))}
</div>
)}
</div>
{hint && <div className="hint">{hint}</div>}
{error && <div className="error-box">{error}</div>}
</div>
);
}

View File

@@ -4,6 +4,7 @@ interface ToggleSwitchProps {
checked: boolean; checked: boolean;
onChange: (value: boolean) => void; onChange: (value: boolean) => void;
label?: ReactNode; label?: ReactNode;
ariaLabel?: string;
disabled?: boolean; disabled?: boolean;
labelPosition?: 'left' | 'right'; labelPosition?: 'left' | 'right';
} }
@@ -12,6 +13,7 @@ export function ToggleSwitch({
checked, checked,
onChange, onChange,
label, label,
ariaLabel,
disabled = false, disabled = false,
labelPosition = 'right' labelPosition = 'right'
}: ToggleSwitchProps) { }: ToggleSwitchProps) {
@@ -25,7 +27,13 @@ export function ToggleSwitch({
return ( return (
<label className={className}> <label className={className}>
<input type="checkbox" checked={checked} onChange={handleChange} disabled={disabled} /> <input
type="checkbox"
checked={checked}
onChange={handleChange}
disabled={disabled}
aria-label={ariaLabel}
/>
<span className="track"> <span className="track">
<span className="thumb" /> <span className="thumb" />
</span> </span>

View File

@@ -6,6 +6,8 @@ import styles from '@/pages/UsagePage.module.scss';
export interface ModelStat { export interface ModelStat {
model: string; model: string;
requests: number; requests: number;
successCount: number;
failureCount: number;
tokens: number; tokens: number;
cost: number; cost: number;
} }
@@ -38,7 +40,15 @@ export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCar
{modelStats.map((stat) => ( {modelStats.map((stat) => (
<tr key={stat.model}> <tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td> <td className={styles.modelCell}>{stat.model}</td>
<td>{stat.requests.toLocaleString()}</td> <td>
<span className={styles.requestCountCell}>
<span>{stat.requests.toLocaleString()}</span>
<span className={styles.requestBreakdown}>
(<span className={styles.statSuccess}>{stat.successCount.toLocaleString()}</span>{' '}
<span className={styles.statFailure}>{stat.failureCount.toLocaleString()}</span>)
</span>
</span>
</td>
<td>{formatTokensInMillions(stat.tokens)}</td> <td>{formatTokensInMillions(stat.tokens)}</td>
{hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>} {hasPrices && <td>{stat.cost > 0 ? formatUsd(stat.cost) : '--'}</td>}
</tr> </tr>

View File

@@ -249,10 +249,10 @@
"vertex_edit_modal_url_label": "Base URL (Required):", "vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):", "vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?", "vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model mappings (alias required):", "vertex_models_label": "Model aliases (alias required):",
"vertex_models_add_btn": "Add Mapping", "vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.", "vertex_models_hint": "Each alias needs both the original model and the alias.",
"vertex_models_count": "Mapping count", "vertex_models_count": "Alias count",
"ampcode_title": "Amp CLI Integration (ampcode)", "ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode", "ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -293,12 +293,12 @@
"openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free", "openai_model_name_placeholder": "Model name, e.g. moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "Model alias (optional)", "openai_model_alias_placeholder": "Model alias (optional)",
"openai_models_add_btn": "Add Model", "openai_models_add_btn": "Add Model",
"openai_models_fetch_button": "Fetch via /v1/models", "openai_models_fetch_button": "Fetch via /models",
"openai_models_fetch_title": "Pick Models from /v1/models", "openai_models_fetch_title": "Pick Models from /models",
"openai_models_fetch_hint": "Call the /v1/models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.", "openai_models_fetch_hint": "Call the /models endpoint using the Base URL above, sending the first API key as Bearer plus custom headers.",
"openai_models_fetch_url_label": "Request URL", "openai_models_fetch_url_label": "Request URL",
"openai_models_fetch_refresh": "Refresh", "openai_models_fetch_refresh": "Refresh",
"openai_models_fetch_loading": "Fetching models from /v1/models...", "openai_models_fetch_loading": "Fetching models from /models...",
"openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.", "openai_models_fetch_empty": "No models returned. Please check the endpoint or auth.",
"openai_models_fetch_error": "Failed to fetch models", "openai_models_fetch_error": "Failed to fetch models",
"openai_models_fetch_back": "Back to edit", "openai_models_fetch_back": "Back to edit",
@@ -316,7 +316,7 @@
"openai_keys_count": "Keys Count", "openai_keys_count": "Keys Count",
"openai_models_count": "Models Count", "openai_models_count": "Models Count",
"openai_test_title": "Connection Test", "openai_test_title": "Connection Test",
"openai_test_hint": "Send a /v1/chat/completions request with the current settings to verify availability.", "openai_test_hint": "Send a /chat/completions request with the current settings to verify availability.",
"openai_test_model_placeholder": "Model to test", "openai_test_model_placeholder": "Model to test",
"openai_test_action": "Run Test", "openai_test_action": "Run Test",
"openai_test_running": "Sending test request...", "openai_test_running": "Sending test request...",
@@ -395,7 +395,19 @@
"models_unsupported": "This feature is not supported in the current version", "models_unsupported": "This feature is not supported in the current version",
"models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again", "models_unsupported_desc": "Please update CLI Proxy API to the latest version and try again",
"models_excluded_badge": "Excluded", "models_excluded_badge": "Excluded",
"models_excluded_hint": "This model is excluded by OAuth" "models_excluded_hint": "This model is excluded by OAuth",
"status_toggle_label": "Enabled",
"status_enabled_success": "\"{{name}}\" enabled",
"status_disabled_success": "\"{{name}}\" disabled",
"prefix_proxy_button": "Edit prefix/proxy_url",
"prefix_proxy_loading": "Loading credential...",
"prefix_proxy_source_label": "Credential JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "This credential is not a JSON object and cannot be edited.",
"prefix_proxy_saved_success": "Updated \"{{name}}\" successfully"
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity Quota", "title": "Antigravity Quota",
@@ -476,8 +488,10 @@
"provider_placeholder": "e.g. gemini-cli", "provider_placeholder": "e.g. gemini-cli",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"models_label": "Models to exclude", "models_label": "Models to exclude",
"models_placeholder": "gpt-4.1-mini\n*-preview", "models_loading": "Loading models...",
"models_hint": "Separate by commas or new lines; saving an empty list removes that provider. * wildcards are supported.", "models_unsupported": "Current CPA version does not support fetching model lists.",
"models_loaded": "{{count}} models loaded. Check the models to exclude.",
"no_models_available": "No models available for this provider.",
"save": "Save/Update", "save": "Save/Update",
"saving": "Saving...", "saving": "Saving...",
"save_success": "Excluded models updated", "save_success": "Excluded models updated",
@@ -500,33 +514,35 @@
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"oauth_model_mappings": { "oauth_model_alias": {
"title": "OAuth Model Mappings", "title": "OAuth Model Aliases",
"add": "Add Mapping", "add": "Add Alias",
"add_title": "Add provider model mappings", "add_title": "Add provider model aliases",
"provider_label": "Provider", "provider_label": "Provider",
"provider_placeholder": "e.g. gemini-cli / vertex", "provider_placeholder": "e.g. gemini-cli / vertex",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.", "provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"mappings_label": "Model mappings", "model_source_loading": "Loading models...",
"mapping_name_placeholder": "Source model name", "model_source_unsupported": "The current CPA version does not support fetching model lists (manual input still works).",
"mapping_alias_placeholder": "Alias (required)", "model_source_loaded": "{{count}} models loaded. Use the dropdown in 'Source model name', or type custom values. Saving an empty list removes that provider. Enable 'Keep original' to keep the original name while adding the alias.",
"mapping_fork_label": "Keep original", "alias_label": "Model aliases",
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.", "alias_name_placeholder": "Source model name",
"add_mapping": "Add mapping", "alias_placeholder": "Alias (required)",
"alias_fork_label": "Keep original",
"add_alias": "Add alias",
"save": "Save/Update", "save": "Save/Update",
"save_success": "Model mappings updated", "save_success": "Model aliases updated",
"save_failed": "Failed to update model mappings", "save_failed": "Failed to update model aliases",
"delete": "Delete Provider", "delete": "Delete Provider",
"delete_confirm": "Delete model mappings for {{provider}}?", "delete_confirm": "Delete model aliases for {{provider}}?",
"delete_success": "Model mappings removed", "delete_success": "Model aliases removed",
"delete_failed": "Failed to delete model mappings", "delete_failed": "Failed to delete model aliases",
"no_models": "No model mappings", "no_models": "No model aliases",
"model_count": "{{count}} mappings", "model_count": "{{count}} aliases",
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.", "list_empty_all": "No model aliases yet—use “Add Alias” to create one.",
"provider_required": "Please enter a provider first", "provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.", "upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server does not support the OAuth model aliases API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
@@ -787,9 +803,9 @@
"not_loaded": "Not Loaded", "not_loaded": "Not Loaded",
"seconds_ago": "seconds ago", "seconds_ago": "seconds ago",
"models_title": "Available Models", "models_title": "Available Models",
"models_desc": "Shows the /v1/models response and uses saved API keys for auth automatically.", "models_desc": "Shows the /models response and uses saved API keys for auth automatically.",
"models_loading": "Loading available models...", "models_loading": "Loading available models...",
"models_empty": "No models returned by /v1/models", "models_empty": "No models returned by /models",
"models_error": "Failed to load model list", "models_error": "Failed to load model list",
"models_count": "{{count}} available models", "models_count": "{{count}} available models",
"version_check_title": "Update Check", "version_check_title": "Update Check",

View File

@@ -249,10 +249,10 @@
"vertex_edit_modal_url_label": "Base URL (必填):", "vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):", "vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗", "vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):", "vertex_models_label": "模型别名 (别名必填):",
"vertex_models_add_btn": "添加映射", "vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。", "vertex_models_hint": "每条别名需要填写原模型与别名。",
"vertex_models_count": "映射数量", "vertex_models_count": "别名数量",
"ampcode_title": "Amp CLI 集成 (ampcode)", "ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode", "ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -293,12 +293,12 @@
"openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free", "openai_model_name_placeholder": "模型名称,如 moonshotai/kimi-k2:free",
"openai_model_alias_placeholder": "模型别名 (可选)", "openai_model_alias_placeholder": "模型别名 (可选)",
"openai_models_add_btn": "添加模型", "openai_models_add_btn": "添加模型",
"openai_models_fetch_button": "从 /v1/models 获取", "openai_models_fetch_button": "从 /models 获取",
"openai_models_fetch_title": "从 /v1/models 选择模型", "openai_models_fetch_title": "从 /models 选择模型",
"openai_models_fetch_hint": "使用上方 Base URL 调用 /v1/models 端点,附带首个 API KeyBearer与自定义请求头。", "openai_models_fetch_hint": "使用上方 Base URL 调用 /models 端点,附带首个 API KeyBearer与自定义请求头。",
"openai_models_fetch_url_label": "请求地址", "openai_models_fetch_url_label": "请求地址",
"openai_models_fetch_refresh": "重新获取", "openai_models_fetch_refresh": "重新获取",
"openai_models_fetch_loading": "正在从 /v1/models 获取模型列表...", "openai_models_fetch_loading": "正在从 /models 获取模型列表...",
"openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。", "openai_models_fetch_empty": "未获取到模型,请检查端点或鉴权信息。",
"openai_models_fetch_error": "获取模型失败", "openai_models_fetch_error": "获取模型失败",
"openai_models_fetch_back": "返回编辑", "openai_models_fetch_back": "返回编辑",
@@ -316,7 +316,7 @@
"openai_keys_count": "密钥数量", "openai_keys_count": "密钥数量",
"openai_models_count": "模型数量", "openai_models_count": "模型数量",
"openai_test_title": "连通性测试", "openai_test_title": "连通性测试",
"openai_test_hint": "使用当前配置向 /v1/chat/completions 请求,验证是否可用。", "openai_test_hint": "使用当前配置向 /chat/completions 请求,验证是否可用。",
"openai_test_model_placeholder": "选择或输入要测试的模型", "openai_test_model_placeholder": "选择或输入要测试的模型",
"openai_test_action": "发送测试", "openai_test_action": "发送测试",
"openai_test_running": "正在发送测试请求...", "openai_test_running": "正在发送测试请求...",
@@ -395,7 +395,19 @@
"models_unsupported": "当前版本不支持此功能", "models_unsupported": "当前版本不支持此功能",
"models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试", "models_unsupported_desc": "请更新 CLI Proxy API 到最新版本后重试",
"models_excluded_badge": "已排除", "models_excluded_badge": "已排除",
"models_excluded_hint": "此模型已被 OAuth 排除" "models_excluded_hint": "此模型已被 OAuth 排除",
"status_toggle_label": "启用",
"status_enabled_success": "已启用 \"{{name}}\"",
"status_disabled_success": "已停用 \"{{name}}\"",
"prefix_proxy_button": "配置 prefix/proxy_url",
"prefix_proxy_loading": "正在加载凭证文件...",
"prefix_proxy_source_label": "凭证 JSON",
"prefix_label": "prefix",
"proxy_url_label": "proxy_url",
"prefix_placeholder": "",
"proxy_url_placeholder": "socks5://username:password@proxy_ip:port/",
"prefix_proxy_invalid_json": "该凭证文件不是 JSON 对象,无法编辑。",
"prefix_proxy_saved_success": "已更新 \"{{name}}\""
}, },
"antigravity_quota": { "antigravity_quota": {
"title": "Antigravity 额度", "title": "Antigravity 额度",
@@ -476,8 +488,10 @@
"provider_placeholder": "例如 gemini-cli / openai", "provider_placeholder": "例如 gemini-cli / openai",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。", "provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"models_label": "排除的模型", "models_label": "排除的模型",
"models_placeholder": "gpt-4.1-mini\n*-preview", "models_loading": "正在加载模型列表...",
"models_hint": "逗号或换行分隔;留空保存将删除该提供商记录;支持 * 通配符。", "models_unsupported": "当前 CPA 版本不支持获取模型列表。",
"models_loaded": "已加载 {{count}} 个模型,勾选要排除的模型。",
"no_models_available": "该提供商暂无可用模型列表。",
"save": "保存/更新", "save": "保存/更新",
"saving": "正在保存...", "saving": "正在保存...",
"save_success": "排除列表已更新", "save_success": "排除列表已更新",
@@ -500,33 +514,35 @@
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"oauth_model_mappings": { "oauth_model_alias": {
"title": "OAuth 模型映射", "title": "OAuth 模型别名",
"add": "新增映射", "add": "新增别名",
"add_title": "新增提供商模型映射", "add_title": "新增提供商模型别名",
"provider_label": "提供商", "provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex", "provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。", "provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"mappings_label": "模型映射", "model_source_loading": "正在加载模型列表...",
"mapping_name_placeholder": "原模型名称", "model_source_unsupported": "当前 CPA 版本不支持获取模型列表(仍可手动输入)。",
"mapping_alias_placeholder": "别名 (必填)", "model_source_loaded": "已加载 {{count}} 个模型,可在“原模型名称”中下拉选择;也可手动输入。留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"mapping_fork_label": "保留原名", "alias_label": "模型别名",
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。", "alias_name_placeholder": "原模型名称",
"add_mapping": "添加映射", "alias_placeholder": "别名 (必填)",
"alias_fork_label": "保留原名",
"add_alias": "添加别名",
"save": "保存/更新", "save": "保存/更新",
"save_success": "模型映射已更新", "save_success": "模型别名已更新",
"save_failed": "更新模型映射失败", "save_failed": "更新模型别名失败",
"delete": "删除提供商", "delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?", "delete_confirm": "确定要删除 {{provider}} 的模型别名吗?",
"delete_success": "已删除该提供商的模型映射", "delete_success": "已删除该提供商的模型别名",
"delete_failed": "删除模型映射失败", "delete_failed": "删除模型别名失败",
"no_models": "未配置模型映射", "no_models": "未配置模型别名",
"model_count": "映射 {{count}} 条模型", "model_count": "{{count}} 条别名",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。", "list_empty_all": "暂无任何提供商的模型别名,点击“新增别名”创建。",
"provider_required": "请先填写提供商名称", "provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本", "upgrade_required": "当前 CPA 版本不支持模型别名功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持 OAuth 模型别名功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
@@ -787,9 +803,9 @@
"not_loaded": "未加载", "not_loaded": "未加载",
"seconds_ago": "秒前", "seconds_ago": "秒前",
"models_title": "可用模型列表", "models_title": "可用模型列表",
"models_desc": "展示 /v1/models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。", "models_desc": "展示 /models 返回的模型,并自动使用服务器保存的 API Key 进行鉴权。",
"models_loading": "正在加载可用模型...", "models_loading": "正在加载可用模型...",
"models_empty": "未从 /v1/models 获取到模型数据", "models_empty": "未从 /models 获取到模型数据",
"models_error": "获取模型列表失败", "models_error": "获取模型列表失败",
"models_count": "可用模型 {{count}} 个", "models_count": "可用模型 {{count}} 个",
"version_check_title": "版本检查", "version_check_title": "版本检查",

View File

@@ -28,7 +28,7 @@ import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() { export function AiProvidersPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
@@ -180,18 +180,25 @@ export function AiProvidersPage() {
const deleteGemini = async (index: number) => { const deleteGemini = async (index: number) => {
const entry = geminiKeys[index]; const entry = geminiKeys[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.gemini_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.gemini_delete_title', { defaultValue: 'Delete Gemini Key' }),
await providersApi.deleteGeminiKey(entry.apiKey); message: t('ai_providers.gemini_delete_confirm'),
const next = geminiKeys.filter((_, idx) => idx !== index); variant: 'danger',
setGeminiKeys(next); confirmText: t('common.confirm'),
updateConfigValue('gemini-api-key', next); onConfirm: async () => {
clearCache('gemini-api-key'); try {
showNotification(t('notification.gemini_key_deleted'), 'success'); await providersApi.deleteGeminiKey(entry.apiKey);
} catch (err: unknown) { const next = geminiKeys.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setGeminiKeys(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('gemini-api-key', next);
} clearCache('gemini-api-key');
showNotification(t('notification.gemini_key_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const setConfigEnabled = async ( const setConfigEnabled = async (
@@ -352,27 +359,34 @@ export function AiProvidersPage() {
const source = type === 'codex' ? codexConfigs : claudeConfigs; const source = type === 'codex' ? codexConfigs : claudeConfigs;
const entry = source[index]; const entry = source[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t(`ai_providers.${type}_delete_confirm`))) return; showConfirmation({
try { title: t(`ai_providers.${type}_delete_title`, { defaultValue: `Delete ${type === 'codex' ? 'Codex' : 'Claude'} Config` }),
if (type === 'codex') { message: t(`ai_providers.${type}_delete_confirm`),
await providersApi.deleteCodexConfig(entry.apiKey); variant: 'danger',
const next = codexConfigs.filter((_, idx) => idx !== index); confirmText: t('common.confirm'),
setCodexConfigs(next); onConfirm: async () => {
updateConfigValue('codex-api-key', next); try {
clearCache('codex-api-key'); if (type === 'codex') {
showNotification(t('notification.codex_config_deleted'), 'success'); await providersApi.deleteCodexConfig(entry.apiKey);
} else { const next = codexConfigs.filter((_, idx) => idx !== index);
await providersApi.deleteClaudeConfig(entry.apiKey); setCodexConfigs(next);
const next = claudeConfigs.filter((_, idx) => idx !== index); updateConfigValue('codex-api-key', next);
setClaudeConfigs(next); clearCache('codex-api-key');
updateConfigValue('claude-api-key', next); showNotification(t('notification.codex_config_deleted'), 'success');
clearCache('claude-api-key'); } else {
showNotification(t('notification.claude_config_deleted'), 'success'); await providersApi.deleteClaudeConfig(entry.apiKey);
} const next = claudeConfigs.filter((_, idx) => idx !== index);
} catch (err: unknown) { setClaudeConfigs(next);
const message = getErrorMessage(err); updateConfigValue('claude-api-key', next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); clearCache('claude-api-key');
} showNotification(t('notification.claude_config_deleted'), 'success');
}
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const saveVertex = async (form: VertexFormState, editIndex: number | null) => { const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
@@ -427,18 +441,25 @@ export function AiProvidersPage() {
const deleteVertex = async (index: number) => { const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index]; const entry = vertexConfigs[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.vertex_delete_title', { defaultValue: 'Delete Vertex Config' }),
await providersApi.deleteVertexConfig(entry.apiKey); message: t('ai_providers.vertex_delete_confirm'),
const next = vertexConfigs.filter((_, idx) => idx !== index); variant: 'danger',
setVertexConfigs(next); confirmText: t('common.confirm'),
updateConfigValue('vertex-api-key', next); onConfirm: async () => {
clearCache('vertex-api-key'); try {
showNotification(t('notification.vertex_config_deleted'), 'success'); await providersApi.deleteVertexConfig(entry.apiKey);
} catch (err: unknown) { const next = vertexConfigs.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setVertexConfigs(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('vertex-api-key', next);
} clearCache('vertex-api-key');
showNotification(t('notification.vertex_config_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => { const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
@@ -485,18 +506,25 @@ export function AiProvidersPage() {
const deleteOpenai = async (index: number) => { const deleteOpenai = async (index: number) => {
const entry = openaiProviders[index]; const entry = openaiProviders[index];
if (!entry) return; if (!entry) return;
if (!window.confirm(t('ai_providers.openai_delete_confirm'))) return; showConfirmation({
try { title: t('ai_providers.openai_delete_title', { defaultValue: 'Delete OpenAI Provider' }),
await providersApi.deleteOpenAIProvider(entry.name); message: t('ai_providers.openai_delete_confirm'),
const next = openaiProviders.filter((_, idx) => idx !== index); variant: 'danger',
setOpenaiProviders(next); confirmText: t('common.confirm'),
updateConfigValue('openai-compatibility', next); onConfirm: async () => {
clearCache('openai-compatibility'); try {
showNotification(t('notification.openai_provider_deleted'), 'success'); await providersApi.deleteOpenAIProvider(entry.name);
} catch (err: unknown) { const next = openaiProviders.filter((_, idx) => idx !== index);
const message = getErrorMessage(err); setOpenaiProviders(next);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error'); updateConfigValue('openai-compatibility', next);
} clearCache('openai-compatibility');
showNotification(t('notification.openai_provider_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
},
});
}; };
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null; const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;

View File

@@ -277,27 +277,15 @@
} }
.antigravityCard { .antigravityCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
180deg,
rgba(224, 247, 250, 0.12),
rgba(224, 247, 250, 0)
);
} }
.codexCard { .codexCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
180deg,
rgba(255, 243, 224, 0.18),
rgba(255, 243, 224, 0)
);
} }
.geminiCliCard { .geminiCliCard {
background-image: linear-gradient( background-image: linear-gradient(180deg, rgba(231, 239, 255, 0.2), rgba(231, 239, 255, 0));
180deg,
rgba(231, 239, 255, 0.2),
rgba(231, 239, 255, 0)
);
} }
.quotaSection { .quotaSection {
@@ -446,7 +434,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast; transition:
transform $transition-fast,
box-shadow $transition-fast,
border-color $transition-fast;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -455,6 +446,16 @@
} }
} }
.fileCardDisabled {
opacity: 0.6;
&:hover {
transform: none;
box-shadow: none;
border-color: var(--border-color);
}
}
.cardHeader { .cardHeader {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -546,7 +547,9 @@
height: 8px; height: 8px;
border-radius: 2px; border-radius: 2px;
min-width: 6px; min-width: 6px;
transition: transform 0.15s ease, opacity 0.15s ease; transition:
transform 0.15s ease,
opacity 0.15s ease;
&:hover { &:hover {
transform: scaleY(1.5); transform: scaleY(1.5);
@@ -597,14 +600,90 @@
background: var(--failure-badge-bg, #fee2e2); background: var(--failure-badge-bg, #fee2e2);
} }
.prefixProxyEditor {
display: flex;
flex-direction: column;
gap: $spacing-md;
max-height: 60vh;
overflow: auto;
}
.prefixProxyLoading {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
padding: $spacing-sm 0;
}
.prefixProxyError {
padding: $spacing-sm $spacing-md;
border-radius: $radius-md;
border: 1px solid var(--danger-color);
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
font-size: 12px;
}
.prefixProxyJsonWrapper {
display: flex;
flex-direction: column;
gap: 6px;
}
.prefixProxyLabel {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
}
.prefixProxyTextarea {
width: 100%;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: monospace;
resize: vertical;
min-height: 120px;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--primary-color);
}
}
.prefixProxyFields {
display: grid;
grid-template-columns: 1fr;
gap: $spacing-sm;
:global(.form-group) {
margin: 0;
}
}
.cardActions { .cardActions {
display: flex; display: flex;
gap: $spacing-xs; gap: $spacing-xs;
justify-content: flex-end; justify-content: flex-end;
align-items: center;
margin-top: auto; margin-top: auto;
padding-top: $spacing-sm; padding-top: $spacing-sm;
} }
.statusToggle {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.iconButton:global(.btn.btn-sm) { .iconButton:global(.btn.btn-sm) {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -916,3 +995,53 @@
border: 1px solid var(--danger-color); border: 1px solid var(--danger-color);
flex-shrink: 0; flex-shrink: 0;
} }
// 排除模型勾选列表
.excludedCheckList {
display: flex;
flex-direction: column;
gap: $spacing-xs;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: $radius-md;
padding: $spacing-sm;
background-color: var(--bg-secondary);
}
.excludedCheckItem {
display: flex;
align-items: center;
gap: $spacing-sm;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-sm;
cursor: pointer;
transition: background-color $transition-fast;
&:hover {
background-color: var(--bg-hover);
}
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary-color);
}
}
.excludedCheckLabel {
display: flex;
align-items: center;
gap: $spacing-sm;
font-size: 13px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--text-primary);
word-break: break-all;
}
.excludedCheckDisplayName {
font-size: 12px;
color: var(--text-tertiary);
font-family: inherit;
}

File diff suppressed because it is too large Load Diff

View File

@@ -371,7 +371,7 @@ type TabType = 'logs' | 'errors';
export function LogsPage() { export function LogsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false); const requestLogEnabled = useConfigStore((state) => state.config?.requestLog ?? false);
@@ -478,19 +478,26 @@ export function LogsPage() {
useHeaderRefresh(() => loadLogs(false)); useHeaderRefresh(() => loadLogs(false));
const clearLogs = async () => { const clearLogs = async () => {
if (!window.confirm(t('logs.clear_confirm'))) return; showConfirmation({
try { title: t('logs.clear_confirm_title', { defaultValue: 'Clear Logs' }),
await logsApi.clearLogs(); message: t('logs.clear_confirm'),
setLogState({ buffer: [], visibleFrom: 0 }); variant: 'danger',
latestTimestampRef.current = 0; confirmText: t('common.confirm'),
showNotification(t('logs.clear_success'), 'success'); onConfirm: async () => {
} catch (err: unknown) { try {
const message = getErrorMessage(err); await logsApi.clearLogs();
showNotification( setLogState({ buffer: [], visibleFrom: 0 });
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`, latestTimestampRef.current = 0;
'error' showNotification(t('logs.clear_success'), 'success');
); } catch (err: unknown) {
} const message = getErrorMessage(err);
showNotification(
`${t('notification.delete_failed')}${message ? `: ${message}` : ''}`,
'error'
);
}
},
});
}; };
const downloadLogs = () => { const downloadLogs = () => {

View File

@@ -11,7 +11,7 @@ import styles from './SystemPage.module.scss';
export function SystemPage() { export function SystemPage() {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification, showConfirmation } = useNotificationStore();
const auth = useAuthStore(); const auth = useAuthStore();
const config = useConfigStore((state) => state.config); const config = useConfigStore((state) => state.config);
const fetchConfig = useConfigStore((state) => state.fetchConfig); const fetchConfig = useConfigStore((state) => state.fetchConfig);
@@ -106,12 +106,19 @@ export function SystemPage() {
}; };
const handleClearLoginStorage = () => { const handleClearLoginStorage = () => {
if (!window.confirm(t('system_info.clear_login_confirm'))) return; showConfirmation({
auth.logout(); title: t('system_info.clear_login_title', { defaultValue: 'Clear Login Storage' }),
if (typeof localStorage === 'undefined') return; message: t('system_info.clear_login_confirm'),
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey']; variant: 'danger',
keysToRemove.forEach((key) => localStorage.removeItem(key)); confirmText: t('common.confirm'),
showNotification(t('notification.login_storage_cleared'), 'success'); onConfirm: () => {
auth.logout();
if (typeof localStorage === 'undefined') return;
const keysToRemove = [STORAGE_KEY_AUTH, 'isLoggedIn', 'apiBase', 'apiUrl', 'managementKey'];
keysToRemove.forEach((key) => localStorage.removeItem(key));
showNotification(t('notification.login_storage_cleared'), 'success');
},
});
}; };
useEffect(() => { useEffect(() => {

View File

@@ -456,6 +456,18 @@
word-break: break-all; word-break: break-all;
} }
.requestCountCell {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-variant-numeric: tabular-nums;
}
.requestBreakdown {
color: var(--text-secondary);
white-space: nowrap;
}
// Pricing Section (80%比例) // Pricing Section (80%比例)
.pricingSection { .pricingSection {
display: flex; display: flex;

View File

@@ -4,9 +4,10 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile'; import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types'; import type { OAuthModelAliasEntry } from '@/types';
type StatusError = { status?: number }; type StatusError = { status?: number };
type AuthFileStatusResponse = { status: string; disabled: boolean };
const getStatusCode = (err: unknown): number | undefined => { const getStatusCode = (err: unknown): number | undefined => {
if (!err || typeof err !== 'object') return undefined; if (!err || typeof err !== 'object') return undefined;
@@ -17,7 +18,8 @@ const getStatusCode = (err: unknown): number | undefined => {
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => { const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
if (!payload || typeof payload !== 'object') return {}; if (!payload || typeof payload !== 'object') return {};
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload; const record = payload as Record<string, unknown>;
const source = record['oauth-excluded-models'] ?? record.items ?? payload;
if (!source || typeof source !== 'object') return {}; if (!source || typeof source !== 'object') return {};
const result: Record<string, string[]> = {}; const result: Record<string, string[]> = {};
@@ -51,17 +53,17 @@ const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]
return result; return result;
}; };
const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthModelMappingEntry[]> => { const normalizeOauthModelAlias = (payload: unknown): Record<string, OAuthModelAliasEntry[]> => {
if (!payload || typeof payload !== 'object') return {}; if (!payload || typeof payload !== 'object') return {};
const record = payload as Record<string, unknown>;
const source = const source =
(payload as any)['oauth-model-mappings'] ?? record['oauth-model-alias'] ??
(payload as any)['oauth-model-alias'] ?? record.items ??
(payload as any).items ??
payload; payload;
if (!source || typeof source !== 'object') return {}; if (!source || typeof source !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {}; const result: Record<string, OAuthModelAliasEntry[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => { Object.entries(source as Record<string, unknown>).forEach(([channel, mappings]) => {
const key = String(channel ?? '') const key = String(channel ?? '')
@@ -70,24 +72,25 @@ const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthMode
if (!key) return; if (!key) return;
if (!Array.isArray(mappings)) return; if (!Array.isArray(mappings)) return;
const seen = new Set<string>(); const seen = new Set<string>();
const normalized = mappings const normalized = mappings
.map((item) => { .map((item) => {
if (!item || typeof item !== 'object') return null; if (!item || typeof item !== 'object') return null;
const name = String((item as any).name ?? (item as any).id ?? (item as any).model ?? '').trim(); const entry = item as Record<string, unknown>;
const alias = String((item as any).alias ?? '').trim(); const name = String(entry.name ?? entry.id ?? entry.model ?? '').trim();
if (!name || !alias) return null; const alias = String(entry.alias ?? '').trim();
const fork = (item as any).fork === true; if (!name || !alias) return null;
return fork ? { name, alias, fork } : { name, alias }; const fork = entry.fork === true;
}) return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean) .filter(Boolean)
.filter((entry) => { .filter((entry) => {
const mapping = entry as OAuthModelMappingEntry; const aliasEntry = entry as OAuthModelAliasEntry;
const dedupeKey = `${mapping.name.toLowerCase()}::${mapping.alias.toLowerCase()}::${mapping.fork ? '1' : '0'}`; const dedupeKey = `${aliasEntry.name.toLowerCase()}::${aliasEntry.alias.toLowerCase()}::${aliasEntry.fork ? '1' : '0'}`;
if (seen.has(dedupeKey)) return false; if (seen.has(dedupeKey)) return false;
seen.add(dedupeKey); seen.add(dedupeKey);
return true; return true;
}) as OAuthModelMappingEntry[]; }) as OAuthModelAliasEntry[];
if (normalized.length) { if (normalized.length) {
result[key] = normalized; result[key] = normalized;
@@ -97,12 +100,14 @@ const normalizeOauthModelMappings = (payload: unknown): Record<string, OAuthMode
return result; return result;
}; };
const OAUTH_MODEL_MAPPINGS_ENDPOINT = '/oauth-model-mappings'; const OAUTH_MODEL_ALIAS_ENDPOINT = '/oauth-model-alias';
const OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT = '/oauth-model-alias';
export const authFilesApi = { export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'), list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
setStatus: (name: string, disabled: boolean) =>
apiClient.patch<AuthFileStatusResponse>('/auth-files/status', { name, disabled }),
upload: (file: File) => { upload: (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file, file.name); formData.append('file', file, file.name);
@@ -136,63 +141,31 @@ export const authFilesApi = {
replaceOauthExcludedModels: (map: Record<string, string[]>) => replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)), apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
// OAuth 模型映射 // OAuth 模型别名
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> { async getOauthModelAlias(): Promise<Record<string, OAuthModelAliasEntry[]>> {
try { const data = await apiClient.get(OAUTH_MODEL_ALIAS_ENDPOINT);
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_ENDPOINT); return normalizeOauthModelAlias(data);
return normalizeOauthModelMappings(data);
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
const data = await apiClient.get(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT);
return normalizeOauthModelMappings(data);
}
}, },
saveOauthModelMappings: async (channel: string, mappings: OAuthModelMappingEntry[]) => { saveOauthModelAlias: async (channel: string, aliases: OAuthModelAliasEntry[]) => {
const normalizedChannel = String(channel ?? '') const normalizedChannel = String(channel ?? '')
.trim() .trim()
.toLowerCase(); .toLowerCase();
const normalizedMappings = normalizeOauthModelMappings({ [normalizedChannel]: mappings })[normalizedChannel] ?? []; const normalizedAliases = normalizeOauthModelAlias({ [normalizedChannel]: aliases })[normalizedChannel] ?? [];
await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: normalizedAliases });
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: normalizedMappings });
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: normalizedMappings });
}
}, },
deleteOauthModelMappings: async (channel: string) => { deleteOauthModelAlias: async (channel: string) => {
const normalizedChannel = String(channel ?? '') const normalizedChannel = String(channel ?? '')
.trim() .trim()
.toLowerCase(); .toLowerCase();
const deleteViaPatch = async () => {
try {
await apiClient.patch(OAUTH_MODEL_MAPPINGS_ENDPOINT, { channel: normalizedChannel, mappings: [] });
return true;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.patch(OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT, { channel: normalizedChannel, aliases: [] });
return true;
}
};
try { try {
await deleteViaPatch(); await apiClient.patch(OAUTH_MODEL_ALIAS_ENDPOINT, { channel: normalizedChannel, aliases: [] });
return;
} catch (err: unknown) { } catch (err: unknown) {
const status = getStatusCode(err); const status = getStatusCode(err);
if (status !== 405) throw err; if (status !== 405) throw err;
} await apiClient.delete(`${OAUTH_MODEL_ALIAS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
try {
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
return;
} catch (err: unknown) {
if (getStatusCode(err) !== 404) throw err;
await apiClient.delete(`${OAUTH_MODEL_MAPPINGS_LEGACY_ENDPOINT}?channel=${encodeURIComponent(normalizedChannel)}`);
} }
}, },
@@ -200,5 +173,13 @@ export const authFilesApi = {
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`); const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : []; return (data && Array.isArray(data['models'])) ? data['models'] : [];
},
// 获取指定 channel 的模型定义
async getModelDefinitions(channel: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const normalizedChannel = String(channel ?? '').trim().toLowerCase();
if (!normalizedChannel) return [];
const data = await apiClient.get(`/model-definitions/${encodeURIComponent(normalizedChannel)}`);
return (data && Array.isArray(data['models'])) ? data['models'] : [];
} }
}; };

View File

@@ -20,12 +20,21 @@ const normalizeBaseUrl = (baseUrl: string): string => {
const buildModelsEndpoint = (baseUrl: string): string => { const buildModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl); const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return ''; if (!normalized) return '';
return normalized.endsWith('/v1') ? `${normalized}/models` : `${normalized}/v1/models`; return `${normalized}/models`;
};
const buildV1ModelsEndpoint = (baseUrl: string): string => {
const normalized = normalizeBaseUrl(baseUrl);
if (!normalized) return '';
return `${normalized}/v1/models`;
}; };
export const modelsApi = { export const modelsApi = {
/**
* Fetch available models from /v1/models endpoint (for system info page)
*/
async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) { async fetchModels(baseUrl: string, apiKey?: string, headers: Record<string, string> = {}) {
const endpoint = buildModelsEndpoint(baseUrl); const endpoint = buildV1ModelsEndpoint(baseUrl);
if (!endpoint) { if (!endpoint) {
throw new Error('Invalid base url'); throw new Error('Invalid base url');
} }
@@ -42,6 +51,9 @@ export const modelsApi = {
return normalizeModelList(payload, { dedupe: true }); return normalizeModelList(payload, { dedupe: true });
}, },
/**
* Fetch models from /models endpoint via api-call (for OpenAI provider discovery)
*/
async fetchModelsViaApiCall( async fetchModelsViaApiCall(
baseUrl: string, baseUrl: string,
apiKey?: string, apiKey?: string,

View File

@@ -34,11 +34,11 @@ export interface OAuthExcludedModels {
models: string[]; models: string[];
} }
// OAuth 模型映射 // OAuth 模型别名
export interface OAuthModelMappingEntry { export interface OAuthModelAliasEntry {
name: string; name: string;
alias: string; alias: string;
fork?: boolean; fork?: boolean;
} }
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>; export type OAuthModelAlias = Record<string, OAuthModelAliasEntry[]>;

View File

@@ -55,6 +55,7 @@ export interface AntigravityQuotaGroupDefinition {
export interface GeminiCliQuotaGroupDefinition { export interface GeminiCliQuotaGroupDefinition {
id: string; id: string;
label: string; label: string;
preferredModelId?: string;
modelIds: string[]; modelIds: string[];
} }

View File

@@ -8,7 +8,7 @@ import type {
AntigravityQuotaInfo, AntigravityQuotaInfo,
AntigravityModelsPayload, AntigravityModelsPayload,
GeminiCliParsedBucket, GeminiCliParsedBucket,
GeminiCliQuotaBucketState GeminiCliQuotaBucketState,
} from '@/types'; } from '@/types';
import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants'; import { ANTIGRAVITY_QUOTA_GROUPS, GEMINI_CLI_GROUP_LOOKUP } from './constants';
import { normalizeQuotaFraction } from './parsers'; import { normalizeQuotaFraction } from './parsers';
@@ -35,7 +35,19 @@ export function buildGeminiCliQuotaBuckets(
): GeminiCliQuotaBucketState[] { ): GeminiCliQuotaBucketState[] {
if (buckets.length === 0) return []; if (buckets.length === 0) return [];
const grouped = new Map<string, GeminiCliQuotaBucketState & { modelIds: string[] }>(); type GeminiCliQuotaBucketGroup = {
id: string;
label: string;
tokenType: string | null;
modelIds: string[];
preferredModelId?: string;
preferredBucket?: GeminiCliParsedBucket;
fallbackRemainingFraction: number | null;
fallbackRemainingAmount: number | null;
fallbackResetTime: string | undefined;
};
const grouped = new Map<string, GeminiCliQuotaBucketGroup>();
buckets.forEach((bucket) => { buckets.forEach((bucket) => {
if (isIgnoredGeminiCliModel(bucket.modelId)) return; if (isIgnoredGeminiCliModel(bucket.modelId)) return;
@@ -47,37 +59,55 @@ export function buildGeminiCliQuotaBuckets(
const existing = grouped.get(mapKey); const existing = grouped.get(mapKey);
if (!existing) { if (!existing) {
const preferredModelId = group?.preferredModelId;
const preferredBucket =
preferredModelId && bucket.modelId === preferredModelId ? bucket : undefined;
grouped.set(mapKey, { grouped.set(mapKey, {
id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`, id: `${groupId}${tokenKey ? `-${tokenKey}` : ''}`,
label, label,
remainingFraction: bucket.remainingFraction,
remainingAmount: bucket.remainingAmount,
resetTime: bucket.resetTime,
tokenType: bucket.tokenType, tokenType: bucket.tokenType,
modelIds: [bucket.modelId] modelIds: [bucket.modelId],
preferredModelId,
preferredBucket,
fallbackRemainingFraction: bucket.remainingFraction,
fallbackRemainingAmount: bucket.remainingAmount,
fallbackResetTime: bucket.resetTime,
}); });
return; return;
} }
existing.remainingFraction = minNullableNumber( existing.fallbackRemainingFraction = minNullableNumber(
existing.remainingFraction, existing.fallbackRemainingFraction,
bucket.remainingFraction bucket.remainingFraction
); );
existing.remainingAmount = minNullableNumber(existing.remainingAmount, bucket.remainingAmount); existing.fallbackRemainingAmount = minNullableNumber(
existing.resetTime = pickEarlierResetTime(existing.resetTime, bucket.resetTime); existing.fallbackRemainingAmount,
bucket.remainingAmount
);
existing.fallbackResetTime = pickEarlierResetTime(existing.fallbackResetTime, bucket.resetTime);
existing.modelIds.push(bucket.modelId); existing.modelIds.push(bucket.modelId);
if (existing.preferredModelId && bucket.modelId === existing.preferredModelId) {
existing.preferredBucket = bucket;
}
}); });
return Array.from(grouped.values()).map((bucket) => { return Array.from(grouped.values()).map((bucket) => {
const uniqueModelIds = Array.from(new Set(bucket.modelIds)); const uniqueModelIds = Array.from(new Set(bucket.modelIds));
const preferred = bucket.preferredBucket;
const remainingFraction = preferred
? preferred.remainingFraction
: bucket.fallbackRemainingFraction;
const remainingAmount = preferred ? preferred.remainingAmount : bucket.fallbackRemainingAmount;
const resetTime = preferred ? preferred.resetTime : bucket.fallbackResetTime;
return { return {
id: bucket.id, id: bucket.id,
label: bucket.label, label: bucket.label,
remainingFraction: bucket.remainingFraction, remainingFraction,
remainingAmount: bucket.remainingAmount, remainingAmount,
resetTime: bucket.resetTime, resetTime,
tokenType: bucket.tokenType, tokenType: bucket.tokenType,
modelIds: uniqueModelIds modelIds: uniqueModelIds,
}; };
}); });
} }
@@ -101,7 +131,7 @@ export function getAntigravityQuotaInfo(entry?: AntigravityQuotaInfo): {
return { return {
remainingFraction, remainingFraction,
resetTime, resetTime,
displayName displayName,
}; };
} }
@@ -150,7 +180,7 @@ export function buildAntigravityQuotaGroups(
id, id,
remainingFraction, remainingFraction,
resetTime: info.resetTime, resetTime: info.resetTime,
displayName: info.displayName displayName: info.displayName,
}; };
}) })
.filter((entry): entry is NonNullable<typeof entry> => entry !== null); .filter((entry): entry is NonNullable<typeof entry> => entry !== null);
@@ -168,7 +198,7 @@ export function buildAntigravityQuotaGroups(
label, label,
models: quotaEntries.map((entry) => entry.id), models: quotaEntries.map((entry) => entry.id),
remainingFraction, remainingFraction,
resetTime resetTime,
}; };
}; };

View File

@@ -5,64 +5,64 @@
import type { import type {
AntigravityQuotaGroupDefinition, AntigravityQuotaGroupDefinition,
GeminiCliQuotaGroupDefinition, GeminiCliQuotaGroupDefinition,
TypeColorSet TypeColorSet,
} from '@/types'; } from '@/types';
// Theme colors for type badges // Theme colors for type badges
export const TYPE_COLORS: Record<string, TypeColorSet> = { export const TYPE_COLORS: Record<string, TypeColorSet> = {
qwen: { qwen: {
light: { bg: '#e8f5e9', text: '#2e7d32' }, light: { bg: '#e8f5e9', text: '#2e7d32' },
dark: { bg: '#1b5e20', text: '#81c784' } dark: { bg: '#1b5e20', text: '#81c784' },
}, },
gemini: { gemini: {
light: { bg: '#e3f2fd', text: '#1565c0' }, light: { bg: '#e3f2fd', text: '#1565c0' },
dark: { bg: '#0d47a1', text: '#64b5f6' } dark: { bg: '#0d47a1', text: '#64b5f6' },
}, },
'gemini-cli': { 'gemini-cli': {
light: { bg: '#e7efff', text: '#1e4fa3' }, light: { bg: '#e7efff', text: '#1e4fa3' },
dark: { bg: '#1c3f73', text: '#a8c7ff' } dark: { bg: '#1c3f73', text: '#a8c7ff' },
}, },
aistudio: { aistudio: {
light: { bg: '#f0f2f5', text: '#2f343c' }, light: { bg: '#f0f2f5', text: '#2f343c' },
dark: { bg: '#373c42', text: '#cfd3db' } dark: { bg: '#373c42', text: '#cfd3db' },
}, },
claude: { claude: {
light: { bg: '#fce4ec', text: '#c2185b' }, light: { bg: '#fce4ec', text: '#c2185b' },
dark: { bg: '#880e4f', text: '#f48fb1' } dark: { bg: '#880e4f', text: '#f48fb1' },
}, },
codex: { codex: {
light: { bg: '#fff3e0', text: '#ef6c00' }, light: { bg: '#fff3e0', text: '#ef6c00' },
dark: { bg: '#e65100', text: '#ffb74d' } dark: { bg: '#e65100', text: '#ffb74d' },
}, },
antigravity: { antigravity: {
light: { bg: '#e0f7fa', text: '#006064' }, light: { bg: '#e0f7fa', text: '#006064' },
dark: { bg: '#004d40', text: '#80deea' } dark: { bg: '#004d40', text: '#80deea' },
}, },
iflow: { iflow: {
light: { bg: '#f3e5f5', text: '#7b1fa2' }, light: { bg: '#f3e5f5', text: '#7b1fa2' },
dark: { bg: '#4a148c', text: '#ce93d8' } dark: { bg: '#4a148c', text: '#ce93d8' },
}, },
empty: { empty: {
light: { bg: '#f5f5f5', text: '#616161' }, light: { bg: '#f5f5f5', text: '#616161' },
dark: { bg: '#424242', text: '#bdbdbd' } dark: { bg: '#424242', text: '#bdbdbd' },
}, },
unknown: { unknown: {
light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' }, light: { bg: '#f0f0f0', text: '#666666', border: '1px dashed #999999' },
dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' } dark: { bg: '#3a3a3a', text: '#aaaaaa', border: '1px dashed #666666' },
} },
}; };
// Antigravity API configuration // Antigravity API configuration
export const ANTIGRAVITY_QUOTA_URLS = [ export const ANTIGRAVITY_QUOTA_URLS = [
'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels', 'https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels', 'https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels',
'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels' 'https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels',
]; ];
export const ANTIGRAVITY_REQUEST_HEADERS = { export const ANTIGRAVITY_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'antigravity/1.11.5 windows/amd64' 'User-Agent': 'antigravity/1.11.5 windows/amd64',
}; };
export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
@@ -73,40 +73,40 @@ export const ANTIGRAVITY_QUOTA_GROUPS: AntigravityQuotaGroupDefinition[] = [
'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5-thinking',
'claude-opus-4-5-thinking', 'claude-opus-4-5-thinking',
'claude-sonnet-4-5', 'claude-sonnet-4-5',
'gpt-oss-120b-medium' 'gpt-oss-120b-medium',
] ],
}, },
{ {
id: 'gemini-3-pro', id: 'gemini-3-pro',
label: 'Gemini 3 Pro', label: 'Gemini 3 Pro',
identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'] identifiers: ['gemini-3-pro-high', 'gemini-3-pro-low'],
}, },
{ {
id: 'gemini-2-5-flash', id: 'gemini-2-5-flash',
label: 'Gemini 2.5 Flash', label: 'Gemini 2.5 Flash',
identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'] identifiers: ['gemini-2.5-flash', 'gemini-2.5-flash-thinking'],
}, },
{ {
id: 'gemini-2-5-flash-lite', id: 'gemini-2-5-flash-lite',
label: 'Gemini 2.5 Flash Lite', label: 'Gemini 2.5 Flash Lite',
identifiers: ['gemini-2.5-flash-lite'] identifiers: ['gemini-2.5-flash-lite'],
}, },
{ {
id: 'gemini-2-5-cu', id: 'gemini-2-5-cu',
label: 'Gemini 2.5 CU', label: 'Gemini 2.5 CU',
identifiers: ['rev19-uic3-1p'] identifiers: ['rev19-uic3-1p'],
}, },
{ {
id: 'gemini-3-flash', id: 'gemini-3-flash',
label: 'Gemini 3 Flash', label: 'Gemini 3 Flash',
identifiers: ['gemini-3-flash'] identifiers: ['gemini-3-flash'],
}, },
{ {
id: 'gemini-image', id: 'gemini-image',
label: 'gemini-3-pro-image', label: 'gemini-3-pro-image',
identifiers: ['gemini-3-pro-image'], identifiers: ['gemini-3-pro-image'],
labelFromModel: true labelFromModel: true,
} },
]; ];
// Gemini CLI API configuration // Gemini CLI API configuration
@@ -115,30 +115,22 @@ export const GEMINI_CLI_QUOTA_URL =
export const GEMINI_CLI_REQUEST_HEADERS = { export const GEMINI_CLI_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}; };
export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ export const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [
{ {
id: 'gemini-2-5-flash-series', id: 'gemini-flash-series',
label: 'Gemini 2.5 Flash Series', label: 'Gemini Flash Series',
modelIds: ['gemini-2.5-flash', 'gemini-2.5-flash-lite'] preferredModelId: 'gemini-3-flash-preview',
modelIds: ['gemini-3-flash-preview', 'gemini-2.5-flash', 'gemini-2.5-flash-lite'],
}, },
{ {
id: 'gemini-2-5-pro', id: 'gemini-pro-series',
label: 'Gemini 2.5 Pro', label: 'Gemini Pro Series',
modelIds: ['gemini-2.5-pro'] preferredModelId: 'gemini-3-pro-preview',
modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'],
}, },
{
id: 'gemini-3-pro-preview',
label: 'Gemini 3 Pro Preview',
modelIds: ['gemini-3-pro-preview']
},
{
id: 'gemini-3-flash-preview',
label: 'Gemini 3 Flash Preview',
modelIds: ['gemini-3-flash-preview']
}
]; ];
export const GEMINI_CLI_GROUP_LOOKUP = new Map( export const GEMINI_CLI_GROUP_LOOKUP = new Map(
@@ -155,5 +147,5 @@ export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
export const CODEX_REQUEST_HEADERS = { export const CODEX_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$', Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal' 'User-Agent': 'codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal',
}; };

View File

@@ -579,6 +579,8 @@ export function getApiStats(usageData: any, modelPrices: Record<string, ModelPri
export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{ export function getModelStats(usageData: any, modelPrices: Record<string, ModelPrice>): Array<{
model: string; model: string;
requests: number; requests: number;
successCount: number;
failureCount: number;
tokens: number; tokens: number;
cost: number; cost: number;
}> { }> {
@@ -586,20 +588,39 @@ export function getModelStats(usageData: any, modelPrices: Record<string, ModelP
return []; return [];
} }
const modelMap = new Map<string, { requests: number; tokens: number; cost: number }>(); const modelMap = new Map<string, { requests: number; successCount: number; failureCount: number; tokens: number; cost: number }>();
Object.values(usageData.apis as Record<string, any>).forEach(apiData => { Object.values(usageData.apis as Record<string, any>).forEach(apiData => {
const models = apiData?.models || {}; const models = apiData?.models || {};
Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => { Object.entries(models as Record<string, any>).forEach(([modelName, modelData]) => {
const existing = modelMap.get(modelName) || { requests: 0, tokens: 0, cost: 0 }; const existing = modelMap.get(modelName) || { requests: 0, successCount: 0, failureCount: 0, tokens: 0, cost: 0 };
existing.requests += modelData.total_requests || 0; existing.requests += modelData.total_requests || 0;
existing.tokens += modelData.total_tokens || 0; existing.tokens += modelData.total_tokens || 0;
const details = Array.isArray(modelData.details) ? modelData.details : [];
const price = modelPrices[modelName]; const price = modelPrices[modelName];
if (price) {
const details = Array.isArray(modelData.details) ? modelData.details : []; const hasExplicitCounts =
typeof modelData.success_count === 'number' || typeof modelData.failure_count === 'number';
if (hasExplicitCounts) {
existing.successCount += Number(modelData.success_count) || 0;
existing.failureCount += Number(modelData.failure_count) || 0;
}
if (details.length > 0 && (!hasExplicitCounts || price)) {
details.forEach((detail: any) => { details.forEach((detail: any) => {
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); if (!hasExplicitCounts) {
if (detail?.failed === true) {
existing.failureCount += 1;
} else {
existing.successCount += 1;
}
}
if (price) {
existing.cost += calculateCost({ ...detail, __modelName: modelName }, modelPrices);
}
}); });
} }
modelMap.set(modelName, existing); modelMap.set(modelName, existing);
@@ -903,11 +924,11 @@ export function calculateStatusBarData(
authIndexFilter?: number authIndexFilter?: number
): StatusBarData { ): StatusBarData {
const BLOCK_COUNT = 20; const BLOCK_COUNT = 20;
const BLOCK_DURATION_MS = 5 * 60 * 1000; // 5 minutes const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
const HOUR_MS = 60 * 60 * 1000; const WINDOW_MS = 200 * 60 * 1000; // 200 minutes
const now = Date.now(); const now = Date.now();
const hourAgo = now - HOUR_MS; const windowStart = now - WINDOW_MS;
// Initialize blocks // Initialize blocks
const blockStats: Array<{ success: number; failure: number }> = Array.from( const blockStats: Array<{ success: number; failure: number }> = Array.from(
@@ -921,7 +942,7 @@ export function calculateStatusBarData(
// Filter and bucket the usage details // Filter and bucket the usage details
usageDetails.forEach((detail) => { usageDetails.forEach((detail) => {
const timestamp = Date.parse(detail.timestamp); const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < hourAgo || timestamp > now) { if (Number.isNaN(timestamp) || timestamp < windowStart || timestamp > now) {
return; return;
} }