Compare commits

...

5 Commits

7 changed files with 161 additions and 18 deletions
@@ -65,7 +65,13 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
<Button
onClick={onSave}
loading={editor?.saving === true}
disabled={disableControls || editor?.saving === true || !dirty || !editor?.json}
disabled={
disableControls ||
editor?.saving === true ||
!dirty ||
!editor?.json ||
Boolean(editor?.headersTouched && editor.headersError)
}
>
{t('common.save')}
</Button>
@@ -138,6 +144,20 @@ export function AuthFilesPrefixProxyEditorModal(props: AuthFilesPrefixProxyEdito
/>
<div className="hint">{t('auth_files.excluded_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('auth_files.headers_label')}</label>
<textarea
className={`input ${editor.headersError ? styles.prefixProxyTextareaInvalid : ''}`}
value={editor.headersText}
placeholder={t('auth_files.headers_placeholder')}
rows={4}
aria-invalid={Boolean(editor.headersError)}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('headersText', e.target.value)}
/>
{editor.headersError && <div className="error-box">{editor.headersError}</div>}
<div className="hint">{t('auth_files.headers_hint')}</div>
</div>
<Input
label={t('auth_files.disable_cooling_label')}
value={editor.disableCooling}
@@ -14,6 +14,12 @@ import {
readCodexAuthFileWebsockets,
} from '@/features/authFiles/constants';
type AuthFileHeaders = Record<string, string>;
type AuthFileHeadersErrorKey =
| 'auth_files.headers_invalid_json'
| 'auth_files.headers_invalid_object'
| 'auth_files.headers_invalid_value';
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
@@ -21,7 +27,8 @@ export type PrefixProxyEditorField =
| 'excludedModelsText'
| 'disableCooling'
| 'websockets'
| 'note';
| 'note'
| 'headersText';
export type PrefixProxyEditorFieldValue = string | boolean;
@@ -43,6 +50,9 @@ export type PrefixProxyEditorState = {
websockets: boolean;
note: string;
noteTouched: boolean;
headersText: string;
headersTouched: boolean;
headersError: string | null;
};
export type UseAuthFilesPrefixProxyEditorOptions = {
@@ -64,7 +74,45 @@ export type UseAuthFilesPrefixProxyEditorResult = {
handlePrefixProxySave: () => Promise<void>;
};
const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): string => {
const isRecordObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validateHeadersValue = (value: unknown): AuthFileHeadersErrorKey | null => {
if (!isRecordObject(value)) {
return 'auth_files.headers_invalid_object';
}
return Object.values(value).every((item) => typeof item === 'string')
? null
: 'auth_files.headers_invalid_value';
};
const parseHeadersText = (
text: string
): { value: AuthFileHeaders | null; errorKey: AuthFileHeadersErrorKey | null } => {
const trimmed = text.trim();
if (!trimmed) {
return { value: null, errorKey: null };
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return { value: null, errorKey: 'auth_files.headers_invalid_json' };
}
const errorKey = validateHeadersValue(parsed);
if (errorKey) {
return { value: null, errorKey };
}
return { value: parsed as AuthFileHeaders, errorKey: null };
};
const buildPrefixProxyUpdatedText = (
editor: PrefixProxyEditorState | null,
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
): string => {
if (!editor?.json) return editor?.rawText ?? '';
const next: Record<string, unknown> = { ...editor.json };
if ('prefix' in next || editor.prefix.trim()) {
@@ -104,6 +152,18 @@ const buildPrefixProxyUpdatedText = (editor: PrefixProxyEditorState | null): str
}
}
if (editor.headersTouched) {
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
if (errorKey) {
throw new Error(resolveHeadersError(errorKey));
}
if (parsedHeaders) {
next.headers = parsedHeaders;
} else {
delete next.headers;
}
}
return JSON.stringify(
editor.isCodexFile ? applyCodexAuthFileWebsockets(next, editor.websockets) : next
);
@@ -118,11 +178,18 @@ export function useAuthFilesPrefixProxyEditor(
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const prefixProxyUpdatedText = buildPrefixProxyUpdatedText(prefixProxyEditor);
const hasBlockingValidationError = Boolean(
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
);
const prefixProxyUpdatedText =
prefixProxyEditor?.json && !hasBlockingValidationError
? buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key))
: '';
const prefixProxyDirty =
Boolean(prefixProxyEditor?.json) &&
Boolean(prefixProxyEditor?.originalText) &&
prefixProxyUpdatedText !== prefixProxyEditor?.originalText;
(prefixProxyUpdatedText === '' || prefixProxyUpdatedText !== prefixProxyEditor?.originalText);
const closePrefixProxyEditor = () => {
setPrefixProxyEditor(null);
@@ -162,6 +229,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: false,
note: '',
noteTouched: false,
headersText: '',
headersTouched: false,
headersError: null,
});
try {
@@ -213,6 +283,14 @@ export function useAuthFilesPrefixProxyEditor(
const disableCoolingValue = parseDisableCoolingValue(json.disable_cooling);
const websocketsValue = readCodexAuthFileWebsockets(json);
const note = typeof json.note === 'string' ? json.note : '';
const headers = json.headers;
let headersText = '';
let headersError: string | null = null;
if (headers !== undefined) {
headersText = JSON.stringify(headers, null, 2);
const { errorKey } = parseHeadersText(headersText);
headersError = errorKey ? t(errorKey) : null;
}
setPrefixProxyEditor((prev) => {
if (!prev || prev.fileName !== name) return prev;
@@ -231,6 +309,9 @@ export function useAuthFilesPrefixProxyEditor(
websockets: websocketsValue,
note,
noteTouched: false,
headersText,
headersTouched: false,
headersError,
error: null,
};
});
@@ -256,6 +337,16 @@ export function useAuthFilesPrefixProxyEditor(
if (field === 'excludedModelsText') return { ...prev, excludedModelsText: String(value) };
if (field === 'disableCooling') return { ...prev, disableCooling: String(value) };
if (field === 'note') return { ...prev, note: String(value), noteTouched: true };
if (field === 'headersText') {
const headersText = String(value);
const { errorKey } = parseHeadersText(headersText);
return {
...prev,
headersText,
headersTouched: true,
headersError: errorKey ? t(errorKey) : null,
};
}
return { ...prev, websockets: Boolean(value) };
});
};
@@ -265,7 +356,15 @@ export function useAuthFilesPrefixProxyEditor(
if (!prefixProxyDirty) return;
const name = prefixProxyEditor.fileName;
const payload = prefixProxyUpdatedText;
let payload = '';
try {
payload = buildPrefixProxyUpdatedText(prefixProxyEditor, (key) => t(key));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Invalid format';
showNotification(errorMessage, 'error');
return;
}
const fileSize = new Blob([payload]).size;
if (fileSize > MAX_AUTH_FILE_SIZE) {
showNotification(
+6
View File
@@ -597,6 +597,12 @@
"note_placeholder": "Enter a note, e.g.: John's account",
"note_hint": "Optional. Used to describe the purpose or owner of this credential; leave empty to omit.",
"note_display": "Note",
"headers_label": "Custom Headers (headers)",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "Enter custom HTTP headers as a JSON object, e.g., {\"X-My-Header\": \"value\"}",
"headers_invalid_json": "Custom headers must be valid JSON.",
"headers_invalid_object": "Custom headers must be a JSON object.",
"headers_invalid_value": "Each custom header value must be a string.",
"prefix_proxy_invalid_json": "This auth file is not a JSON object, so fields cannot be edited.",
"prefix_proxy_saved_success": "Updated auth file \"{{name}}\" successfully",
"quota_refresh_success": "Quota refreshed for \"{{name}}\"",
+6
View File
@@ -597,6 +597,12 @@
"note_placeholder": "输入备注信息,例如:张三的账号",
"note_hint": "可选,用于标记凭证用途或归属;留空则不写入。",
"note_display": "备注",
"headers_label": "自定义请求头(headers",
"headers_placeholder": "{\n \"Header-Name\": \"value\"\n}",
"headers_hint": "以 JSON 对象格式输入自定义 HTTP 请求头,例如:{\"X-My-Header\": \"value\"}",
"headers_invalid_json": "自定义请求头必须是有效的 JSON。",
"headers_invalid_object": "自定义请求头必须是 JSON 对象。",
"headers_invalid_value": "每个自定义请求头的值都必须是字符串。",
"prefix_proxy_invalid_json": "该认证文件不是 JSON 对象,无法编辑字段。",
"prefix_proxy_saved_success": "已更新认证文件 \"{{name}}\"",
"quota_refresh_success": "已刷新 \"{{name}}\" 的额度",
+4 -4
View File
@@ -164,7 +164,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteGeminiKey(entry.apiKey);
await providersApi.deleteGeminiKey(entry.apiKey, entry.baseUrl);
const next = geminiKeys.filter((_, idx) => idx !== index);
setGeminiKeys(next);
updateConfigValue('gemini-api-key', next);
@@ -297,14 +297,14 @@ export function AiProvidersPage() {
onConfirm: async () => {
try {
if (type === 'codex') {
await providersApi.deleteCodexConfig(entry.apiKey);
await providersApi.deleteCodexConfig(entry.apiKey, entry.baseUrl);
const next = codexConfigs.filter((_, idx) => idx !== index);
setCodexConfigs(next);
updateConfigValue('codex-api-key', next);
clearCache('codex-api-key');
showNotification(t('notification.codex_config_deleted'), 'success');
} else {
await providersApi.deleteClaudeConfig(entry.apiKey);
await providersApi.deleteClaudeConfig(entry.apiKey, entry.baseUrl);
const next = claudeConfigs.filter((_, idx) => idx !== index);
setClaudeConfigs(next);
updateConfigValue('claude-api-key', next);
@@ -329,7 +329,7 @@ export function AiProvidersPage() {
confirmText: t('common.confirm'),
onConfirm: async () => {
try {
await providersApi.deleteVertexConfig(entry.apiKey);
await providersApi.deleteVertexConfig(entry.apiKey, entry.baseUrl);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
+5
View File
@@ -1384,6 +1384,11 @@
}
}
.prefixProxyTextareaInvalid {
border-color: var(--danger-color);
box-shadow: 0 0 0 3px rgba($error-color, 0.12);
}
.cardActions {
display: flex;
align-items: center;
+15 -8
View File
@@ -28,6 +28,13 @@ const extractArrayPayload = (data: unknown, key: string): unknown[] => {
return Array.isArray(candidate) ? candidate : [];
};
const buildProviderDeleteQuery = (apiKey: string, baseUrl?: string) => {
const params = new URLSearchParams();
params.set('api-key', apiKey.trim());
params.set('base-url', (baseUrl ?? '').trim());
return `?${params.toString()}`;
};
const serializeModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
@@ -160,8 +167,8 @@ export const providersApi = {
updateGeminiKey: (index: number, value: GeminiKeyConfig) =>
apiClient.patch('/gemini-api-key', { index, value: serializeGeminiKey(value) }),
deleteGeminiKey: (apiKey: string) =>
apiClient.delete(`/gemini-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteGeminiKey: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/gemini-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getCodexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/codex-api-key');
@@ -175,8 +182,8 @@ export const providersApi = {
updateCodexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/codex-api-key', { index, value: serializeProviderKey(value) }),
deleteCodexConfig: (apiKey: string) =>
apiClient.delete(`/codex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteCodexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/codex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getClaudeConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/claude-api-key');
@@ -190,8 +197,8 @@ export const providersApi = {
updateClaudeConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/claude-api-key', { index, value: serializeProviderKey(value) }),
deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteClaudeConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/claude-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
@@ -205,8 +212,8 @@ export const providersApi = {
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
deleteVertexConfig: (apiKey: string, baseUrl?: string) =>
apiClient.delete(`/vertex-api-key${buildProviderDeleteQuery(apiKey, baseUrl)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility');