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:
1 parent
2fd0f73f4c
commit
06fd58d3d8
15 files changed
+433
-35
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user