Compare commits

...

10 Commits
v1.4.8 ... dev

Author SHA1 Message Date
Supra4E8C
e40c3488fe Merge pull request #98 from razorback16/main
feat(quota): add Claude OAuth usage quota detection
2026-02-12 15:50:42 +08:00
Supra4E8C
04686aafc8 fix(ai-providers): stabilize OpenAI key test state during editing 2026-02-12 15:46:00 +08:00
Supra4E8C
9476afc41c Merge pull request #102 from moxi000/feat/openai-ui-ux-optimization
feat(ai-providers): 优化 OpenAI 编辑页 UI,支持批量与按 Key 单独测试模型连通性
2026-02-12 15:23:11 +08:00
moxi
ab6a1a412c fix(ai-providers): 统一 OpenAI key 表头与内容居中对齐 2026-02-12 00:08:10 +08:00
moxi
2cf1e23351 fix(ai-providers): 修复 OpenAI 密钥测试状态与共享样式回归 2026-02-11 23:51:53 +08:00
moxi
0089d4a705 chore: 同步 package-lock 以匹配依赖变更 2026-02-11 23:34:45 +08:00
moxi
c726fbc379 feat(ai-providers): 优化 OpenAI 编辑页 UI 交互与对齐 2026-02-11 23:31:43 +08:00
Razorback16
83f6a1a9f9 feat(quota): add Claude OAuth usage quota detection
Add Claude quota section to the Quota Management page, using the
Anthropic OAuth usage API (api.anthropic.com/api/oauth/usage) to
display utilization across all rate limit windows (5-hour, 7-day,
Opus, Sonnet, etc.) and extra usage credits.
2026-02-09 14:12:07 -08:00
LTbinglingfeng
027ab483d4 refactor(providers): remove deprecated AI provider modal implementations and unused modal types 2026-02-09 00:54:24 +08:00
LTbinglingfeng
535c303aec fix(ai-providers): enforce required provider name for OpenAI-compatible save 2026-02-09 00:21:56 +08:00
32 changed files with 1297 additions and 1665 deletions

38
package-lock.json generated
View File

@@ -72,7 +72,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -467,7 +466,6 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz",
"integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1243,18 +1241,6 @@
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@openai/codex": {
"version": "0.98.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.98.0.tgz",
"integrity": "sha512-CKjrhAmzTvWn7Vbsi27iZRKBAJw9a7ZTTkWQDbLgQZP1weGbDIBk1r6wiLEp1ZmDO7w0fHPLYgnVspiOrYgcxg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
@@ -1945,7 +1931,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2033,7 +2018,6 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1",
@@ -2351,7 +2335,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2401,13 +2384,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -2563,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -2828,7 +2810,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3305,7 +3286,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -3635,7 +3615,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3742,7 +3721,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3760,7 +3738,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3869,7 +3846,6 @@
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -4052,7 +4028,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4129,7 +4104,6 @@
"integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4259,7 +4233,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
@@ -4287,7 +4260,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,281 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useConfigStore, useNotificationStore } from '@/stores';
import { ampcodeApi } from '@/services/api';
import type { AmpcodeConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '../utils';
import type { AmpcodeFormState } from '../types';
interface AmpcodeModalProps {
isOpen: boolean;
disableControls: boolean;
onClose: () => void;
onBusyChange?: (busy: boolean) => void;
}
export function AmpcodeModal({ isOpen, disableControls, onClose, onBusyChange }: AmpcodeModalProps) {
const { t } = useTranslation();
const { showNotification, showConfirmation } = useNotificationStore();
const config = useConfigStore((state) => state.config);
const updateConfigValue = useConfigStore((state) => state.updateConfigValue);
const clearCache = useConfigStore((state) => state.clearCache);
const [form, setForm] = useState<AmpcodeFormState>(() => buildAmpcodeFormState(null));
const [loading, setLoading] = useState(false);
const [loaded, setLoaded] = useState(false);
const [mappingsDirty, setMappingsDirty] = useState(false);
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const initializedRef = useRef(false);
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
useEffect(() => {
onBusyChange?.(loading || saving);
}, [loading, saving, onBusyChange]);
useEffect(() => {
if (!isOpen) {
initializedRef.current = false;
setLoading(false);
setSaving(false);
setError('');
setLoaded(false);
setMappingsDirty(false);
setForm(buildAmpcodeFormState(null));
onBusyChange?.(false);
return;
}
if (initializedRef.current) return;
initializedRef.current = true;
setLoading(true);
setLoaded(false);
setMappingsDirty(false);
setError('');
setForm(buildAmpcodeFormState(config?.ampcode ?? null));
void (async () => {
try {
const ampcode = await ampcodeApi.getAmpcode();
setLoaded(true);
updateConfigValue('ampcode', ampcode);
clearCache('ampcode');
setForm(buildAmpcodeFormState(ampcode));
} catch (err: unknown) {
setError(getErrorMessage(err) || t('notification.refresh_failed'));
} finally {
setLoading(false);
}
})();
}, [clearCache, config?.ampcode, isOpen, onBusyChange, t, updateConfigValue]);
const clearAmpcodeUpstreamApiKey = async () => {
showConfirmation({
title: t('ai_providers.ampcode_clear_upstream_api_key_title', { defaultValue: 'Clear Upstream API Key' }),
message: t('ai_providers.ampcode_clear_upstream_api_key_confirm'),
variant: 'danger',
confirmText: t('common.confirm'),
onConfirm: async () => {
setSaving(true);
setError('');
try {
await ampcodeApi.clearUpstreamApiKey();
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = { ...previous };
delete next.upstreamApiKey;
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_upstream_api_key_cleared'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
},
});
};
const performSaveAmpcode = async () => {
setSaving(true);
setError('');
try {
const upstreamUrl = form.upstreamUrl.trim();
const overrideKey = form.upstreamApiKey.trim();
const modelMappings = entriesToAmpcodeMappings(form.mappingEntries);
if (upstreamUrl) {
await ampcodeApi.updateUpstreamUrl(upstreamUrl);
} else {
await ampcodeApi.clearUpstreamUrl();
}
await ampcodeApi.updateForceModelMappings(form.forceModelMappings);
if (loaded || mappingsDirty) {
if (modelMappings.length) {
await ampcodeApi.saveModelMappings(modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
}
if (overrideKey) {
await ampcodeApi.updateUpstreamApiKey(overrideKey);
}
const previous = config?.ampcode ?? {};
const next: AmpcodeConfig = {
upstreamUrl: upstreamUrl || undefined,
forceModelMappings: form.forceModelMappings,
};
if (previous.upstreamApiKey) {
next.upstreamApiKey = previous.upstreamApiKey;
}
if (Array.isArray(previous.modelMappings)) {
next.modelMappings = previous.modelMappings;
}
if (overrideKey) {
next.upstreamApiKey = overrideKey;
}
if (loaded || mappingsDirty) {
if (modelMappings.length) {
next.modelMappings = modelMappings;
} else {
delete next.modelMappings;
}
}
updateConfigValue('ampcode', next);
clearCache('ampcode');
showNotification(t('notification.ampcode_updated'), 'success');
onClose();
} catch (err: unknown) {
const message = getErrorMessage(err);
setError(message);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
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 (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.ampcode_modal_title')}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={saveAmpcode} loading={saving} disabled={disableControls || loading}>
{t('common.save')}
</Button>
</>
}
>
{error && <div className="error-box">{error}</div>}
<Input
label={t('ai_providers.ampcode_upstream_url_label')}
placeholder={t('ai_providers.ampcode_upstream_url_placeholder')}
value={form.upstreamUrl}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamUrl: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_url_hint')}
/>
<Input
label={t('ai_providers.ampcode_upstream_api_key_label')}
placeholder={t('ai_providers.ampcode_upstream_api_key_placeholder')}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((prev) => ({ ...prev, upstreamApiKey: e.target.value }))}
disabled={loading || saving}
hint={t('ai_providers.ampcode_upstream_api_key_hint')}
/>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
marginTop: -8,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<div className="hint" style={{ margin: 0 }}>
{t('ai_providers.ampcode_upstream_api_key_current', {
key: config?.ampcode?.upstreamApiKey
? maskApiKey(config.ampcode.upstreamApiKey)
: t('common.not_set'),
})}
</div>
<Button
variant="danger"
size="sm"
onClick={clearAmpcodeUpstreamApiKey}
disabled={loading || saving || !config?.ampcode?.upstreamApiKey}
>
{t('ai_providers.ampcode_clear_upstream_api_key')}
</Button>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
checked={form.forceModelMappings}
onChange={(value) => setForm((prev) => ({ ...prev, forceModelMappings: value }))}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_force_model_mappings_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.ampcode_model_mappings_label')}</label>
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
disabled={loading || saving}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,129 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface ClaudeModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function ClaudeModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: ClaudeModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.claude_edit_modal_title')
: t('ai_providers.claude_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.claude_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.claude_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.claude_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.claude_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,117 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types';
interface CodexModalProps extends ProviderModalProps<ProviderKeyConfig, ProviderFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): ProviderFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
excludedText: '',
});
export function CodexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: CodexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<ProviderFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.codex_edit_modal_title')
: t('ai_providers.codex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.codex_add_modal_key_label')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.codex_add_modal_url_label')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.codex_add_modal_proxy_label')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,113 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types';
interface GeminiModalProps extends ProviderModalProps<GeminiKeyConfig, GeminiFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): GeminiFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
headers: [],
excludedModels: [],
excludedText: '',
});
export function GeminiModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: GeminiModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<GeminiFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
const handleSave = () => {
void onSave(form, editIndex);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.gemini_edit_modal_title')
: t('ai_providers.gemini_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.gemini_add_modal_key_label')}
placeholder={t('ai_providers.gemini_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.gemini_base_url_label')}
placeholder={t('ai_providers.gemini_base_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { modelsApi } from '@/services/api';
import type { ApiKeyEntry } from '@/types';
import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, type HeaderEntry } from '@/utils/headers';
import { buildOpenAIModelsEndpoint } from '../utils';
import styles from '@/pages/AiProvidersPage.module.scss';
interface OpenAIDiscoveryModalProps {
isOpen: boolean;
baseUrl: string;
headers: HeaderEntry[];
apiKeyEntries: ApiKeyEntry[];
onClose: () => void;
onApply: (selected: ModelInfo[]) => void;
}
export function OpenAIDiscoveryModal({
isOpen,
baseUrl,
headers,
apiKeyEntries,
onClose,
onApply,
}: OpenAIDiscoveryModalProps) {
const { t } = useTranslation();
const [endpoint, setEndpoint] = useState('');
const [models, setModels] = useState<ModelInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const filteredModels = useMemo(() => {
const filter = search.trim().toLowerCase();
if (!filter) return models;
return models.filter((model) => {
const name = (model.name || '').toLowerCase();
const alias = (model.alias || '').toLowerCase();
const desc = (model.description || '').toLowerCase();
return name.includes(filter) || alias.includes(filter) || desc.includes(filter);
});
}, [models, search]);
const fetchOpenaiModelDiscovery = useCallback(
async ({ allowFallback = true }: { allowFallback?: boolean } = {}) => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) return;
setLoading(true);
setError('');
try {
const headerObject = buildHeaderObject(headers);
const firstKey = apiKeyEntries.find((entry) => entry.apiKey?.trim())?.apiKey?.trim();
const hasAuthHeader = Boolean(headerObject.Authorization || headerObject['authorization']);
const list = await modelsApi.fetchModelsViaApiCall(
trimmedBaseUrl,
hasAuthHeader ? undefined : firstKey,
headerObject
);
setModels(list);
} catch (err: unknown) {
if (allowFallback) {
try {
const list = await modelsApi.fetchModelsViaApiCall(trimmedBaseUrl);
setModels(list);
return;
} catch (fallbackErr: unknown) {
const message = getErrorMessage(fallbackErr) || getErrorMessage(err);
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${message}`);
}
} else {
setModels([]);
setError(`${t('ai_providers.openai_models_fetch_error')}: ${getErrorMessage(err)}`);
}
} finally {
setLoading(false);
}
},
[apiKeyEntries, baseUrl, headers, t]
);
useEffect(() => {
if (!isOpen) return;
setEndpoint(buildOpenAIModelsEndpoint(baseUrl));
setModels([]);
setSearch('');
setSelected(new Set());
setError('');
void fetchOpenaiModelDiscovery();
}, [baseUrl, fetchOpenaiModelDiscovery, isOpen]);
const toggleSelection = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const handleApply = () => {
const selectedModels = models.filter((model) => selected.has(model.name));
onApply(selectedModels);
};
return (
<Modal
open={isOpen}
onClose={onClose}
title={t('ai_providers.openai_models_fetch_title')}
width={720}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={loading}>
{t('ai_providers.openai_models_fetch_back')}
</Button>
<Button onClick={handleApply} disabled={loading}>
{t('ai_providers.openai_models_fetch_apply')}
</Button>
</>
}
>
<div className="hint" style={{ marginBottom: 8 }}>
{t('ai_providers.openai_models_fetch_hint')}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_models_fetch_url_label')}</label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input className="input" readOnly value={endpoint} />
<Button
variant="secondary"
size="sm"
onClick={() => void fetchOpenaiModelDiscovery({ allowFallback: true })}
loading={loading}
>
{t('ai_providers.openai_models_fetch_refresh')}
</Button>
</div>
</div>
<Input
label={t('ai_providers.openai_models_search_label')}
placeholder={t('ai_providers.openai_models_search_placeholder')}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('ai_providers.openai_models_fetch_loading')}</div>
) : models.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_fetch_empty')}</div>
) : filteredModels.length === 0 ? (
<div className="hint">{t('ai_providers.openai_models_search_empty')}</div>
) : (
<div className={styles.modelDiscoveryList}>
{filteredModels.map((model) => {
const checked = selected.has(model.name);
return (
<label
key={model.name}
className={`${styles.modelDiscoveryRow} ${checked ? styles.modelDiscoveryRowSelected : ''}`}
>
<input type="checkbox" checked={checked} onChange={() => toggleSelection(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>
)}
</Modal>
);
}

View File

@@ -1,433 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import { useNotificationStore } from '@/stores';
import { apiCallApi, getApiCallErrorMessage } from '@/services/api';
import type { OpenAIProviderConfig, ApiKeyEntry } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import type { ModelInfo } from '@/utils/models';
import styles from '@/pages/AiProvidersPage.module.scss';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '../utils';
import type { ModelEntry, OpenAIFormState, ProviderModalProps } from '../types';
import { OpenAIDiscoveryModal } from './OpenAIDiscoveryModal';
const OPENAI_TEST_TIMEOUT_MS = 30_000;
interface OpenAIModalProps extends ProviderModalProps<OpenAIProviderConfig, OpenAIFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): OpenAIFormState => ({
name: '',
prefix: '',
baseUrl: '',
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelEntries: [{ name: '', alias: '' }],
testModel: undefined,
});
export function OpenAIModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: OpenAIModalProps) {
const { t } = useTranslation();
const { showNotification } = useNotificationStore();
const [form, setForm] = useState<OpenAIFormState>(buildEmptyForm);
const [discoveryOpen, setDiscoveryOpen] = useState(false);
const [testModel, setTestModel] = useState('');
const [testStatus, setTestStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const getErrorMessage = (err: unknown) => {
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
return '';
};
const availableModels = useMemo(
() => form.modelEntries.map((entry) => entry.name.trim()).filter(Boolean),
[form.modelEntries]
);
useEffect(() => {
if (!isOpen) {
setDiscoveryOpen(false);
return;
}
if (initialData) {
const modelEntries = modelsToEntries(initialData.models);
setForm({
name: initialData.name,
prefix: initialData.prefix ?? '',
baseUrl: initialData.baseUrl,
headers: headersToEntries(initialData.headers),
testModel: initialData.testModel,
modelEntries,
apiKeyEntries: initialData.apiKeyEntries?.length
? initialData.apiKeyEntries
: [buildApiKeyEntry()],
});
const available = modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
const initialModel =
initialData.testModel && available.includes(initialData.testModel)
? initialData.testModel
: available[0] || '';
setTestModel(initialModel);
} else {
setForm(buildEmptyForm());
setTestModel('');
}
setTestStatus('idle');
setTestMessage('');
setDiscoveryOpen(false);
}, [initialData, isOpen]);
useEffect(() => {
if (!isOpen) return;
if (availableModels.length === 0) {
if (testModel) {
setTestModel('');
setTestStatus('idle');
setTestMessage('');
}
return;
}
if (!testModel || !availableModels.includes(testModel)) {
setTestModel(availableModels[0]);
setTestStatus('idle');
setTestMessage('');
}
}, [availableModels, isOpen, testModel]);
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={list.length <= 1 || isSaving}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={isSaving}>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
setDiscoveryOpen(true);
};
const applyOpenaiModelDiscoverySelection = (selectedModels: ModelInfo[]) => {
if (!selectedModels.length) {
setDiscoveryOpen(false);
return;
}
const mergedMap = new Map<string, ModelEntry>();
form.modelEntries.forEach((entry) => {
const name = entry.name.trim();
if (!name) return;
mergedMap.set(name, { name, alias: entry.alias?.trim() || '' });
});
let addedCount = 0;
selectedModels.forEach((model) => {
const name = model.name.trim();
if (!name || mergedMap.has(name)) return;
mergedMap.set(name, { name, alias: model.alias ?? '' });
addedCount += 1;
});
const mergedEntries = Array.from(mergedMap.values());
setForm((prev) => ({
...prev,
modelEntries: mergedEntries.length ? mergedEntries : [{ name: '', alias: '' }],
}));
setDiscoveryOpen(false);
if (addedCount > 0) {
showNotification(t('ai_providers.openai_models_fetch_added', { count: addedCount }), 'success');
}
};
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
const message = t('notification.openai_test_url_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
if (!firstKeyEntry) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('notification.openai_test_model_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
}
setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running'));
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setTestStatus('success');
setTestMessage(t('ai_providers.openai_test_success'));
} catch (err: unknown) {
setTestStatus('error');
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err ? String((err as { code?: string }).code) : '';
const isTimeout =
errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
if (isTimeout) {
setTestMessage(t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 }));
} else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
}
}
};
return (
<>
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.openai_edit_modal_title')
: t('ai_providers.openai_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>
{editIndex !== null
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<Button variant="secondary" size="sm" onClick={openOpenaiModelDiscovery} disabled={isSaving}>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_test_title')}</label>
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={isSaving || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
className={`${styles.openaiTestButton} ${testStatus === 'success' ? styles.openaiTestButtonSuccess : ''}`}
onClick={testOpenaiProviderConnection}
loading={testStatus === 'loading'}
disabled={isSaving || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
</div>
{testMessage && (
<div
className={`status-badge ${
testStatus === 'error' ? 'error' : testStatus === 'success' ? 'success' : 'muted'
}`}
>
{testMessage}
</div>
)}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
{renderKeyEntries(form.apiKeyEntries)}
</div>
</Modal>
<OpenAIDiscoveryModal
isOpen={discoveryOpen}
baseUrl={form.baseUrl}
headers={form.headers}
apiKeyEntries={form.apiKeyEntries}
onClose={() => setDiscoveryOpen(false)}
onApply={applyOpenaiModelDiscoverySelection}
/>
</>
);
}

View File

@@ -1,118 +0,0 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList } from '@/components/ui/ModelInputList';
import { modelsToEntries } from '@/components/ui/modelInputListUtils';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -2,14 +2,6 @@ import type { ApiKeyEntry, GeminiKeyConfig, ProviderKeyConfig } from '@/types';
import type { HeaderEntry } from '@/utils/headers';
import type { KeyStats, UsageDetail } from '@/utils/usage';
export type ProviderModal =
| { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null };
export interface ModelEntry {
name: string;
alias: string;
@@ -58,12 +50,3 @@ export interface ProviderSectionProps<TConfig> {
onDelete: (index: number) => void;
onToggle?: (index: number, enabled: boolean) => void;
}
export interface ProviderModalProps<TConfig, TPayload = TConfig> {
isOpen: boolean;
editIndex: number | null;
initialData?: TConfig;
onClose: () => void;
onSave: (data: TPayload, index: number | null) => Promise<void>;
disabled?: boolean;
}

View File

@@ -5,5 +5,5 @@
export { QuotaSection } from './QuotaSection';
export { QuotaCard } from './QuotaCard';
export { useQuotaLoader } from './useQuotaLoader';
export { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export { ANTIGRAVITY_CONFIG, CLAUDE_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG } from './quotaConfigs';
export type { QuotaConfig } from './quotaConfigs';

View File

@@ -10,6 +10,10 @@ import type {
AntigravityModelsPayload,
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
CodexRateLimitInfo,
CodexQuotaState,
CodexUsageWindow,
@@ -23,6 +27,9 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
CODEX_USAGE_URL,
CODEX_REQUEST_HEADERS,
GEMINI_CLI_QUOTA_URL,
@@ -34,6 +41,7 @@ import {
normalizeQuotaFraction,
normalizeStringValue,
parseAntigravityPayload,
parseClaudeUsagePayload,
parseCodexUsagePayload,
parseGeminiCliQuotaPayload,
resolveCodexChatgptAccountId,
@@ -46,6 +54,7 @@ import {
createStatusError,
getStatusFromError,
isAntigravityFile,
isClaudeFile,
isCodexFile,
isDisabledAuthFile,
isGeminiCliFile,
@@ -56,15 +65,17 @@ import styles from '@/pages/QuotaPage.module.scss';
type QuotaUpdater<T> = T | ((prev: T) => T);
type QuotaType = 'antigravity' | 'codex' | 'gemini-cli';
type QuotaType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli';
const DEFAULT_ANTIGRAVITY_PROJECT_ID = 'bamboo-precept-lgxtn';
export interface QuotaStore {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -558,6 +569,149 @@ const renderGeminiCliItems = (
});
};
const buildClaudeQuotaWindows = (
payload: ClaudeUsagePayload,
t: TFunction
): ClaudeQuotaWindow[] => {
const windows: ClaudeQuotaWindow[] = [];
for (const { key, id, labelKey } of CLAUDE_USAGE_WINDOW_KEYS) {
const window = payload[key as keyof ClaudeUsagePayload];
if (!window || typeof window !== 'object' || !('utilization' in window)) continue;
const typedWindow = window as { utilization: number; resets_at: string };
const usedPercent = normalizeNumberValue(typedWindow.utilization);
const resetLabel = formatQuotaResetTime(typedWindow.resets_at);
windows.push({
id,
label: t(labelKey),
labelKey,
usedPercent,
resetLabel,
});
}
return windows;
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndexValue(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
}
const payload = parseClaudeUsagePayload(result.body ?? result.bodyText);
if (!payload) {
throw new Error(t('claude_quota.empty_windows'));
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
};
const renderClaudeItems = (
quota: ClaudeQuotaState,
t: TFunction,
helpers: QuotaRenderHelpers
): ReactNode => {
const { styles: styleMap, QuotaProgressBar } = helpers;
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const nodes: ReactNode[] = [];
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
h(
'div',
{ key: 'extra', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.extra_usage_label')),
h('span', { className: styleMap.codexPlanValue }, usedLabel)
)
);
}
if (windows.length === 0) {
nodes.push(
h('div', { key: 'empty', className: styleMap.quotaMessage }, t('claude_quota.empty_windows'))
);
return h(Fragment, null, ...nodes);
}
nodes.push(
...windows.map((window) => {
const used = window.usedPercent;
const clampedUsed = used === null ? null : Math.max(0, Math.min(100, used));
const remaining = clampedUsed === null ? null : Math.max(0, Math.min(100, 100 - clampedUsed));
const percentLabel = remaining === null ? '--' : `${Math.round(remaining)}%`;
const windowLabel = window.labelKey ? t(window.labelKey) : window.label;
return h(
'div',
{ key: window.id, className: styleMap.quotaRow },
h(
'div',
{ className: styleMap.quotaRowHeader },
h('span', { className: styleMap.quotaModel }, windowLabel),
h(
'div',
{ className: styleMap.quotaMeta },
h('span', { className: styleMap.quotaPercent }, percentLabel),
h('span', { className: styleMap.quotaReset }, window.resetLabel)
)
),
h(QuotaProgressBar, { percent: remaining, highThreshold: 80, mediumThreshold: 50 })
);
})
);
return h(Fragment, null, ...nodes);
};
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
filterFn: (file) => isClaudeFile(file) && !isDisabledAuthFile(file),
fetchQuota: fetchClaudeQuota,
storeSelector: (state) => state.claudeQuota,
storeSetter: 'setClaudeQuota',
buildLoadingState: () => ({ status: 'loading', windows: [] }),
buildSuccessState: (data) => ({
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
}),
buildErrorState: (message, status) => ({
status: 'error',
windows: [],
error: message,
errorStatus: status,
}),
cardClassName: styles.claudeCard,
controlsClassName: styles.claudeControls,
controlClassName: styles.claudeControl,
gridClassName: styles.claudeGrid,
renderQuotaItems: renderClaudeItems,
};
export const ANTIGRAVITY_CONFIG: QuotaConfig<AntigravityQuotaState, AntigravityQuotaGroup[]> = {
type: 'antigravity',
i18nPrefix: 'antigravity_quota',

View File

@@ -10,6 +10,8 @@ interface HeaderInputListProps {
disabled?: boolean;
keyPlaceholder?: string;
valuePlaceholder?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
}
export function HeaderInputList({
@@ -18,7 +20,9 @@ export function HeaderInputList({
addLabel,
disabled = false,
keyPlaceholder = 'X-Custom-Header',
valuePlaceholder = 'value'
valuePlaceholder = 'value',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: HeaderInputListProps) {
const currentEntries = entries.length ? entries : [{ key: '', value: '' }];
@@ -61,8 +65,8 @@ export function HeaderInputList({
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
title={removeButtonTitle}
aria-label={removeButtonAriaLabel}
>
<IconX size={14} />
</Button>

View File

@@ -6,10 +6,18 @@ import type { ModelEntry } from './modelInputListUtils';
interface ModelInputListProps {
entries: ModelEntry[];
onChange: (entries: ModelEntry[]) => void;
addLabel: string;
addLabel?: string;
disabled?: boolean;
namePlaceholder?: string;
aliasPlaceholder?: string;
hideAddButton?: boolean;
onAdd?: () => void;
className?: string;
rowClassName?: string;
inputClassName?: string;
removeButtonClassName?: string;
removeButtonTitle?: string;
removeButtonAriaLabel?: string;
}
export function ModelInputList({
@@ -18,9 +26,20 @@ export function ModelInputList({
addLabel,
disabled = false,
namePlaceholder = 'model-name',
aliasPlaceholder = 'alias (optional)'
aliasPlaceholder = 'alias (optional)',
hideAddButton = false,
onAdd,
className = '',
rowClassName = '',
inputClassName = '',
removeButtonClassName = '',
removeButtonTitle = 'Remove',
removeButtonAriaLabel = 'Remove',
}: ModelInputListProps) {
const currentEntries = entries.length ? entries : [{ name: '', alias: '' }];
const containerClassName = ['header-input-list', className].filter(Boolean).join(' ');
const inputClassNames = ['input', inputClassName].filter(Boolean).join(' ');
const rowClassNames = ['header-input-row', rowClassName].filter(Boolean).join(' ');
const updateEntry = (index: number, field: 'name' | 'alias', value: string) => {
const next = currentEntries.map((entry, idx) => (idx === index ? { ...entry, [field]: value } : entry));
@@ -28,7 +47,11 @@ export function ModelInputList({
};
const addEntry = () => {
onChange([...currentEntries, { name: '', alias: '' }]);
if (onAdd) {
onAdd();
} else {
onChange([...currentEntries, { name: '', alias: '' }]);
}
};
const removeEntry = (index: number) => {
@@ -37,12 +60,12 @@ export function ModelInputList({
};
return (
<div className="header-input-list">
<div className={containerClassName}>
{currentEntries.map((entry, index) => (
<Fragment key={index}>
<div className="header-input-row">
<div className={rowClassNames}>
<input
className="input"
className={inputClassNames}
placeholder={namePlaceholder}
value={entry.name}
onChange={(e) => updateEntry(index, 'name', e.target.value)}
@@ -50,7 +73,7 @@ export function ModelInputList({
/>
<span className="header-separator"></span>
<input
className="input"
className={inputClassNames}
placeholder={aliasPlaceholder}
value={entry.alias}
onChange={(e) => updateEntry(index, 'alias', e.target.value)}
@@ -61,17 +84,20 @@ export function ModelInputList({
size="sm"
onClick={() => removeEntry(index)}
disabled={disabled || currentEntries.length <= 1}
title="Remove"
aria-label="Remove"
className={removeButtonClassName}
title={removeButtonTitle}
aria-label={removeButtonAriaLabel}
>
<IconX size={14} />
</Button>
</div>
</Fragment>
))}
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
{!hideAddButton && addLabel && (
<Button variant="secondary" size="sm" onClick={addEntry} disabled={disabled} className="align-start">
{addLabel}
</Button>
)}
</div>
);
}

View File

@@ -38,13 +38,16 @@
"quota_update_required": "Please update the CPA version or check for updates",
"quota_check_credential": "Please check the credential status",
"copy": "Copy",
"status": "Status",
"action": "Action",
"custom_headers_label": "Custom Headers",
"custom_headers_hint": "Optional HTTP headers to send with the request. Leave blank to remove.",
"custom_headers_add": "Add Header",
"custom_headers_key_placeholder": "Header name, e.g. X-Custom-Header",
"custom_headers_value_placeholder": "Header value",
"model_name_placeholder": "Model name, e.g. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Model alias (optional)"
"model_alias_placeholder": "Model alias (optional)",
"invalid_provider_index": "Invalid provider index."
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -333,7 +336,13 @@
"openai_test_success": "Test succeeded. The model responded.",
"openai_test_failed": "Test failed",
"openai_test_select_placeholder": "Choose from current models",
"openai_test_select_empty": "No models configured. Add models first"
"openai_test_select_empty": "No models configured. Add models first",
"openai_test_single_action": "Test",
"openai_test_all_action": "Test All Keys",
"openai_test_all_hint": "Test connection status for all keys",
"openai_test_all_success": "All {{count}} keys passed the test",
"openai_test_all_failed": "All {{count}} keys failed the test",
"openai_test_all_partial": "Test completed: {{success}} passed, {{failed}} failed"
},
"auth_files": {
"title": "Auth Files Management",
@@ -434,6 +443,26 @@
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All"
},
"claude_quota": {
"title": "Claude Quota",
"empty_title": "No Claude OAuth Files",
"empty_desc": "Log in with Claude OAuth to view quota.",
"idle": "Click here to refresh quota",
"loading": "Loading quota...",
"load_failed": "Failed to load quota: {{message}}",
"missing_auth_index": "Auth file missing auth_index",
"empty_windows": "No quota data available",
"refresh_button": "Refresh Quota",
"fetch_all": "Fetch All",
"five_hour": "5-hour limit",
"seven_day": "7-day limit",
"seven_day_oauth_apps": "7-day OAuth apps",
"seven_day_opus": "7-day Opus",
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
},
"codex_quota": {
"title": "Codex Quota",
"empty_title": "No Codex Auth Files",

View File

@@ -38,13 +38,16 @@
"quota_update_required": "Пожалуйста, обновите CPA или проверьте наличие обновлений",
"quota_check_credential": "Пожалуйста, проверьте статус учётных данных",
"copy": "Копировать",
"status": "Статус",
"action": "Действие",
"custom_headers_label": "Пользовательские заголовки",
"custom_headers_hint": "Необязательно — HTTP-заголовки для отправки с запросом. Оставьте пустым для удаления.",
"custom_headers_add": "Добавить заголовок",
"custom_headers_key_placeholder": "Имя заголовка, например X-Custom-Header",
"custom_headers_value_placeholder": "Значение заголовка",
"model_name_placeholder": "Имя модели, напр. claude-3-5-sonnet-20241022",
"model_alias_placeholder": "Псевдоним модели (необязательно)"
"model_alias_placeholder": "Псевдоним модели (необязательно)",
"invalid_provider_index": "Неверный индекс провайдера."
},
"title": {
"main": "Центр управления CLI Proxy API",
@@ -333,7 +336,13 @@
"openai_test_success": "Тест выполнен успешно. Модель ответила.",
"openai_test_failed": "Тест не выполнен",
"openai_test_select_placeholder": "Выберите из текущих моделей",
"openai_test_select_empty": "Модели не настроены. Сначала добавьте модели"
"openai_test_select_empty": "Модели не настроены. Сначала добавьте модели",
"openai_test_single_action": "Тест",
"openai_test_all_action": "Тестировать все ключи",
"openai_test_all_hint": "Проверить состояние подключения для всех ключей",
"openai_test_all_success": "Все {{count}} ключей прошли тест",
"openai_test_all_failed": "Все {{count}} ключей не прошли тест",
"openai_test_all_partial": "Тест завершен: {{success}} прошло, {{failed}} не прошло"
},
"auth_files": {
"title": "Управление файлами авторизации",
@@ -437,6 +446,26 @@
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все"
},
"claude_quota": {
"title": "Квота Claude",
"empty_title": "Файлы авторизации Claude OAuth отсутствуют",
"empty_desc": "Войдите через Claude OAuth, чтобы увидеть квоту.",
"idle": "Не загружено. Нажмите \"Обновить квоту\".",
"loading": "Загрузка квоты...",
"load_failed": "Не удалось загрузить квоту: {{message}}",
"missing_auth_index": "В файле авторизации отсутствует auth_index",
"empty_windows": "Данные по квоте отсутствуют",
"refresh_button": "Обновить квоту",
"fetch_all": "Получить все",
"five_hour": "Лимит на 5 часов",
"seven_day": "Лимит на 7 дней",
"seven_day_oauth_apps": "7 дней OAuth приложения",
"seven_day_opus": "7 дней Opus",
"seven_day_sonnet": "7 дней Sonnet",
"seven_day_cowork": "7 дней Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Дополнительное использование"
},
"codex_quota": {
"title": "Квота Codex",
"empty_title": "Файлы авторизации Codex отсутствуют",

View File

@@ -38,13 +38,16 @@
"quota_update_required": "请更新 CPA 版本或检查更新",
"quota_check_credential": "请检查凭证状态",
"copy": "复制",
"status": "状态",
"action": "操作",
"custom_headers_label": "自定义请求头",
"custom_headers_hint": "可选,设置需要附带到请求中的 HTTP 头,名称和值均不能为空。",
"custom_headers_add": "添加请求头",
"custom_headers_key_placeholder": "Header 名称,例如 X-Custom-Header",
"custom_headers_value_placeholder": "Header 值",
"model_name_placeholder": "模型名称,例如 claude-3-5-sonnet-20241022",
"model_alias_placeholder": "模型别名 (可选)"
"model_alias_placeholder": "模型别名 (可选)",
"invalid_provider_index": "无效的提供商索引。"
},
"title": {
"main": "CLI Proxy API Management Center",
@@ -333,7 +336,13 @@
"openai_test_success": "测试成功,模型可用。",
"openai_test_failed": "测试失败",
"openai_test_select_placeholder": "从当前模型列表选择",
"openai_test_select_empty": "当前未配置模型,请先添加模型"
"openai_test_select_empty": "当前未配置模型,请先添加模型",
"openai_test_single_action": "测试",
"openai_test_all_action": "一键测试全部密钥",
"openai_test_all_hint": "测试所有密钥的连接状态",
"openai_test_all_success": "所有 {{count}} 个密钥测试通过",
"openai_test_all_failed": "所有 {{count}} 个密钥测试失败",
"openai_test_all_partial": "测试完成:{{success}} 个通过,{{failed}} 个失败"
},
"auth_files": {
"title": "认证文件管理",
@@ -434,6 +443,26 @@
"refresh_button": "刷新额度",
"fetch_all": "获取全部"
},
"claude_quota": {
"title": "Claude 额度",
"empty_title": "暂无 Claude OAuth 认证",
"empty_desc": "使用 Claude OAuth 登录后即可查看额度。",
"idle": "点击此处刷新额度",
"loading": "正在加载额度...",
"load_failed": "额度获取失败:{{message}}",
"missing_auth_index": "认证文件缺少 auth_index",
"empty_windows": "暂无额度数据",
"refresh_button": "刷新额度",
"fetch_all": "获取全部",
"five_hour": "5 小时限额",
"seven_day": "7 天限额",
"seven_day_oauth_apps": "7 天 OAuth 应用",
"seven_day_opus": "7 天 Opus",
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
},
"codex_quota": {
"title": "Codex 额度",
"empty_title": "暂无 Codex 认证",

View File

@@ -302,6 +302,8 @@ export function AiProvidersAmpcodeEditPage() {
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
namePlaceholder={t('ai_providers.ampcode_model_mappings_from_placeholder')}
aliasPlaceholder={t('ai_providers.ampcode_model_mappings_to_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={loading || saving || disableControls}
/>
<div className="hint">{t('ai_providers.ampcode_model_mappings_hint')}</div>

View File

@@ -210,7 +210,7 @@ export function AiProvidersClaudeEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -245,6 +245,8 @@ export function AiProvidersClaudeEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">
@@ -255,6 +257,8 @@ export function AiProvidersClaudeEditPage() {
addLabel={t('ai_providers.claude_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
</div>

View File

@@ -210,7 +210,7 @@ export function AiProvidersCodexEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -245,6 +245,8 @@ export function AiProvidersCodexEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">

View File

@@ -193,7 +193,7 @@ export function AiProvidersGeminiEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -224,6 +224,8 @@ export function AiProvidersGeminiEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">

View File

@@ -10,6 +10,7 @@ import type { ModelInfo } from '@/utils/models';
import { buildHeaderObject, headersToEntries } from '@/utils/headers';
import { buildApiKeyEntry } from '@/components/providers/utils';
import type { ModelEntry, OpenAIFormState } from '@/components/providers/types';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
type LocationState = { fromAiProviders?: boolean } | null;
@@ -29,6 +30,9 @@ export type OpenAIEditOutletContext = {
setTestStatus: Dispatch<SetStateAction<'idle' | 'loading' | 'success' | 'error'>>;
testMessage: string;
setTestMessage: Dispatch<SetStateAction<string>>;
keyTestStatuses: KeyTestStatus[];
setDraftKeyTestStatus: (keyIndex: number, status: KeyTestStatus) => void;
resetDraftKeyTestStatuses: (count: number) => void;
availableModels: string[];
handleBack: () => void;
handleSave: () => Promise<void>;
@@ -99,11 +103,14 @@ export function AiProvidersOpenAIEditLayout() {
const setDraftTestModel = useOpenAIEditDraftStore((state) => state.setDraftTestModel);
const setDraftTestStatus = useOpenAIEditDraftStore((state) => state.setDraftTestStatus);
const setDraftTestMessage = useOpenAIEditDraftStore((state) => state.setDraftTestMessage);
const setDraftKeyTestStatus = useOpenAIEditDraftStore((state) => state.setDraftKeyTestStatus);
const resetDraftKeyTestStatuses = useOpenAIEditDraftStore((state) => state.resetDraftKeyTestStatuses);
const form = draft?.form ?? buildEmptyForm();
const testModel = draft?.testModel ?? '';
const testStatus = draft?.testStatus ?? 'idle';
const testMessage = draft?.testMessage ?? '';
const keyTestStatuses = draft?.keyTestStatuses ?? [];
const setForm: Dispatch<SetStateAction<OpenAIFormState>> = useCallback(
(action) => {
@@ -134,6 +141,20 @@ export function AiProvidersOpenAIEditLayout() {
[draftKey, setDraftTestMessage]
);
const handleSetDraftKeyTestStatus = useCallback(
(keyIndex: number, status: KeyTestStatus) => {
setDraftKeyTestStatus(draftKey, keyIndex, status);
},
[draftKey, setDraftKeyTestStatus]
);
const handleResetDraftKeyTestStatuses = useCallback(
(count: number) => {
resetDraftKeyTestStatuses(draftKey, count);
},
[draftKey, resetDraftKeyTestStatuses]
);
const initialData = useMemo(() => {
if (editIndex === null) return undefined;
return providers[editIndex];
@@ -215,6 +236,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: initialTestModel,
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
} else {
initDraft(draftKey, {
@@ -222,6 +244,7 @@ export function AiProvidersOpenAIEditLayout() {
testModel: '',
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
}
}, [draft?.initialized, draftKey, initDraft, initialData, loading]);
@@ -280,12 +303,20 @@ export function AiProvidersOpenAIEditLayout() {
);
const handleSave = useCallback(async () => {
const name = form.name.trim();
const baseUrl = form.baseUrl.trim();
if (!name || !baseUrl) {
showNotification(t('notification.openai_provider_required'), 'error');
return;
}
setSaving(true);
try {
const payload: OpenAIProviderConfig = {
name: form.name.trim(),
name,
prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl.trim(),
baseUrl,
headers: buildHeaderObject(form.headers),
apiKeyEntries: form.apiKeyEntries.map((entry: ApiKeyEntry) => ({
apiKey: entry.apiKey.trim(),
@@ -351,6 +382,9 @@ export function AiProvidersOpenAIEditLayout() {
setTestStatus,
testMessage,
setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus: handleSetDraftKeyTestStatus,
resetDraftKeyTestStatuses: handleResetDraftKeyTestStatuses,
availableModels,
handleBack,
handleSave,

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useCallback, useMemo, useRef, useState } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
@@ -14,6 +14,7 @@ import type { ApiKeyEntry } from '@/types';
import { buildHeaderObject } from '@/utils/headers';
import { buildApiKeyEntry, buildOpenAIChatCompletionsEndpoint } from '@/components/providers/utils';
import type { OpenAIEditOutletContext } from './AiProvidersOpenAIEditLayout';
import type { KeyTestStatus } from '@/stores/useOpenAIEditDraftStore';
import styles from './AiProvidersPage.module.scss';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -25,6 +26,72 @@ const getErrorMessage = (err: unknown) => {
return '';
};
// Status icon components
function StatusLoadingIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className={styles.statusIconSpin}>
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
<path
d="M8 1A7 7 0 0 1 8 15"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function StatusSuccessIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--success-color, #22c55e)" />
<path
d="M4.5 8L7 10.5L11.5 6"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusErrorIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="8" fill="var(--danger-color, #ef4444)" />
<path
d="M5 5L11 11M11 5L5 11"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function StatusIdleIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="7" stroke="var(--text-tertiary, #9ca3af)" strokeWidth="2" />
</svg>
);
}
function StatusIcon({ status }: { status: KeyTestStatus['status'] }) {
switch (status) {
case 'loading':
return <StatusLoadingIcon />;
case 'success':
return <StatusSuccessIcon />;
case 'error':
return <StatusErrorIcon />;
default:
return <StatusIdleIcon />;
}
}
export function AiProvidersOpenAIEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -44,6 +111,9 @@ export function AiProvidersOpenAIEditPage() {
setTestStatus,
testMessage,
setTestMessage,
keyTestStatuses,
setDraftKeyTestStatus,
resetDraftKeyTestStatuses,
availableModels,
handleBack,
handleSave,
@@ -54,6 +124,7 @@ export function AiProvidersOpenAIEditPage() {
: t('ai_providers.openai_add_modal_title');
const swipeRef = useEdgeSwipeBack({ onBack: handleBack });
const [isTestingKeys, setIsTestingKeys] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -65,80 +136,131 @@ export function AiProvidersOpenAIEditPage() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleBack]);
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex;
const canSave = !disableControls && !loading && !saving && !invalidIndexParam && !invalidIndex && !isTestingKeys;
const hasConfiguredModels = form.modelEntries.some((entry) => entry.name.trim());
const hasTestableKeys = form.apiKeyEntries.some((entry) => entry.apiKey?.trim());
const connectivityConfigSignature = useMemo(() => {
const headersSignature = form.headers
.map((entry) => `${entry.key.trim()}:${entry.value.trim()}`)
.join('|');
const modelsSignature = form.modelEntries
.map((entry) => `${entry.name.trim()}:${entry.alias.trim()}`)
.join('|');
return [form.baseUrl.trim(), testModel.trim(), headersSignature, modelsSignature].join('||');
}, [form.baseUrl, form.headers, form.modelEntries, testModel]);
const previousConnectivityConfigRef = useRef(connectivityConfigSignature);
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
};
return (
<div className="stack">
{list.map((entry, index) => (
<div key={index} className="item-row">
<div className="item-meta">
<Input
label={`${t('common.api_key')} #${index + 1}`}
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
disabled={saving || disableControls}
/>
<Input
label={t('common.proxy_url')}
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls}
/>
</div>
<div className="item-actions">
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
))}
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls}
>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
);
};
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
useEffect(() => {
if (previousConnectivityConfigRef.current === connectivityConfigSignature) {
return;
}
navigate('models');
};
previousConnectivityConfigRef.current = connectivityConfigSignature;
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
setTestStatus('idle');
setTestMessage('');
}, [
connectivityConfigSignature,
form.apiKeyEntries.length,
resetDraftKeyTestStatuses,
setTestStatus,
setTestMessage,
]);
// Test a single key by index
const runSingleKeyTest = useCallback(
async (keyIndex: number): Promise<boolean> => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const endpoint = buildOpenAIChatCompletionsEndpoint(baseUrl);
if (!endpoint) {
showNotification(t('notification.openai_test_url_required'), 'error');
return false;
}
const keyEntry = form.apiKeyEntries[keyIndex];
if (!keyEntry?.apiKey?.trim()) {
setDraftKeyTestStatus(keyIndex, { status: 'error', message: t('notification.openai_test_key_required') });
return false;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
showNotification(t('notification.openai_test_model_required'), 'error');
return false;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${keyEntry.apiKey.trim()}`;
}
// Set loading state for this key
setDraftKeyTestStatus(keyIndex, { status: 'loading', message: '' });
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
setDraftKeyTestStatus(keyIndex, { status: 'success', message: '' });
return true;
} catch (err: unknown) {
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: string }).code)
: '';
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
const errorMessage = isTimeout
? t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
: message;
setDraftKeyTestStatus(keyIndex, { status: 'error', message: errorMessage });
return false;
}
},
[form.baseUrl, form.apiKeyEntries, form.headers, testModel, availableModels, t, setDraftKeyTestStatus, showNotification]
);
const testSingleKey = useCallback(
async (keyIndex: number): Promise<boolean> => {
if (isTestingKeys) return false;
setIsTestingKeys(true);
try {
return await runSingleKeyTest(keyIndex);
} finally {
setIsTestingKeys(false);
}
},
[isTestingKeys, runSingleKeyTest]
);
// Test all keys
const testAllKeys = useCallback(async () => {
if (isTestingKeys) return;
const testOpenaiProviderConnection = async () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
const message = t('notification.openai_test_url_required');
@@ -157,15 +279,6 @@ export function AiProvidersOpenAIEditPage() {
return;
}
const firstKeyEntry = form.apiKeyEntries.find((entry) => entry.apiKey?.trim());
if (!firstKeyEntry) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
const modelName = testModel.trim() || availableModels[0] || '';
if (!modelName) {
const message = t('notification.openai_test_model_required');
@@ -175,56 +288,194 @@ export function AiProvidersOpenAIEditPage() {
return;
}
const customHeaders = buildHeaderObject(form.headers);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...customHeaders,
};
if (!headers.Authorization && !headers['authorization']) {
headers.Authorization = `Bearer ${firstKeyEntry.apiKey.trim()}`;
const validKeyIndexes = form.apiKeyEntries
.map((entry, index) => (entry.apiKey?.trim() ? index : -1))
.filter((index) => index >= 0);
if (validKeyIndexes.length === 0) {
const message = t('notification.openai_test_key_required');
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
return;
}
setIsTestingKeys(true);
setTestStatus('loading');
setTestMessage(t('ai_providers.openai_test_running'));
resetDraftKeyTestStatuses(form.apiKeyEntries.length);
try {
const result = await apiCallApi.request(
{
method: 'POST',
url: endpoint,
header: Object.keys(headers).length ? headers : undefined,
data: JSON.stringify({
model: modelName,
messages: [{ role: 'user', content: 'Hi' }],
stream: false,
max_tokens: 5,
}),
},
{ timeout: OPENAI_TEST_TIMEOUT_MS }
);
const results = await Promise.all(validKeyIndexes.map((index) => runSingleKeyTest(index)));
if (result.statusCode < 200 || result.statusCode >= 300) {
throw new Error(getApiCallErrorMessage(result));
}
const successCount = results.filter(Boolean).length;
const failCount = validKeyIndexes.length - successCount;
setTestStatus('success');
setTestMessage(t('ai_providers.openai_test_success'));
} catch (err: unknown) {
setTestStatus('error');
const message = getErrorMessage(err);
const errorCode =
typeof err === 'object' && err !== null && 'code' in err
? String((err as { code?: string }).code)
: '';
const isTimeout = errorCode === 'ECONNABORTED' || message.toLowerCase().includes('timeout');
if (isTimeout) {
setTestMessage(
t('ai_providers.openai_test_timeout', { seconds: OPENAI_TEST_TIMEOUT_MS / 1000 })
);
if (failCount === 0) {
const message = t('ai_providers.openai_test_all_success', { count: successCount });
setTestStatus('success');
setTestMessage(message);
showNotification(message, 'success');
} else if (successCount === 0) {
const message = t('ai_providers.openai_test_all_failed', { count: failCount });
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'error');
} else {
setTestMessage(`${t('ai_providers.openai_test_failed')}: ${message}`);
const message = t('ai_providers.openai_test_all_partial', { success: successCount, failed: failCount });
setTestStatus('error');
setTestMessage(message);
showNotification(message, 'warning');
}
} finally {
setIsTestingKeys(false);
}
}, [
isTestingKeys,
form.baseUrl,
form.apiKeyEntries,
testModel,
availableModels,
t,
setTestStatus,
setTestMessage,
resetDraftKeyTestStatuses,
runSingleKeyTest,
showNotification,
]);
const openOpenaiModelDiscovery = () => {
const baseUrl = form.baseUrl.trim();
if (!baseUrl) {
showNotification(t('ai_providers.openai_models_fetch_invalid_url'), 'error');
return;
}
navigate('models');
};
const renderKeyEntries = (entries: ApiKeyEntry[]) => {
const list = entries.length ? entries : [buildApiKeyEntry()];
const updateEntry = (idx: number, field: keyof ApiKeyEntry, value: string) => {
const next = list.map((entry, i) => (i === idx ? { ...entry, [field]: value } : entry));
setForm((prev) => ({ ...prev, apiKeyEntries: next }));
setDraftKeyTestStatus(idx, { status: 'idle', message: '' });
setTestStatus('idle');
setTestMessage('');
};
const removeEntry = (idx: number) => {
const next = list.filter((_, i) => i !== idx);
const nextLength = next.length ? next.length : 1;
setForm((prev) => ({
...prev,
apiKeyEntries: next.length ? next : [buildApiKeyEntry()],
}));
resetDraftKeyTestStatuses(nextLength);
setTestStatus('idle');
setTestMessage('');
};
const addEntry = () => {
setForm((prev) => ({ ...prev, apiKeyEntries: [...list, buildApiKeyEntry()] }));
resetDraftKeyTestStatuses(list.length + 1);
setTestStatus('idle');
setTestMessage('');
};
return (
<div className={styles.keyEntriesList}>
<div className={styles.keyEntriesToolbar}>
<span className={styles.keyEntriesCount}>
{t('ai_providers.openai_keys_count')}: {list.length}
</span>
<Button
variant="secondary"
size="sm"
onClick={addEntry}
disabled={saving || disableControls || isTestingKeys}
className={styles.addKeyButton}
>
{t('ai_providers.openai_keys_add_btn')}
</Button>
</div>
<div className={styles.keyTableShell}>
{/* 表头 */}
<div className={styles.keyTableHeader}>
<div className={styles.keyTableColIndex}>#</div>
<div className={styles.keyTableColStatus}>{t('common.status')}</div>
<div className={styles.keyTableColKey}>{t('common.api_key')}</div>
<div className={styles.keyTableColProxy}>{t('common.proxy_url')}</div>
<div className={styles.keyTableColAction}>{t('common.action')}</div>
</div>
{/* 数据行 */}
{list.map((entry, index) => {
const keyStatus = keyTestStatuses[index]?.status ?? 'idle';
const canTestKey = Boolean(entry.apiKey?.trim()) && hasConfiguredModels;
return (
<div key={index} className={styles.keyTableRow}>
{/* 序号 */}
<div className={styles.keyTableColIndex}>{index + 1}</div>
{/* 状态指示灯 */}
<div
className={styles.keyTableColStatus}
title={keyTestStatuses[index]?.message || ''}
>
<StatusIcon status={keyStatus} />
</div>
{/* Key 输入框 */}
<div className={styles.keyTableColKey}>
<input
type="text"
value={entry.apiKey}
onChange={(e) => updateEntry(index, 'apiKey', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_key_placeholder')}
/>
</div>
{/* Proxy 输入框 */}
<div className={styles.keyTableColProxy}>
<input
type="text"
value={entry.proxyUrl ?? ''}
onChange={(e) => updateEntry(index, 'proxyUrl', e.target.value)}
disabled={saving || disableControls || isTestingKeys}
className={`input ${styles.keyTableInput}`}
placeholder={t('ai_providers.openai_proxy_placeholder')}
/>
</div>
{/* 操作按钮 */}
<div className={styles.keyTableColAction}>
<Button
variant="secondary"
size="sm"
onClick={() => void testSingleKey(index)}
disabled={saving || disableControls || isTestingKeys || !canTestKey}
loading={keyStatus === 'loading'}
>
{t('ai_providers.openai_test_single_action')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => removeEntry(index)}
disabled={saving || disableControls || isTestingKeys || list.length <= 1}
>
{t('common.delete')}
</Button>
</div>
</div>
);
})}
</div>
</div>
);
};
return (
@@ -245,14 +496,14 @@ export function AiProvidersOpenAIEditPage() {
>
<Card>
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
label={t('ai_providers.openai_add_modal_name_label')}
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.prefix_label')}
@@ -260,13 +511,13 @@ export function AiProvidersOpenAIEditPage() {
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<Input
label={t('ai_providers.openai_add_modal_url_label')}
value={form.baseUrl}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
/>
<HeaderInputList
@@ -275,77 +526,109 @@ export function AiProvidersOpenAIEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
disabled={saving || disableControls}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={saving || disableControls || isTestingKeys}
/>
<div className="form-group">
<label>
{hasIndexParam
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
{/* 模型配置区域 - 统一布局 */}
<div className={styles.modelConfigSection}>
{/* 标题行 */}
<div className={styles.modelConfigHeader}>
<label className={styles.modelConfigTitle}>
{hasIndexParam
? t('ai_providers.openai_edit_modal_models_label')
: t('ai_providers.openai_add_modal_models_label')}
</label>
<div className={styles.modelConfigToolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => setForm((prev) => ({
...prev,
modelEntries: [...prev.modelEntries, { name: '', alias: '' }]
}))}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_add_btn')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls || isTestingKeys}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
</div>
{/* 提示文本 */}
<div className="hint">{t('ai_providers.openai_models_hint')}</div>
{/* 模型列表 */}
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.openai_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={saving || disableControls}
disabled={saving || disableControls || isTestingKeys}
hideAddButton
className={styles.modelInputList}
rowClassName={styles.modelInputRow}
inputClassName={styles.modelInputField}
removeButtonClassName={styles.modelRowRemoveButton}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
/>
<Button
variant="secondary"
size="sm"
onClick={openOpenaiModelDiscovery}
disabled={saving || disableControls}
>
{t('ai_providers.openai_models_fetch_button')}
</Button>
</div>
<div className="form-group">
<label>{t('ai_providers.openai_test_title')}</label>
<div className="hint">{t('ai_providers.openai_test_hint')}</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={saving || disableControls || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
className={`${styles.openaiTestButton} ${
testStatus === 'success' ? styles.openaiTestButtonSuccess : ''
}`}
onClick={() => void testOpenaiProviderConnection()}
loading={testStatus === 'loading'}
disabled={saving || disableControls || availableModels.length === 0}
>
{t('ai_providers.openai_test_action')}
</Button>
{/* 测试区域 */}
<div className={styles.modelTestPanel}>
<div className={styles.modelTestMeta}>
<label className={styles.modelTestLabel}>{t('ai_providers.openai_test_title')}</label>
<span className={styles.modelTestHint}>{t('ai_providers.openai_test_hint')}</span>
</div>
<div className={styles.modelTestControls}>
<select
className={`input ${styles.openaiTestSelect}`}
value={testModel}
onChange={(e) => {
setTestModel(e.target.value);
setTestStatus('idle');
setTestMessage('');
}}
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || availableModels.length === 0}
>
<option value="">
{availableModels.length
? t('ai_providers.openai_test_select_placeholder')
: t('ai_providers.openai_test_select_empty')}
</option>
{form.modelEntries
.filter((entry) => entry.name.trim())
.map((entry, idx) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
const label = alias && alias !== name ? `${name} (${alias})` : name;
return (
<option key={`${name}-${idx}`} value={name}>
{label}
</option>
);
})}
</select>
<Button
variant={testStatus === 'error' ? 'danger' : 'secondary'}
size="sm"
onClick={() => void testAllKeys()}
loading={testStatus === 'loading'}
disabled={saving || disableControls || isTestingKeys || testStatus === 'loading' || !hasConfiguredModels || !hasTestableKeys}
title={t('ai_providers.openai_test_all_hint')}
className={styles.modelTestAllButton}
>
{t('ai_providers.openai_test_all_action')}
</Button>
</div>
</div>
{testMessage && (
<div
@@ -362,8 +645,11 @@ export function AiProvidersOpenAIEditPage() {
)}
</div>
<div className="form-group">
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
<div className={`form-group ${styles.keyEntriesSection}`}>
<div className={styles.keyEntriesHeader}>
<label>{t('ai_providers.openai_add_modal_keys_label')}</label>
<span className={styles.keyEntriesHint}>{t('ai_providers.openai_keys_hint')}</span>
</div>
{renderKeyEntries(form.apiKeyEntries)}
</div>
</>

View File

@@ -387,19 +387,6 @@
}
}
// 连通性测试按钮高度对齐
.openaiTestSelect {
flex: 1 1 0;
min-width: 0;
}
.openaiTestButton {
flex: 1 1 0;
padding: 8px 12px;
font-size: 14px;
line-height: 1.5;
}
// 状态监测栏
.statusBar {
display: flex;
@@ -473,6 +460,318 @@
background: var(--failure-badge-bg, #fee2e2);
}
// ============================================
// Model Config Section - Unified Layout
// ============================================
.modelConfigSection {
margin-bottom: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.modelConfigHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
flex-wrap: wrap;
@include mobile {
align-items: flex-start;
}
}
.modelConfigTitle {
font-weight: 600;
color: var(--text-primary);
font-size: 14px;
line-height: 1.4;
}
.modelConfigToolbar {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-wrap: wrap;
justify-content: flex-end;
@include mobile {
width: 100%;
justify-content: flex-start;
}
:global(.btn) {
white-space: nowrap;
}
}
.modelInputList {
gap: $spacing-xs;
}
.modelInputRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: $spacing-sm;
align-items: center;
@include mobile {
grid-template-columns: minmax(0, 1fr) auto;
row-gap: $spacing-xs;
> :nth-child(2) {
display: none;
}
> :nth-child(3) {
grid-column: 1 / 3;
}
> :nth-child(4) {
grid-column: 2 / 3;
grid-row: 1 / 2;
}
}
}
.modelInputField {
min-width: 0;
}
.modelRowRemoveButton {
justify-self: center;
}
.modelTestPanel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: $spacing-md;
margin-top: $spacing-sm;
padding: $spacing-sm $spacing-md;
border: 1px solid var(--border-color);
border-radius: $radius-md;
background: var(--bg-secondary);
@include mobile {
flex-direction: column;
align-items: stretch;
}
}
.modelTestMeta {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.modelTestLabel {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
line-height: 1.4;
}
.modelTestHint {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.4;
}
.modelTestControls {
display: flex;
align-items: center;
justify-content: flex-end;
gap: $spacing-xs;
flex: 1;
min-width: 0;
@include mobile {
justify-content: flex-start;
}
}
// ============================================
// Key Entry Styles - Table Design
// ============================================
.keyEntriesSection {
margin-bottom: 0;
}
.keyEntriesHeader {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: $spacing-sm;
label {
margin: 0;
}
}
.keyEntriesHint {
font-size: 13px;
line-height: 1.4;
color: var(--text-secondary);
}
.keyEntriesList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.keyEntriesToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-sm;
flex-wrap: wrap;
}
.keyEntriesCount {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.keyTableShell {
overflow-x: auto;
border-radius: $radius-md;
}
// 表头
.keyTableHeader {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-bottom: none;
border-radius: $radius-md $radius-md 0 0;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: none;
align-items: center;
text-align: center;
}
// 数据行
.keyTableRow {
display: grid;
grid-template-columns: 46px 56px minmax(220px, 1.4fr) minmax(200px, 1.1fr) 180px;
gap: $spacing-sm;
min-width: 760px;
padding: 10px $spacing-md;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-top: none;
align-items: center;
&:last-child {
border-radius: 0 0 $radius-md $radius-md;
}
&:hover {
background: var(--bg-tertiary);
}
}
// 列定义
.keyTableColIndex {
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--text-tertiary);
}
.keyTableColStatus {
display: flex;
align-items: center;
justify-content: center;
svg {
display: block;
}
}
.keyTableColKey,
.keyTableColProxy {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
}
.keyTableColAction {
display: flex;
align-items: center;
justify-content: center;
gap: $spacing-xs;
flex-shrink: 0;
white-space: nowrap;
}
.keyTableInput {
width: 100%;
padding: 8px 10px;
font-size: 14px;
min-height: 38px;
text-align: center;
}
.addKeyButton {
align-self: auto;
margin-top: 0;
}
.openaiTestSelect {
flex: 1 1 260px;
min-width: 180px;
max-width: 380px;
@include mobile {
min-width: 0;
max-width: none;
}
}
.modelTestAllButton {
white-space: nowrap;
flex-shrink: 0;
}
.statusIconWrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--text-secondary);
flex-shrink: 0;
}
.statusIconSpin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 暗色主题适配
:global([data-theme='dark']) {
.headerBadge {

View File

@@ -218,7 +218,7 @@ export function AiProvidersVertexEditPage() {
<Card>
{error && <div className="error-box">{error}</div>}
{invalidIndexParam || invalidIndex ? (
<div className="hint">Invalid provider index.</div>
<div className="hint">{t('common.invalid_provider_index')}</div>
) : (
<>
<Input
@@ -256,6 +256,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="form-group">
@@ -266,6 +268,8 @@ export function AiProvidersVertexEditPage() {
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
removeButtonTitle={t('common.delete')}
removeButtonAriaLabel={t('common.delete')}
disabled={disableControls || saving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>

View File

@@ -103,6 +103,7 @@
}
.antigravityGrid,
.claudeGrid,
.codexGrid,
.geminiCliGrid {
display: grid;
@@ -115,6 +116,7 @@
}
.antigravityControls,
.claudeControls,
.codexControls,
.geminiCliControls {
display: flex;
@@ -125,6 +127,7 @@
}
.antigravityControl,
.claudeControl,
.codexControl,
.geminiCliControl {
display: flex;
@@ -145,6 +148,12 @@
align-items: center;
}
.claudeCard {
background-image: linear-gradient(180deg,
rgba(252, 228, 236, 0.18),
rgba(252, 228, 236, 0));
}
.antigravityCard {
background-image: linear-gradient(180deg,
rgba(224, 247, 250, 0.12),

View File

@@ -10,6 +10,7 @@ import { authFilesApi, configFileApi } from '@/services/api';
import {
QuotaSection,
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG
} from '@/components/quota';
@@ -69,6 +70,12 @@ export function QuotaPage() {
{error && <div className={styles.errorBox}>{error}</div>}
<QuotaSection
config={CLAUDE_CONFIG}
files={files}
loading={loading}
disabled={disableControls}
/>
<QuotaSection
config={ANTIGRAVITY_CONFIG}
files={files}

View File

@@ -15,12 +15,18 @@ import { buildApiKeyEntry } from '@/components/providers/utils';
export type OpenAITestStatus = 'idle' | 'loading' | 'success' | 'error';
export type KeyTestStatus = {
status: OpenAITestStatus;
message: string;
};
export type OpenAIEditDraft = {
initialized: boolean;
form: OpenAIFormState;
testModel: string;
testStatus: OpenAITestStatus;
testMessage: string;
keyTestStatuses: KeyTestStatus[];
};
interface OpenAIEditDraftState {
@@ -31,6 +37,8 @@ interface OpenAIEditDraftState {
setDraftTestModel: (key: string, action: SetStateAction<string>) => void;
setDraftTestStatus: (key: string, action: SetStateAction<OpenAITestStatus>) => void;
setDraftTestMessage: (key: string, action: SetStateAction<string>) => void;
setDraftKeyTestStatus: (draftKey: string, keyIndex: number, status: KeyTestStatus) => void;
resetDraftKeyTestStatuses: (draftKey: string, count: number) => void;
clearDraft: (key: string) => void;
}
@@ -53,6 +61,7 @@ const buildEmptyDraft = (): OpenAIEditDraft => ({
testModel: '',
testStatus: 'idle',
testMessage: '',
keyTestStatuses: [],
});
export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) => ({
@@ -135,6 +144,38 @@ export const useOpenAIEditDraftStore = create<OpenAIEditDraftState>((set, get) =
});
},
setDraftKeyTestStatus: (draftKey, keyIndex, status) => {
if (!draftKey) return;
set((state) => {
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
const nextStatuses = [...existing.keyTestStatuses];
nextStatuses[keyIndex] = status;
return {
drafts: {
...state.drafts,
[draftKey]: { ...existing, initialized: true, keyTestStatuses: nextStatuses },
},
};
});
},
resetDraftKeyTestStatuses: (draftKey, count) => {
if (!draftKey) return;
set((state) => {
const existing = state.drafts[draftKey] ?? buildEmptyDraft();
return {
drafts: {
...state.drafts,
[draftKey]: {
...existing,
initialized: true,
keyTestStatuses: Array.from({ length: count }, () => ({ status: 'idle', message: '' })),
},
},
};
});
},
clearDraft: (key) => {
if (!key) return;
set((state) => {

View File

@@ -3,15 +3,17 @@
*/
import { create } from 'zustand';
import type { AntigravityQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
import type { AntigravityQuotaState, ClaudeQuotaState, CodexQuotaState, GeminiCliQuotaState } from '@/types';
type QuotaUpdater<T> = T | ((prev: T) => T);
interface QuotaStoreState {
antigravityQuota: Record<string, AntigravityQuotaState>;
claudeQuota: Record<string, ClaudeQuotaState>;
codexQuota: Record<string, CodexQuotaState>;
geminiCliQuota: Record<string, GeminiCliQuotaState>;
setAntigravityQuota: (updater: QuotaUpdater<Record<string, AntigravityQuotaState>>) => void;
setClaudeQuota: (updater: QuotaUpdater<Record<string, ClaudeQuotaState>>) => void;
setCodexQuota: (updater: QuotaUpdater<Record<string, CodexQuotaState>>) => void;
setGeminiCliQuota: (updater: QuotaUpdater<Record<string, GeminiCliQuotaState>>) => void;
clearQuotaCache: () => void;
@@ -26,12 +28,17 @@ const resolveUpdater = <T,>(updater: QuotaUpdater<T>, prev: T): T => {
export const useQuotaStore = create<QuotaStoreState>((set) => ({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {},
setAntigravityQuota: (updater) =>
set((state) => ({
antigravityQuota: resolveUpdater(updater, state.antigravityQuota)
})),
setClaudeQuota: (updater) =>
set((state) => ({
claudeQuota: resolveUpdater(updater, state.claudeQuota)
})),
setCodexQuota: (updater) =>
set((state) => ({
codexQuota: resolveUpdater(updater, state.codexQuota)
@@ -43,6 +50,7 @@ export const useQuotaStore = create<QuotaStoreState>((set) => ({
clearQuotaCache: () =>
set({
antigravityQuota: {},
claudeQuota: {},
codexQuota: {},
geminiCliQuota: {}
})

View File

@@ -97,6 +97,46 @@ export interface CodexUsagePayload {
codeReviewRateLimit?: CodexRateLimitInfo | null;
}
// Claude API payload types
export interface ClaudeUsageWindow {
utilization: number;
resets_at: string;
}
export interface ClaudeExtraUsage {
is_enabled: boolean;
monthly_limit: number;
used_credits: number;
utilization: number | null;
}
export interface ClaudeUsagePayload {
five_hour?: ClaudeUsageWindow | null;
seven_day?: ClaudeUsageWindow | null;
seven_day_oauth_apps?: ClaudeUsageWindow | null;
seven_day_opus?: ClaudeUsageWindow | null;
seven_day_sonnet?: ClaudeUsageWindow | null;
seven_day_cowork?: ClaudeUsageWindow | null;
iguana_necktie?: ClaudeUsageWindow | null;
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
labelKey?: string;
usedPercent: number | null;
resetLabel: string;
}
export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
error?: string;
errorStatus?: number;
}
// Quota state types
export interface AntigravityQuotaGroup {
id: string;

View File

@@ -151,6 +151,25 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
// Claude API configuration
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
export const CLAUDE_REQUEST_HEADERS = {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
};
export const CLAUDE_USAGE_WINDOW_KEYS = [
{ key: 'five_hour', id: 'five-hour', labelKey: 'claude_quota.five_hour' },
{ key: 'seven_day', id: 'seven-day', labelKey: 'claude_quota.seven_day' },
{ key: 'seven_day_oauth_apps', id: 'seven-day-oauth-apps', labelKey: 'claude_quota.seven_day_oauth_apps' },
{ key: 'seven_day_opus', id: 'seven-day-opus', labelKey: 'claude_quota.seven_day_opus' },
{ key: 'seven_day_sonnet', id: 'seven-day-sonnet', labelKey: 'claude_quota.seven_day_sonnet' },
{ key: 'seven_day_cowork', id: 'seven-day-cowork', labelKey: 'claude_quota.seven_day_cowork' },
{ key: 'iguana_necktie', id: 'iguana-necktie', labelKey: 'claude_quota.iguana_necktie' },
] as const;
// Codex API configuration
export const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';

View File

@@ -2,7 +2,7 @@
* Normalization and parsing functions for quota data.
*/
import type { CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
import type { ClaudeUsagePayload, CodexUsagePayload, GeminiCliQuotaPayload } from '@/types';
const GEMINI_CLI_MODEL_SUFFIX = '_vertex';
@@ -129,6 +129,23 @@ export function parseAntigravityPayload(payload: unknown): Record<string, unknow
return null;
}
export function parseClaudeUsagePayload(payload: unknown): ClaudeUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as ClaudeUsagePayload;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as ClaudeUsagePayload;
}
return null;
}
export function parseCodexUsagePayload(payload: unknown): CodexUsagePayload | null {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {

View File

@@ -14,6 +14,23 @@ export function isAntigravityFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'antigravity';
}
export function isClaudeFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'claude';
}
export function isClaudeOAuthFile(file: AuthFileItem): boolean {
if (!isClaudeFile(file)) return false;
const metadata =
file && typeof file.metadata === 'object' && file.metadata !== null
? (file.metadata as Record<string, unknown>)
: null;
const accessToken =
metadata && typeof metadata.access_token === 'string'
? metadata.access_token.trim()
: '';
return accessToken.includes('sk-ant-oat');
}
export function isCodexFile(file: AuthFileItem): boolean {
return resolveAuthProvider(file) === 'codex';
}