fix(v8): preserve migrated config and correct provider requests

- Preserve provider credentials, aliases, headers, and network settings during migration
- Normalize Claude cloak metadata and repair previously saved invalid types
- Use streaming Gemini health probes and honor group and key proxy settings
- Retain Codex customizations for temporarily unavailable models
- Add regression tests for migration, metadata, proxy routing, and catalog persistence
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 2fd0f73f4c
commit 06fd58d3d8
15 files changed
+433 -35

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
const [error, setError] = useState('');
const [discard, setDiscard] = useState(false);
const [attempt, setAttempt] = useState(0);
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(original);
const dirty = draft !== null && (Boolean(draft.normalizeCloakMetadata?.length) || JSON.stringify(draft) !== JSON.stringify(original));
useEffect(() => {
const previousFocus = document.activeElement;
+13 -1
View File
@@ -1011,6 +1011,12 @@ export const buildProviderKeyRecord = (
return next;
};
export const providerDraftConnection = (
draft: Pick<ProviderDraft, 'headersText' | 'proxyUrl'>, key?: ProviderKeyDraft,
) => effectiveProviderKey({
headers: parseProviderHeaders(draft.headersText ?? ''), 'proxy-url': draft.proxyUrl,
}, key ? serializeProviderKey(key) : {});
export const buildProviderGroupRecord = (
section: ProviderSection, draft: ProviderDraft, current?: Record<string, unknown>,
): Record<string, unknown> => {
@@ -1437,6 +1443,8 @@ export function ApiAccessPage() {
preparedDraft.groupKeys?.[0]
? providerHeadersFromRecord(effectiveProviderKey({ headers: providerHeaders }, serializeProviderKey(preparedDraft.groupKeys[0])))
: providerHeaders,
undefined,
readString(providerDraftConnection(draftToSave, preparedDraft.groupKeys?.[0]), 'proxy-url'),
);
if (fetchedModels.length === 0) {
throw new Error(t('apiAccess.error.noModels'));
@@ -1765,6 +1773,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
apiKeys: row.apiKeys,
authIndex: row.authIndex,
customHeaders: providerHeadersFromRecord(row.record),
proxyUrl: readString(row.record, 'proxy-url'),
timeoutMs: PROVIDER_HEALTH_TIMEOUT_MS,
}), [row]);
@@ -1779,6 +1788,7 @@ function ProviderHealthDialog({ row, onClose, keySelector }: ProviderHealthDialo
healthOptions.authIndex,
healthOptions.customHeaders,
healthOptions.timeoutMs,
healthOptions.proxyUrl,
).then((discovered) => {
if (!disposed) setModels(mergeProviderHealthModels(discovered, row.models));
}).catch((requestError) => {
@@ -2118,7 +2128,7 @@ export function ApiProviderDialog({
try {
const provider: ModelProvider = activeCategory === 'deepseek' ? 'deepseek' : providerModelType(definition.section);
const selectedKey = draft.groupKeys?.find((key) => key.id === discoveryKeyId) ?? draft.groupKeys?.[0];
const connection = selectedKey ? effectiveProviderKey({ headers: parseProviderHeaders(draft.headersText ?? '') }, serializeProviderKey(selectedKey)) : null;
const connection = selectedKey ? providerDraftConnection(draft, selectedKey) : null;
const modelApiKey = connection ? readString(connection, 'api-key') : draft.apiKey.split(/\r?\n/).map((value) => value.trim()).find(Boolean) ?? '';
const fetchedModels = await fetchModels(
provider,
@@ -2126,6 +2136,8 @@ export function ApiProviderDialog({
modelApiKey,
connection ? readString(selectedKey!.value, 'auth-index', 'authIndex') || undefined : editingRow?.authIndex,
connection ? providerHeadersFromRecord(connection) : parseProviderHeaders(draft.headersText ?? ''),
undefined,
connection ? readString(connection, 'proxy-url') : draft.proxyUrl,
);
if (requestId !== discoveryRequestRef.current) return;
const models = applyProviderPreset(
+32 -3
View File
@@ -20,6 +20,7 @@ export type AuthFileSettingsDraft = {
headers: string;
note: string;
advanced: Record<string, unknown>;
normalizeCloakMetadata?: string[];
};
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
@@ -52,6 +53,27 @@ const headersFromText = (text: string): Record<string, string> => {
return result;
};
// OAuth cloak metadata is consumed as strings by the core, unlike the typed
// cloak block in API-key configuration. Keep typed values only in the form.
const advancedDraftValue = (key: string, value: unknown): unknown => {
if (value == null) return value;
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') {
if (typeof value === 'string') return value.trim().toLowerCase() === 'true';
}
if (key === 'cloak_sensitive_words' && typeof value === 'string') {
return value.split(',').map(word => word.trim()).filter(Boolean);
}
// Accept typed values persisted by older versions of this editor as well.
return value;
};
const advancedMetadataValue = (key: string, value: unknown): unknown => {
if (value == null) return null;
if (key === 'cloak_strict_mode' || key === 'cloak_cache_user_id') return String(value);
if (key === 'cloak_sensitive_words') return (value as string[]).map(word => word.trim()).filter(Boolean).join(',');
return value;
};
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
let metadata = payload;
if (typeof metadata === 'string') {
@@ -63,7 +85,14 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
const headers = metadata.headers == null ? {} : metadata.headers;
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
const normalizeCloakMetadata = ['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words'].filter(key => {
const value = read(key, key.replace(/_/g, '-'));
return key === 'cloak_sensitive_words'
? Array.isArray(value) && value.every(word => typeof word === 'string')
: typeof value === 'boolean';
});
return {
...(normalizeCloakMetadata.length ? { normalizeCloakMetadata } : {}),
prefix: text(metadata.prefix),
proxy_url: text(read('proxy_url', 'proxy-url')),
priority: text(metadata.priority),
@@ -75,7 +104,7 @@ export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsD
note: text(metadata.note),
advanced: Object.fromEntries(Object.keys(credentialAdvancedShape.fields ?? {}).flatMap(key => {
const value = read(key, key.replace(/_/g, '-'));
return value === undefined ? [] : [[key, value]];
return value === undefined ? [] : [[key, advancedDraftValue(key, value)]];
})),
};
};
@@ -120,10 +149,10 @@ export const buildAuthFileSettingsPatch = (
}
for (const [key, shape] of Object.entries(credentialAdvancedShape.fields ?? {})) {
const next = draft.advanced[key];
if (sameTemplateValue(next, original.advanced[key])) continue;
if (sameTemplateValue(next, original.advanced[key]) && !original.normalizeCloakMetadata?.includes(key)) continue;
const error = validateStructuredValueText(shape, next, key);
if (error) throw new Error(templateText(error, getCurrentLocale()));
patch[key] = next ?? null;
patch[key] = advancedMetadataValue(key, next);
}
return patch;
};
+4
View File
@@ -184,6 +184,7 @@ export async function fetchModels(
authIndex?: string,
customHeaders: Record<string, string> = {},
timeoutMs?: number,
proxyUrl?: string,
): Promise<ModelOption[]> {
const normalized = baseUrl.trim() ? normalizeBaseUrl(baseUrl) : '';
const candidates = modelEndpointCandidates(provider, normalized);
@@ -222,6 +223,7 @@ export async function fetchModels(
if (pageToken) pageUrl.searchParams.set('pageToken', pageToken);
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
authIndex: authIndex?.trim() || undefined,
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url: pageUrl.toString(),
header: Object.keys(headers).length ? headers : undefined,
@@ -258,6 +260,7 @@ export async function fetchModels(
if (provider === 'openai' && Object.keys(headers).length > 0) {
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url,
}, { timeoutMs });
@@ -272,6 +275,7 @@ export async function fetchModels(
if (provider === 'openai' && Object.keys(headers).length > 0) {
try {
const response = await managementApi.post<Record<string, unknown>>('/requests/api-call', {
proxy_url: proxyUrl?.trim() || undefined,
method: 'GET',
url,
}, { timeoutMs });
+5 -1
View File
@@ -35,6 +35,7 @@ export type ProviderHealthCheckOptions = {
authIndex?: string;
customHeaders?: Record<string, string>;
timeoutMs?: number;
proxyUrl?: string;
};
const defaultBaseUrl = defaultProviderBaseUrl;
@@ -125,7 +126,7 @@ export function buildProviderHealthProbe(
return {
...metadata,
url: provider === 'vertex' ? `${root}/v1/publishers/google/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:generateContent?alt=sse`,
: `${root}/v1beta/models/${encodeURIComponent(normalizedModel)}:streamGenerateContent?alt=sse`,
header: headers,
protocol: 'gemini',
data: JSON.stringify({
@@ -203,6 +204,7 @@ export async function checkProviderHealthProbe(
authIndex = '',
customHeaders: Record<string, string> = {},
timeoutMs = PROVIDER_HEALTH_TIMEOUT_MS,
proxyUrl?: string,
): Promise<ProviderHealthProbeResult> {
try {
const probe = buildProviderHealthProbe(
@@ -233,6 +235,7 @@ export async function checkProviderHealthProbe(
model: probe.model,
source: probe.source,
authIndex: probe.authIndex,
proxyUrl: proxyUrl?.trim() || undefined,
},
});
const firstTokenLatencyMs = Number.isFinite(response.firstTokenLatencyMs)
@@ -268,6 +271,7 @@ export async function checkProviderModelHealth(
options.authIndex,
options.customHeaders,
options.timeoutMs,
options.proxyUrl,
);
return {
...result,