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
@@ -88,9 +88,9 @@ describe('credential settings', () => {
|
||||
|
||||
it('edits template credential fields without resending tokens or unmodified metadata', () => {
|
||||
const original = authFileSettingsFromPayload({ access_token: 'never-copy', cloak_mode: 'auto', timezone: 'Asia/Tokyo',
|
||||
cloak_strict_mode: true, model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
|
||||
cloak_strict_mode: 'true', model_aliases: [{ name: 'upstream', alias: 'public', future_metadata: true }] });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, cloak_mode: 'always', cloak_strict_mode: false } }))
|
||||
.toEqual({ cloak_mode: 'always', cloak_strict_mode: false });
|
||||
.toEqual({ cloak_mode: 'always', cloak_strict_mode: 'false' });
|
||||
const next = { ...original.advanced }; delete next.timezone;
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: next })).toEqual({ timezone: null });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, model_aliases: [] } })).toEqual({ model_aliases: [] });
|
||||
@@ -99,4 +99,59 @@ describe('credential settings', () => {
|
||||
expect(() => buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced, ...advanced } })).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it('converts native string cloak metadata to typed controls and writes executor-compatible strings', () => {
|
||||
const original = authFileSettingsFromPayload({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false',
|
||||
cloak_sensitive_words: ' first,second , ', custom_metadata: { keep: true } });
|
||||
expect(original.advanced).toEqual({ cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['first', 'second'] });
|
||||
expect(buildAuthFileSettingsPatch(original, original)).toEqual({});
|
||||
const patch = buildAuthFileSettingsPatch(original, { ...original, advanced: {
|
||||
cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['changed', ' second '],
|
||||
} });
|
||||
expect(patch).toEqual({ cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'changed,second' });
|
||||
expect(authFileSettingsFromPayload(patch).advanced).toEqual({ cloak_strict_mode: false, cloak_cache_user_id: true,
|
||||
cloak_sensitive_words: ['changed', 'second'] });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { cloak_sensitive_words: [] } }))
|
||||
.toEqual({ cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: '' });
|
||||
});
|
||||
|
||||
it('can edit older incorrectly typed cloak metadata without resending unrelated fields', () => {
|
||||
const original = authFileSettingsFromPayload({ cloak_strict_mode: false, cloak_cache_user_id: true,
|
||||
cloak_sensitive_words: ['old'], fingerprint_profile: 'claude-code-cli', model_aliases: [{ name: 'upstream', alias: 'public', extension: true }] });
|
||||
expect(original.advanced.cloak_strict_mode).toBe(false);
|
||||
expect(original.advanced.cloak_sensitive_words).toEqual(['old']);
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: { ...original.advanced,
|
||||
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['new'],
|
||||
} })).toEqual({ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: 'new' });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, note: 'only note' })).toEqual({ note: 'only note',
|
||||
cloak_strict_mode: 'false', cloak_cache_user_id: 'true', cloak_sensitive_words: 'old' });
|
||||
const canonicalNull = authFileSettingsFromPayload({ cloak_strict_mode: null, 'cloak-strict-mode': 'true', cloak_sensitive_words: null });
|
||||
expect(canonicalNull.advanced).toEqual({ cloak_strict_mode: null, cloak_sensitive_words: null });
|
||||
expect(canonicalNull.normalizeCloakMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('repairs previously typed cloak fields on an unchanged explicit save and stops after reload', async () => {
|
||||
const metadata = { cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: [] as string[],
|
||||
access_token: 'retain', extension: { untouched: true } } as Record<string, unknown>;
|
||||
const writes: Record<string, unknown>[] = [];
|
||||
const api = { get: async () => metadata, patch: async (_path: string, body: Record<string, unknown>) => {
|
||||
const { name, ...patch } = body;
|
||||
writes.push(patch);
|
||||
Object.assign(metadata, patch);
|
||||
} };
|
||||
const original = await loadAuthFileSettings('old.json', api);
|
||||
expect(original.normalizeCloakMetadata).toEqual(['cloak_strict_mode', 'cloak_cache_user_id', 'cloak_sensitive_words']);
|
||||
expect(await saveAuthFileSettings('old.json', original, original, api)).toBe(true);
|
||||
expect(writes).toEqual([{ cloak_strict_mode: 'true', cloak_cache_user_id: 'false', cloak_sensitive_words: '' }]);
|
||||
expect(metadata.access_token).toBe('retain');
|
||||
expect(metadata.extension).toEqual({ untouched: true });
|
||||
const reloaded = await loadAuthFileSettings('old.json', api);
|
||||
expect(reloaded.normalizeCloakMetadata).toBeUndefined();
|
||||
expect(reloaded.advanced).toEqual(original.advanced);
|
||||
expect(await saveAuthFileSettings('old.json', reloaded, reloaded, api)).toBe(false);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, advanced: {} })).toEqual({
|
||||
cloak_strict_mode: null, cloak_cache_user_id: null, cloak_sensitive_words: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -149,9 +149,18 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
it('round-trips template credential aliases and Claude settings without replacing token metadata', async () => {
|
||||
const fileName = 'template-claude.json';
|
||||
await managementApi.uploadAuthFile(new File([JSON.stringify({ type: 'claude', access_token: 'isolated-template-token',
|
||||
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true } })], fileName, { type: 'application/json' }));
|
||||
expired: '2099-01-01T00:00:00Z', custom_metadata: { keep: true },
|
||||
cloak_strict_mode: true, cloak_cache_user_id: false, cloak_sensitive_words: ['Legacy'] })], fileName, { type: 'application/json' }));
|
||||
try {
|
||||
const legacy = await loadAuthFileSettings(fileName);
|
||||
expect(await saveAuthFileSettings(fileName, legacy, legacy)).toBe(true);
|
||||
const repaired = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
|
||||
expect(repaired.cloak_strict_mode).toBe('true');
|
||||
expect(repaired.cloak_cache_user_id).toBe('false');
|
||||
expect(repaired.cloak_sensitive_words).toBe('Legacy');
|
||||
const original = await loadAuthFileSettings(fileName);
|
||||
expect(original.normalizeCloakMetadata).toBeUndefined();
|
||||
expect(await saveAuthFileSettings(fileName, original, original)).toBe(false);
|
||||
const advanced = { cloak_mode: 'always', cloak_strict_mode: false, cloak_cache_user_id: true, cloak_sensitive_words: ['Word'],
|
||||
fingerprint_profile: 'claude-code-cli', timezone: 'Asia/Shanghai',
|
||||
model_aliases: [{ name: 'claude-template', alias: 'public-template', fork: false, 'display-name': 'Template model', 'force-mapping': true }] };
|
||||
@@ -159,6 +168,9 @@ oauth: {auth-dir: ${JSON.stringify(join(work, 'auth'))}}
|
||||
const loaded = await loadAuthFileSettings(fileName);
|
||||
expect(loaded.advanced).toEqual(advanced);
|
||||
const metadata = await managementApi.get<Record<string, unknown>>('/credentials/download', { name: fileName });
|
||||
expect(metadata.cloak_strict_mode).toBe('false');
|
||||
expect(metadata.cloak_cache_user_id).toBe('true');
|
||||
expect(metadata.cloak_sensitive_words).toBe('Word');
|
||||
expect(metadata.access_token).toBe('isolated-template-token');
|
||||
expect(metadata.custom_metadata).toEqual({ keep: true });
|
||||
await saveAuthFileSettings(fileName, loaded, { ...loaded, advanced: {} });
|
||||
|
||||
@@ -207,7 +207,7 @@ describe('API 接入健康检测', () => {
|
||||
);
|
||||
|
||||
expect(probe.url).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?alt=sse',
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse',
|
||||
);
|
||||
expect(probe.header['x-goog-api-key']).toBe('gemini-key');
|
||||
expect(probe.protocol).toBe('gemini');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
|
||||
import { fetchModels } from '../src/services/modelService';
|
||||
import { checkProviderModelHealth } from '../src/services/providerHealthCheck';
|
||||
import { providerDraftConnection } from '../src/pages/ApiAccessPage';
|
||||
import { providerKeyDraft } from '../src/services/providerGroups';
|
||||
|
||||
describe('provider request proxy routing', () => {
|
||||
let originalWindow: PropertyDescriptor | undefined;
|
||||
beforeEach(() => {
|
||||
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
clearMocks();
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
|
||||
it('uses the edited shared proxy unless a key supplies a non-null override', () => {
|
||||
const draft = { proxyUrl: 'socks5://127.0.0.1:19080', headersText: 'X-Team: new' };
|
||||
for (const override of [undefined, null, '', 'direct', 'http://127.0.0.1:18080']) {
|
||||
const key = providerKeyDraft({ 'api-key': 'test-key', ...(override === undefined ? {} : { 'proxy-url': override }) });
|
||||
const effective = providerDraftConnection(draft, key);
|
||||
expect(effective['proxy-url']).toBe(override == null ? draft.proxyUrl : override);
|
||||
expect(effective.headers).toEqual({ 'X-Team': 'new' });
|
||||
}
|
||||
});
|
||||
|
||||
for (const firstResult of ['http-error', 'transport-error'] as const) {
|
||||
it(`retains direct proxy selection on unauthenticated discovery fallback (${firstResult})`, async () => {
|
||||
const requests: Record<string, any>[] = [];
|
||||
mockIPC((_command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
requests.push(request);
|
||||
if (requests.length === 1) {
|
||||
if (firstResult === 'transport-error') throw new Error('mock transport failure');
|
||||
return { status_code: 401, body: 'unauthorized' };
|
||||
}
|
||||
return { status_code: 200, body: JSON.stringify({ data: [{ id: 'test-model' }] }) };
|
||||
});
|
||||
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key', undefined, {}, undefined, ' direct ');
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.every(request => request.body.proxy_url === 'direct')).toBe(true);
|
||||
expect(requests[1].body.header).toBeUndefined();
|
||||
});
|
||||
}
|
||||
|
||||
it('sends proxy overrides on every Gemini model page and uses SSE for health checks', async () => {
|
||||
const requests: Record<string, any>[] = [];
|
||||
mockIPC((command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
requests.push(request);
|
||||
if (command === 'provider_health_probe') return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
|
||||
return { status_code: 200, body: { models: [{ name: `models/model-${requests.length}` }], ...(requests.length === 1 ? { nextPageToken: 'next' } : {}) } };
|
||||
});
|
||||
const proxyUrl = 'socks5://127.0.0.1:19080';
|
||||
await fetchModels('gemini', 'https://generativelanguage.googleapis.com', 'test-key', undefined, {}, undefined, proxyUrl);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests.every(request => request.body.proxy_url === proxyUrl)).toBe(true);
|
||||
const result = await checkProviderModelHealth({ provider: 'gemini', baseUrl: '', apiKeys: ['test-key'], proxyUrl }, 'model-1');
|
||||
expect(result.success).toBe(true);
|
||||
expect(requests[2].proxyUrl).toBe(proxyUrl);
|
||||
expect(requests[2].url).toEndWith(':streamGenerateContent?alt=sse');
|
||||
});
|
||||
|
||||
it('leaves proxy inheritance unset rather than converting it to direct', async () => {
|
||||
mockIPC((command, args) => {
|
||||
const request = args?.request as Record<string, any>;
|
||||
if (command === 'provider_health_probe') {
|
||||
expect(request.proxyUrl).toBeUndefined();
|
||||
return { firstTokenLatencyMs: 12, responseLatencyMs: 12 };
|
||||
}
|
||||
expect(request.body.proxy_url).toBeUndefined();
|
||||
return { status_code: 200, body: { data: [{ id: 'model' }] } };
|
||||
});
|
||||
await fetchModels('openai', 'https://provider.invalid/v1', 'test-key');
|
||||
await checkProviderModelHealth({ provider: 'openai', baseUrl: 'https://provider.invalid/v1', apiKeys: ['test-key'], proxyUrl: '' }, 'model');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user