diff --git a/src/main.tsx b/src/main.tsx
index 2110279..d209ca0 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,19 +1,29 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { isTauri } from '@tauri-apps/api/core';
import { AppErrorBoundary } from './AppErrorBoundary';
import App from './App';
import { I18nProvider } from './i18n';
import { initializeTheme } from './theme';
import './styles.css';
-initializeTheme();
+async function bootstrap() {
+ if (import.meta.env.DEV && !isTauri()) {
+ const { installBrowserMock } = await import('./mocks/browserMock');
+ installBrowserMock();
+ }
-createRoot(document.getElementById('root')!).render(
-
-
-
-
-
-
-
-);
+ initializeTheme();
+
+ createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+ );
+}
+
+void bootstrap();
diff --git a/src/mocks/browserMock.css b/src/mocks/browserMock.css
new file mode 100644
index 0000000..3d09925
--- /dev/null
+++ b/src/mocks/browserMock.css
@@ -0,0 +1,53 @@
+#browser-mock-toolbar {
+ position: fixed;
+ top: 10px;
+ right: 12px;
+ z-index: 2147483647;
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 30px;
+ padding: 4px 7px;
+ border: 1px solid color-mix(in srgb, #f59e0b 55%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--panel-bg, #17191f) 92%, transparent);
+ box-shadow: 0 7px 24px rgb(0 0 0 / 22%);
+ color: var(--text-primary, #f3f4f6);
+ font: 12px/1.2 system-ui, sans-serif;
+ backdrop-filter: blur(10px);
+}
+
+#browser-mock-toolbar strong {
+ padding: 3px 6px;
+ border-radius: 5px;
+ background: #f59e0b;
+ color: #17120a;
+ font-size: 10px;
+ letter-spacing: 0.08em;
+}
+
+#browser-mock-toolbar select {
+ width: auto;
+ min-width: 94px;
+ min-height: 24px;
+ padding: 2px 24px 2px 7px;
+ border-radius: 5px;
+ font-size: 12px;
+}
+
+#browser-mock-toolbar span {
+ color: var(--text-secondary, #b6bbc6);
+ white-space: nowrap;
+}
+
+@media (max-width: 760px) {
+ #browser-mock-toolbar {
+ top: auto;
+ right: 8px;
+ bottom: 8px;
+ }
+
+ #browser-mock-toolbar span {
+ display: none;
+ }
+}
diff --git a/src/mocks/browserMock.ts b/src/mocks/browserMock.ts
new file mode 100644
index 0000000..13b7851
--- /dev/null
+++ b/src/mocks/browserMock.ts
@@ -0,0 +1,105 @@
+import { emit } from '@tauri-apps/api/event';
+import { mockConvertFileSrc, mockIPC, mockWindows } from '@tauri-apps/api/mocks';
+import {
+ createBrowserMockRuntime,
+ resolveBrowserMockOptions,
+ type BrowserMockScenario,
+} from './browserMockRuntime';
+import './browserMock.css';
+
+const STORAGE_KEY = 'easy-cli-proxy-api.browser-mock-scenario';
+
+export type InstalledBrowserMock = {
+ scenario: BrowserMockScenario;
+ delayMs: number;
+};
+
+function readStoredScenario() {
+ try {
+ return window.localStorage.getItem(STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+function storeScenario(scenario: BrowserMockScenario) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, scenario);
+ } catch {
+ }
+}
+
+function installToolbar(scenario: BrowserMockScenario, delayMs: number) {
+ document.getElementById('browser-mock-toolbar')?.remove();
+
+ const toolbar = document.createElement('aside');
+ toolbar.id = 'browser-mock-toolbar';
+ toolbar.setAttribute('aria-label', 'Browser Mock controls');
+ toolbar.title = '仅在普通浏览器开发模式中显示;Tauri 和正式构建不会启用 Mock。';
+
+ const badge = document.createElement('strong');
+ badge.textContent = 'MOCK';
+
+ const select = document.createElement('select');
+ select.setAttribute('aria-label', 'Browser Mock 场景');
+ const scenarios: Array<[BrowserMockScenario, string]> = [
+ ['running', '内核运行'],
+ ['stopped', '内核停止'],
+ ['empty', '未安装内核'],
+ ['error', '请求错误'],
+ ];
+ scenarios.forEach(([value, label]) => {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = label;
+ option.selected = value === scenario;
+ select.append(option);
+ });
+ select.addEventListener('change', () => {
+ const next = select.value as BrowserMockScenario;
+ storeScenario(next);
+ const url = new URL(window.location.href);
+ url.searchParams.set('mock', next);
+ window.location.assign(url);
+ });
+
+ const hint = document.createElement('span');
+ hint.textContent = delayMs > 0 ? `${delayMs} ms` : '浏览器调试';
+
+ toolbar.append(badge, select, hint);
+ document.body.append(toolbar);
+ document.body.dataset.browserMock = scenario;
+}
+
+export function installBrowserMock(): InstalledBrowserMock | null {
+ const options = resolveBrowserMockOptions(window.location.search, readStoredScenario());
+ if (options.mode === 'off') {
+ console.info('[Browser Mock] 已通过 ?mock=off 禁用。');
+ return null;
+ }
+
+ storeScenario(options.mode);
+ mockWindows('main');
+ mockConvertFileSrc('windows');
+
+ const runtime = createBrowserMockRuntime(
+ options.mode,
+ (event, payload) => {
+ queueMicrotask(() => {
+ void emit(event, payload);
+ });
+ },
+ options.delayMs,
+ );
+
+ mockIPC((command, payload) => runtime.invoke(command, payload), {
+ shouldMockEvents: true,
+ });
+ installToolbar(options.mode, options.delayMs);
+
+ console.info(
+ `[Browser Mock] 已启用场景“${options.mode}”${options.delayMs ? `,延迟 ${options.delayMs} ms` : ''}。`,
+ '使用 ?mock=running|stopped|empty|error 切换,?mock=off 禁用。',
+ );
+ return { scenario: options.mode, delayMs: options.delayMs };
+}
diff --git a/src/mocks/browserMockRuntime.ts b/src/mocks/browserMockRuntime.ts
new file mode 100644
index 0000000..ea7194c
--- /dev/null
+++ b/src/mocks/browserMockRuntime.ts
@@ -0,0 +1,1183 @@
+export type BrowserMockScenario = 'running' | 'stopped' | 'empty' | 'error';
+export type BrowserMockMode = BrowserMockScenario | 'off';
+
+type JsonObject = Record;
+type EmitMockEvent = (event: string, payload: unknown) => void;
+
+export type BrowserMockOptions = {
+ mode: BrowserMockMode;
+ delayMs: number;
+};
+
+export type BrowserMockRuntime = {
+ scenario: BrowserMockScenario;
+ invoke: (command: string, payload?: unknown) => Promise;
+};
+
+const SCENARIOS = new Set(['running', 'stopped', 'empty', 'error']);
+const PROVIDER_SECTIONS = [
+ 'gemini-api-key',
+ 'codex-api-key',
+ 'claude-api-key',
+ 'openai-compatibility',
+] as const;
+
+const clone = (value: Value): Value => structuredClone(value);
+const sleep = (delayMs: number) => delayMs > 0
+ ? new Promise((resolve) => window.setTimeout(resolve, delayMs))
+ : Promise.resolve();
+
+const asObject = (value: unknown): JsonObject => (
+ typeof value === 'object' && value !== null && !Array.isArray(value)
+ ? value as JsonObject
+ : {}
+);
+
+const asArray = (value: unknown): unknown[] => Array.isArray(value) ? value : [];
+const readString = (value: unknown) => typeof value === 'string' ? value : '';
+const readNumber = (value: unknown, fallback = 0) => {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+};
+
+const isoHoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
+const localHourKey = (date: Date) => [
+ date.getFullYear(),
+ String(date.getMonth() + 1).padStart(2, '0'),
+ String(date.getDate()).padStart(2, '0'),
+ String(date.getHours()).padStart(2, '0'),
+].join('-');
+
+export function resolveBrowserMockOptions(
+ search: string,
+ storedScenario?: string | null,
+): BrowserMockOptions {
+ const params = new URLSearchParams(search);
+ const requested = params.get('mock');
+ const mode: BrowserMockMode = requested === 'off'
+ ? 'off'
+ : SCENARIOS.has(requested as BrowserMockScenario)
+ ? requested as BrowserMockScenario
+ : SCENARIOS.has(storedScenario as BrowserMockScenario)
+ ? storedScenario as BrowserMockScenario
+ : 'running';
+ const delay = Number(params.get('mockDelay') ?? 0);
+ return {
+ mode,
+ delayMs: Number.isFinite(delay) ? Math.max(0, Math.min(5_000, Math.round(delay))) : 0,
+ };
+}
+
+type BrowserMockState = ReturnType;
+
+function createCoreStatus(scenario: BrowserMockScenario) {
+ const installed = scenario !== 'empty';
+ const running = scenario === 'running';
+ return {
+ installed,
+ running,
+ ready: running,
+ starting: false,
+ managed: running,
+ processId: running ? 42817 : null,
+ currentVersion: installed ? '7.3.15' : null,
+ installDir: 'C:\\EasyCLIProxyAPI\\cpa-core',
+ binaryPath: installed ? 'C:\\EasyCLIProxyAPI\\cpa-core\\cli-proxy-api.exe' : null,
+ message: running ? 'Browser Mock 内核运行中' : installed ? 'Browser Mock 内核已停止' : 'Browser Mock 内核未安装',
+ };
+}
+
+function createTimeline() {
+ return Array.from({ length: 24 }, (_, index) => {
+ const date = new Date(Date.now() - (23 - index) * 60 * 60 * 1000);
+ const requests = 6 + (index % 5) * 3;
+ const failure = index % 7 === 0 ? 1 : 0;
+ const canceled = index % 11 === 0 ? 1 : 0;
+ const success = Math.max(0, requests - failure - canceled);
+ const codexTokens = 18_000 + index * 1_350;
+ const claudeTokens = 9_000 + (index % 6) * 2_100;
+ return {
+ hour: localHourKey(date),
+ requests,
+ success,
+ failure,
+ canceled,
+ tokens: codexTokens + claudeTokens,
+ models: [
+ { key: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', tokens: codexTokens, requests: Math.ceil(requests * 0.65) },
+ { key: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', tokens: claudeTokens, requests: Math.floor(requests * 0.35) },
+ ],
+ };
+ });
+}
+
+function createUsageEvents() {
+ const models = ['gpt-5.2-codex', 'claude-sonnet-4-6', 'gemini-3-pro'];
+ return Array.from({ length: 18 }, (_, index) => {
+ const failed = index === 5 || index === 13;
+ const input = 1_200 + index * 137;
+ const output = failed ? 0 : 380 + index * 31;
+ const reasoning = failed ? 0 : 160 + index * 19;
+ const cacheRead = index % 3 === 0 ? 640 : 0;
+ return {
+ id: `mock-event-${index + 1}`,
+ row_id: String(index + 1),
+ timestamp: isoHoursAgo(index / 2),
+ latency_ms: 620 + index * 47,
+ ttft_ms: failed ? null : 180 + index * 9,
+ source: index % 2 === 0 ? 'codex' : 'claude-code',
+ source_display: index % 2 === 0 ? 'Codex' : 'Claude Code',
+ failed,
+ canceled: false,
+ failure_status: failed ? 429 : 0,
+ failure_body: failed ? 'Mock rate limit exceeded' : '',
+ provider: index % 2 === 0 ? 'OpenAI OAuth' : 'Claude OAuth',
+ model: models[index % models.length],
+ alias: '',
+ reasoning_effort: index % 2 === 0 ? 'high' : 'medium',
+ endpoint: '/v1/responses',
+ api_key_hash: 'mock-key-hash',
+ api_key_display: 'sk-mock••••••••2026',
+ api_key_remark: '浏览器 Mock 密钥',
+ tokens: {
+ input_tokens: input,
+ output_tokens: output,
+ reasoning_tokens: reasoning,
+ cache_read_tokens: cacheRead,
+ cache_creation_tokens: 0,
+ total_tokens: input + output + reasoning + cacheRead,
+ },
+ };
+ });
+}
+
+function createAgentStatuses() {
+ const ids = [
+ ['claude-code', 'Claude Code'],
+ ['claude-desktop', 'Claude Desktop'],
+ ['codex', 'Codex'],
+ ['opencode', 'OpenCode'],
+ ['openclaw', 'OpenClaw'],
+ ['hermes', 'Hermes Agent'],
+ ['deepseek-harness', 'DeepSeek Harness'],
+ ['zcode', 'ZCode'],
+ ['workbuddy', 'WorkBuddy'],
+ ['antigravity-cli', 'Antigravity CLI'],
+ ['kimi-code', 'Kimi Code'],
+ ['grok-build', 'Grok Build'],
+ ['pi', 'Pi'],
+ ] as const;
+ const claudeMappings = {
+ opus: 'claude-opus-4-6',
+ sonnet: 'claude-sonnet-4-6',
+ haiku: 'claude-haiku-4-5',
+ opus1m: false,
+ sonnet1m: false,
+ haiku1m: false,
+ maxContextTokens: 200_000,
+ autoCompactPct: 90,
+ disableAutoCompact: false,
+ };
+ return ids.map(([id, name], index) => ({
+ id,
+ name,
+ supportedPlatform: true,
+ installed: id !== 'openclaw',
+ pluginInstalled: id === 'pi',
+ launchTargets: id === 'codex' || id === 'opencode'
+ ? [
+ { id: 'app', label: 'Desktop App', detail: 'Browser Mock 桌面入口' },
+ { id: 'cli', label: 'CLI', detail: 'Browser Mock 命令行入口' },
+ ]
+ : [{ id: id === 'claude-desktop' ? 'app' : 'cli', label: '启动', detail: 'Browser Mock 启动目标' }],
+ version: `1.${index + 4}.0`,
+ cliVersion: id === 'claude-desktop' ? null : `1.${index + 4}.0`,
+ appVersion: id === 'claude-desktop' ? '0.12.8' : null,
+ pluginVersion: id === 'pi' ? '0.4.2' : null,
+ configExists: true,
+ configValid: true,
+ configured: id !== 'openclaw',
+ connectionState: id === 'openclaw' ? 'not-configured' : 'configured',
+ configurationSynchronized: true,
+ currentModel: id.startsWith('claude') ? 'claude-sonnet-4-6' : 'gpt-5.2-codex',
+ oauthConfiguration: id === 'codex',
+ codexNativeOauth: false,
+ modificationEnabled: id !== 'openclaw',
+ modificationState: id === 'openclaw' ? 'unconfigured' : 'applied',
+ backupAvailable: true,
+ appliedModel: id.startsWith('claude') ? 'claude-sonnet-4-6' : 'gpt-5.2-codex',
+ claudeCodeModelMappings: id === 'claude-code' ? clone(claudeMappings) : null,
+ claudeDesktopModelMappings: id === 'claude-desktop'
+ ? {
+ ...clone(claudeMappings),
+ desktopModels: [
+ { id: 'claude-opus-5-cpa', alias: 'Claude Opus', model: 'claude-opus-4-6', enabled: true },
+ { id: 'claude-sonnet-5-cpa', alias: 'Claude Sonnet', model: 'claude-sonnet-4-6', enabled: true },
+ { id: 'claude-haiku-4-5-cpa', alias: 'Claude Haiku', model: 'claude-haiku-4-5', enabled: true },
+ ],
+ }
+ : null,
+ warnings: id === 'openclaw' ? ['Mock:尚未应用配置'] : [],
+ error: null,
+ }));
+}
+
+function createState(scenario: BrowserMockScenario) {
+ const timeline = createTimeline();
+ const events = createUsageEvents();
+ const authFiles: JsonObject[] = [
+ {
+ name: 'codex-personal.json',
+ provider: 'codex',
+ type: 'codex',
+ source: 'file',
+ path: 'C:\\EasyCLIProxyAPI\\oauth\\codex-personal.json',
+ auth_index: 'mock-codex-1',
+ account_id: 'acct_mock_codex',
+ email: 'codex.mock@example.com',
+ disabled: false,
+ priority: 0,
+ success: 138,
+ failed: 4,
+ modtime: Date.now() - 20 * 60_000,
+ recent_requests: Array.from({ length: 20 }, (_, index) => ({
+ time: isoHoursAgo((19 - index) / 3),
+ success: 3 + index % 4,
+ failed: index % 8 === 0 ? 1 : 0,
+ })),
+ excluded_models: [],
+ },
+ {
+ name: 'claude-team.json',
+ provider: 'claude',
+ type: 'anthropic',
+ source: 'file',
+ path: 'C:\\EasyCLIProxyAPI\\oauth\\claude-team.json',
+ auth_index: 'mock-claude-1',
+ email: 'claude.mock@example.com',
+ disabled: false,
+ priority: 0,
+ success: 92,
+ failed: 1,
+ modtime: Date.now() - 45 * 60_000,
+ excluded_models: ['claude-legacy-*'],
+ },
+ {
+ name: 'gemini-runtime',
+ provider: 'gemini',
+ type: 'gemini',
+ source: 'runtime',
+ runtime_only: true,
+ auth_index: 'mock-gemini-runtime',
+ disabled: false,
+ success: 27,
+ failed: 0,
+ },
+ ];
+ const providerConfig: Record<(typeof PROVIDER_SECTIONS)[number], JsonObject[]> = {
+ 'codex-api-key': [
+ {
+ name: 'Codex Direct',
+ 'api-key': 'sk-mock-codex-direct',
+ 'base-url': 'https://api.openai.com/v1',
+ priority: 10,
+ models: [
+ { name: 'gpt-5.2-codex', alias: 'codex-latest', thinking: { levels: ['low', 'medium', 'high', 'xhigh'] } },
+ { name: 'gpt-5.1-codex', thinking: { levels: ['low', 'medium', 'high'] } },
+ ],
+ },
+ {
+ name: 'DeepSeek',
+ 'api-key': 'sk-mock-deepseek',
+ 'base-url': 'https://api.deepseek.com',
+ priority: 20,
+ models: [
+ { name: 'deepseek-chat' },
+ { name: 'deepseek-reasoner', thinking: { levels: ['high'] } },
+ ],
+ },
+ ],
+ 'claude-api-key': [{
+ name: 'Claude Direct',
+ 'api-key': 'sk-ant-mock-claude',
+ 'base-url': 'https://api.anthropic.com',
+ models: [
+ { name: 'claude-opus-4-6' },
+ { name: 'claude-sonnet-4-6' },
+ { name: 'claude-haiku-4-5' },
+ ],
+ }],
+ 'gemini-api-key': [{
+ name: 'Gemini AI Studio',
+ 'api-key': 'AIza-mock-gemini',
+ 'base-url': 'https://generativelanguage.googleapis.com',
+ models: [{ name: 'gemini-3-pro' }, { name: 'gemini-3-flash' }],
+ }],
+ 'openai-compatibility': [{
+ name: 'Mock OpenAI Compatible',
+ 'base-url': 'https://mock-provider.example.com/v1',
+ 'api-key-entries': [
+ { 'api-key': 'sk-mock-compatible-a', 'auth-index': 'mock-compatible-a' },
+ { 'api-key': 'sk-mock-compatible-b', 'auth-index': 'mock-compatible-b' },
+ ],
+ models: [
+ { name: 'mock-large', alias: 'mock-default' },
+ { name: 'mock-fast' },
+ ],
+ priority: 30,
+ disabled: false,
+ }],
+ };
+ const analysis = {
+ models: [
+ { key: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', requests: 182, failures: 4, tokens: 1_284_600 },
+ { key: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', requests: 96, failures: 2, tokens: 684_200 },
+ { key: 'gemini-3-pro', label: 'Gemini 3 Pro', requests: 34, failures: 1, tokens: 218_500 },
+ ],
+ providers: [
+ { key: 'openai-oauth', label: 'OpenAI OAuth', requests: 182, failures: 4, tokens: 1_284_600 },
+ { key: 'claude-oauth', label: 'Claude OAuth', requests: 96, failures: 2, tokens: 684_200 },
+ { key: 'gemini-api', label: 'Gemini API', requests: 34, failures: 1, tokens: 218_500 },
+ ],
+ sources: [
+ { key: 'codex', label: 'Codex', requests: 164, failures: 4, tokens: 1_102_000 },
+ { key: 'claude-code', label: 'Claude Code', requests: 114, failures: 2, tokens: 752_000 },
+ { key: 'other', label: 'Other', requests: 34, failures: 1, tokens: 333_300 },
+ ],
+ apiKeys: [
+ { key: 'mock-key-hash', label: '浏览器 Mock 密钥', requests: 312, failures: 7, tokens: 2_187_300 },
+ ],
+ };
+ return {
+ scenario,
+ coreStatus: createCoreStatus(scenario),
+ guiSettings: {
+ host: '127.0.0.1',
+ port: 8317,
+ runOnStartup: true,
+ closeBehavior: 'ask',
+ },
+ coreConfig: {
+ apiKeys: [
+ { apiKey: 'sk-browser-mock-2026', remark: '浏览器 Mock 密钥' },
+ { apiKey: 'sk-browser-demo-secondary', remark: '备用演示密钥' },
+ ],
+ debug: false,
+ commercialMode: false,
+ loggingToFile: true,
+ logsMaxTotalSizeMb: 256,
+ errorLogsMaxFiles: 10,
+ usageStatisticsEnabled: true,
+ redisUsageQueueRetentionSeconds: 60,
+ host: '127.0.0.1',
+ port: 8317,
+ allowLan: false,
+ routingStrategy: 'round-robin',
+ proxyUrl: '',
+ proxyOverride: false,
+ routingSessionAffinity: true,
+ routingSessionAffinityTtl: '30m',
+ disableCooling: false,
+ requestRetry: 3,
+ maxRetryCredentials: 0,
+ maxRetryInterval: 30,
+ streamingBootstrapRetries: 0,
+ },
+ softwareSettings: {
+ closeBehavior: 'ask',
+ autostartEnabled: false,
+ startCoreOnLaunch: true,
+ silentStartEnabled: false,
+ defaultTerminal: 'auto',
+ availableTerminals: [
+ { id: 'auto', label: '自动选择' },
+ { id: 'windows-terminal', label: 'Windows Terminal' },
+ { id: 'powershell', label: 'PowerShell' },
+ ],
+ },
+ tlsSettings: { enabled: false, cert: '', key: '' },
+ sensitiveWords: {
+ antigravitySensitiveWords: ['internal-only', 'mock-secret'],
+ devinSensitiveWords: ['private-repository'],
+ },
+ appUpdateInfo: {
+ currentVersion: '0.2.97',
+ latestVersion: '0.3.0',
+ updateAvailable: true,
+ releaseUrl: 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest',
+ releaseNotes: {
+ 'zh-CN': '## Browser Mock 更新示例\n\n- 新增界面调试模式\n- 改进启动体验',
+ en: '## Browser Mock release\n\n- Added UI mock mode',
+ },
+ publishedAt: isoHoursAgo(18),
+ autoUpdateSupported: true,
+ downloadSizeBytes: 48 * 1024 * 1024,
+ unsupportedReason: null,
+ },
+ appUpdateTask: {
+ running: false,
+ cancellable: false,
+ phase: 'idle',
+ targetVersion: null,
+ downloadedBytes: 0,
+ totalBytes: null,
+ percent: null,
+ message: null,
+ },
+ coreLatest: { version: '7.3.16', assetName: 'CLIProxyAPI_7.3.16_windows_amd64.zip' },
+ coreInstallTask: {
+ running: false,
+ cancellable: false,
+ phase: 'idle',
+ downloaded: 0,
+ total: null,
+ percent: null,
+ message: null,
+ result: null,
+ },
+ versionSource: { source: 'github', gitcodeAvailable: true, customMirrors: ['https://gh-proxy.example.com/'] },
+ providerConfig,
+ authFiles,
+ oauthExcludedModels: { codex: [], claude: ['claude-legacy-*'], gemini: [] } as Record,
+ oauthSessions: new Map(),
+ analysis,
+ usageOverview: {
+ totalRequests: 312,
+ successCount: 301,
+ failureCount: 7,
+ canceledCount: 4,
+ successRate: 96.47,
+ inputTokens: 1_264_000,
+ outputTokens: 486_500,
+ reasoningTokens: 286_800,
+ cacheReadTokens: 132_000,
+ cacheCreationTokens: 18_000,
+ totalTokens: 2_187_300,
+ rpm: 4.8,
+ tpm: 31_450,
+ tps: 58.4,
+ tpsSampleCount: 284,
+ averageLatencyMs: 1_284,
+ cacheHitRate: 38.6,
+ estimatedCost: 3.8421,
+ pricedRequests: 296,
+ timeline,
+ },
+ usageEvents: events,
+ usagePrices: [
+ {
+ model: 'gpt-5.2-codex', prompt: 1.25, completion: 10, cacheRead: 0.125, cacheCreation: 0,
+ promptConfigured: true, completionConfigured: true, cacheReadConfigured: true, cacheCreationConfigured: false,
+ source: 'mock', sourceModelId: 'gpt-5.2-codex', updatedAtMs: Date.now(),
+ },
+ {
+ model: 'claude-sonnet-4-6', prompt: 3, completion: 15, cacheRead: 0.3, cacheCreation: 3.75,
+ promptConfigured: true, completionConfigured: true, cacheReadConfigured: true, cacheCreationConfigured: true,
+ source: 'mock', sourceModelId: 'claude-sonnet-4-6', updatedAtMs: Date.now(),
+ },
+ ],
+ usageStorage: { maxDatabaseSizeMb: 512, databaseSizeBytes: 84 * 1024 * 1024, totalRecords: 312, deletedRecords: 0 },
+ agentStatuses: createAgentStatuses(),
+ deepSeekStatus: {
+ running: false,
+ pid: null as number | null,
+ mode: null as string | null,
+ },
+ thinkingAliases: [
+ {
+ sourceModel: 'gpt-5.2-codex',
+ alias: 'codex-high',
+ effort: 'high' as string | null,
+ provider: 'Codex OAuth',
+ kind: 'codex-oauth',
+ oauthChannel: 'codex' as string | null,
+ },
+ ],
+ speedAliases: [
+ {
+ sourceModel: 'gpt-5.2-codex',
+ alias: 'codex-fast',
+ serviceTier: 'priority',
+ provider: 'Codex OAuth',
+ kind: 'codex-oauth',
+ oauthChannel: 'codex' as string | null,
+ },
+ ],
+ aliasSources: [
+ { id: 'codex:gpt-5.2-codex', model: 'gpt-5.2-codex', displayName: 'GPT-5.2 Codex', provider: 'Codex OAuth', kind: 'codex-oauth', protocol: 'openai', reasoningLevels: ['low', 'medium', 'high', 'xhigh'] },
+ { id: 'claude:claude-sonnet-4-6', model: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', provider: 'Claude OAuth', kind: 'claude-oauth', protocol: 'anthropic', reasoningLevels: ['low', 'medium', 'high'] },
+ ],
+ codexCatalog: {
+ revision: 'mock-catalog-r1',
+ models: ['gpt-5.2-codex', 'gpt-5.1-codex'].map((slug) => {
+ const configuration = {
+ display_name: slug === 'gpt-5.2-codex' ? 'GPT-5.2 Codex' : 'GPT-5.1 Codex',
+ description: 'Browser Mock 模型目录',
+ context_window: 272_000,
+ max_context_window: 400_000,
+ effective_context_window_percent: 90,
+ auto_compact_token_limit: 244_800,
+ default_reasoning_level: 'medium',
+ supported_reasoning_levels: ['low', 'medium', 'high', 'xhigh'].map((effort) => ({ effort, description: `${effort} reasoning effort` })),
+ input_modalities: ['text', 'image'],
+ visibility: 'list',
+ supports_parallel_tool_calls: true,
+ };
+ return {
+ slug,
+ hasOfficialTemplate: true,
+ contextSource: 'template',
+ customized: false,
+ configuration,
+ defaults: clone(configuration),
+ };
+ }),
+ },
+ harnessCatalog: {
+ revision: 'mock-harness-r1',
+ models: [
+ { id: 'deepseek-chat', defaults: { name: 'DeepSeek Chat', input: ['text'], contextWindow: 64_000, maxTokens: 8_192 }, configuration: {} },
+ { id: 'deepseek-reasoner', defaults: { name: 'DeepSeek Reasoner', input: ['text'], contextWindow: 64_000, maxTokens: 16_384 }, configuration: { reasoningEfforts: { high: 'high' } } },
+ ],
+ provider: { api: 'openai-completions', defaultContextWindow: 64_000 },
+ baseUrl: 'http://127.0.0.1:8317/v1',
+ defaultModel: 'deepseek-chat',
+ configured: true,
+ },
+ codexSessions: [
+ { id: 'mock-session-1', title: '实现 Browser Mock', cwd: 'E:\\projects\\mock-demo', modelProvider: 'cpa-gui', archived: false, updatedAtMs: Date.now() - 20 * 60_000, databasePath: 'C:\\Users\\Mock\\.codex\\state.sqlite' },
+ { id: 'mock-session-2', title: 'TypeScript 类型学习', cwd: 'E:\\projects\\typescript-study', modelProvider: 'openai', archived: false, updatedAtMs: Date.now() - 3 * 60 * 60_000, databasePath: 'C:\\Users\\Mock\\.codex\\state.sqlite' },
+ { id: 'mock-session-3', title: '旧版界面检查', cwd: 'E:\\projects\\legacy-ui', modelProvider: 'cpa-gui', archived: true, updatedAtMs: Date.now() - 3 * 24 * 60 * 60_000, databasePath: 'C:\\Users\\Mock\\.codex\\state.sqlite' },
+ ],
+ };
+}
+
+function findAuthFile(state: BrowserMockState, name: string) {
+ return state.authFiles.find((file) => file.name === name);
+}
+
+function mockModelDefinitions() {
+ return {
+ models: [
+ { id: 'gpt-5.2-codex', display_name: 'GPT-5.2 Codex' },
+ { id: 'claude-sonnet-4-6', display_name: 'Claude Sonnet 4.6' },
+ { id: 'gemini-3-pro', display_name: 'Gemini 3 Pro' },
+ { id: 'deepseek-chat', display_name: 'DeepSeek Chat' },
+ ],
+ };
+}
+
+function mockApiCall(body: JsonObject) {
+ const url = readString(body.url).toLowerCase();
+ if (url.includes('/models')) {
+ return {
+ status_code: 200,
+ body: {
+ data: [
+ { id: 'mock-large', display_name: 'Mock Large' },
+ { id: 'mock-fast', display_name: 'Mock Fast' },
+ { id: 'mock-reasoner', display_name: 'Mock Reasoner' },
+ ],
+ },
+ };
+ }
+ if (url.includes('/api/oauth/profile')) {
+ return { status_code: 200, body: { account: { has_claude_pro: true, has_claude_max: false } } };
+ }
+ if (url.includes('wham/usage')) {
+ return {
+ status_code: 200,
+ body: {
+ rate_limit: {
+ primary_window: { used_percent: 28, reset_after_seconds: 7_200, limit_window_seconds: 18_000 },
+ secondary_window: { used_percent: 42, reset_after_seconds: 345_600, limit_window_seconds: 604_800 },
+ },
+ },
+ };
+ }
+ if (url.includes('anthropic.com/api/oauth/usage')) {
+ return {
+ status_code: 200,
+ body: {
+ five_hour: { utilization: 24, resets_at: isoHoursAgo(-3) },
+ seven_day: { utilization: 41, resets_at: isoHoursAgo(-72) },
+ },
+ };
+ }
+ if (url.includes('retrieveuserquotasummary')) {
+ return {
+ status_code: 200,
+ body: {
+ groups: [{ display_name: 'Gemini Pro', buckets: [{ window: '5h', remaining_fraction: 0.74, reset_time: isoHoursAgo(-3) }] }],
+ },
+ };
+ }
+ if (url.includes('billing')) {
+ return {
+ status_code: 200,
+ body: {
+ periodType: 'weekly',
+ usagePercent: 36,
+ periodEnd: isoHoursAgo(-48),
+ productUsage: [{ product: 'Grok Code', usagePercent: 31 }],
+ },
+ };
+ }
+ return {
+ status_code: 200,
+ body: {
+ id: 'mock-response',
+ choices: [{ message: { content: 'Browser Mock response' } }],
+ content: [{ type: 'text', text: 'Browser Mock response' }],
+ },
+ };
+}
+
+function managementResponse(state: BrowserMockState, payload: JsonObject) {
+ const request = asObject(payload.request);
+ const method = readString(request.method).toUpperCase();
+ const path = readString(request.path);
+ const query = asObject(request.query);
+ const body = request.body;
+
+ if (method === 'GET' && path === '/config') {
+ return clone(state.providerConfig);
+ }
+ if (PROVIDER_SECTIONS.some((section) => path === `/${section}`)) {
+ const section = path.slice(1) as (typeof PROVIDER_SECTIONS)[number];
+ if (method === 'GET') return { [section]: clone(state.providerConfig[section]) };
+ if (method === 'PUT') {
+ state.providerConfig[section] = asArray(body).map((item) => clone(asObject(item)));
+ return { [section]: clone(state.providerConfig[section]) };
+ }
+ if (method === 'PATCH' && section === 'openai-compatibility') {
+ const patch = asObject(body);
+ const index = readNumber(patch.index, -1);
+ const current = state.providerConfig[section][index];
+ if (current) state.providerConfig[section][index] = { ...current, ...asObject(patch.value) };
+ return { [section]: clone(state.providerConfig[section]) };
+ }
+ }
+ if (method === 'GET' && path === '/auth-files') {
+ return { files: clone(state.authFiles), observed_at: new Date().toISOString() };
+ }
+ if (method === 'GET' && path === '/auth-files/download') {
+ const file = findAuthFile(state, readString(query.name));
+ return clone(file ?? { name: query.name, excluded_models: [] });
+ }
+ if (method === 'GET' && path === '/auth-files/models') return mockModelDefinitions();
+ if (method === 'GET' && path.startsWith('/model-definitions/')) return mockModelDefinitions();
+ if (method === 'GET' && path === '/oauth-excluded-models') {
+ return { 'oauth-excluded-models': clone(state.oauthExcludedModels) };
+ }
+ if (method === 'PATCH' && path === '/oauth-excluded-models') {
+ const patch = asObject(body);
+ state.oauthExcludedModels[readString(patch.provider)] = asArray(patch.models).map(String);
+ return { 'oauth-excluded-models': clone(state.oauthExcludedModels) };
+ }
+ if (method === 'DELETE' && path === '/oauth-excluded-models') {
+ delete state.oauthExcludedModels[readString(query.provider)];
+ return null;
+ }
+ if (method === 'PATCH' && (path === '/auth-files/fields' || path === '/auth-files/status')) {
+ const patch = asObject(body);
+ const file = findAuthFile(state, readString(patch.name));
+ if (file) Object.assign(file, patch);
+ return clone(file ?? null);
+ }
+ if (method === 'DELETE' && path === '/auth-files') {
+ const index = state.authFiles.findIndex((file) => file.name === query.name);
+ if (index >= 0) state.authFiles.splice(index, 1);
+ return null;
+ }
+ if (method === 'POST' && path === '/api-call') return mockApiCall(asObject(body));
+ if (method === 'DELETE' && path === '/oauth-session') return null;
+ return {};
+}
+
+function updateCoreConfig(state: BrowserMockState, payload: JsonObject) {
+ Object.assign(state.coreConfig, asObject(payload.settings));
+ state.guiSettings.host = state.coreConfig.host;
+ state.guiSettings.port = state.coreConfig.port;
+ return clone(state.coreConfig);
+}
+
+function createActionResult(payload: JsonObject) {
+ return {
+ outcome: 'updated',
+ enabled: true,
+ model: readString(payload.model) || 'gpt-5.2-codex',
+ changedFiles: ['C:\\Users\\Mock\\.config\\agent\\settings.json'],
+ conflictFiles: [],
+ };
+}
+
+const ERROR_SCENARIO_COMMANDS = new Set([
+ 'get_core_status',
+ 'get_gui_settings',
+ 'get_core_config_settings',
+ 'check_app_update',
+ 'check_latest_core',
+ 'management_request',
+ 'get_usage_overview',
+ 'get_agent_config_statuses',
+]);
+
+export function createBrowserMockRuntime(
+ scenario: BrowserMockScenario,
+ emitEvent: EmitMockEvent = () => {},
+ delayMs = 0,
+): BrowserMockRuntime {
+ const state = createState(scenario);
+ const emit = (event: string, payload: unknown) => emitEvent(event, clone(payload));
+
+ const invoke = async (command: string, rawPayload?: unknown): Promise => {
+ await sleep(delayMs);
+ const payload = asObject(rawPayload);
+ if (scenario === 'error' && ERROR_SCENARIO_COMMANDS.has(command)) {
+ throw new Error(`Browser Mock error scenario: ${command}`);
+ }
+
+ switch (command) {
+ case 'plugin:app|version': return '0.2.97-mock';
+ case 'plugin:app|name': return 'EasyCLIProxyAPI Browser Mock';
+ case 'plugin:app|tauri_version': return '2.x-mock';
+ case 'plugin:app|identifier': return 'com.cpa.gui.browser-mock';
+ case 'plugin:dialog|open': {
+ const options = asObject(payload.options);
+ return options.directory
+ ? 'C:\\Users\\Mock\\Projects\\demo'
+ : 'C:\\Users\\Mock\\Certificates\\mock.pem';
+ }
+ case 'plugin:dialog|save': return 'C:\\Users\\Mock\\Downloads\\mock-output.json';
+ case 'detect_core_platform': return { os: 'windows', arch: 'x86_64', assetOs: 'windows', assetArch: 'amd64', archiveKind: 'zip' };
+ case 'get_linux_system_theme': return 'light';
+ case 'set_app_locale':
+ case 'open_external_url':
+ case 'open_oauth_url':
+ case 'resolve_windows_close_request':
+ case 'open_auth_files_directory':
+ case 'open_core_logs_directory':
+ case 'restart_agent_app':
+ case 'restart_codex_app':
+ case 'restart_opencode_app':
+ case 'check_codex_oauth_login':
+ case 'restore_codex_official_config':
+ case 'create_agent_config_backup':
+ case 'restore_agent_config_backup':
+ case 'launch_agent': return null;
+
+ case 'get_core_status': return clone(state.coreStatus);
+ case 'start_core_process': {
+ if (!state.coreStatus.installed) throw new Error('Browser Mock:请先安装内核');
+ Object.assign(state.coreStatus, { running: true, ready: true, managed: true, starting: false, processId: 42817, message: 'Browser Mock 内核运行中' });
+ state.guiSettings.runOnStartup = true;
+ emit('core-status-changed', state.coreStatus);
+ return clone(state.coreStatus);
+ }
+ case 'stop_core_process': {
+ Object.assign(state.coreStatus, { running: false, ready: false, managed: false, starting: false, processId: null, message: 'Browser Mock 内核已停止' });
+ state.guiSettings.runOnStartup = false;
+ emit('core-status-changed', state.coreStatus);
+ return clone(state.coreStatus);
+ }
+ case 'restart_core_process': {
+ if (!state.coreStatus.installed) throw new Error('Browser Mock:请先安装内核');
+ Object.assign(state.coreStatus, { running: true, ready: true, managed: true, starting: false, processId: 42818, message: 'Browser Mock 内核已重启' });
+ emit('core-status-changed', state.coreStatus);
+ return clone(state.coreStatus);
+ }
+ case 'get_gui_settings': return clone(state.guiSettings);
+ case 'get_core_config_settings': return clone(state.coreConfig);
+ case 'get_software_settings': return clone(state.softwareSettings);
+ case 'get_core_tls_settings': return clone(state.tlsSettings);
+ case 'get_core_sensitive_words_settings': return clone(state.sensitiveWords);
+ case 'save_core_sensitive_words_settings': {
+ state.sensitiveWords = clone(asObject(payload.settings)) as typeof state.sensitiveWords;
+ emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
+ return clone(state.sensitiveWords);
+ }
+ case 'save_software_settings': {
+ Object.assign(state.softwareSettings, asObject(payload.settings));
+ return clone(state.softwareSettings);
+ }
+ case 'save_core_tls_settings': {
+ Object.assign(state.tlsSettings, asObject(payload.settings));
+ emit('config-files-changed', { paths: ['cpa-core/config.yaml'], errors: [] });
+ return clone(state.tlsSettings);
+ }
+ case 'save_core_logging_settings':
+ case 'save_network_endpoint_settings':
+ case 'save_retry_settings':
+ case 'save_session_routing_settings': return updateCoreConfig(state, payload);
+ case 'set_core_routing_strategy': {
+ state.coreConfig.routingStrategy = readString(payload.strategy);
+ return clone(state.coreConfig);
+ }
+ case 'set_core_proxy_url': {
+ state.coreConfig.proxyUrl = readString(payload.proxyUrl);
+ return clone(state.coreConfig);
+ }
+ case 'set_core_session_affinity': {
+ state.coreConfig.routingSessionAffinity = Boolean(payload.enabled);
+ return clone(state.coreConfig);
+ }
+ case 'set_core_session_affinity_ttl': {
+ state.coreConfig.routingSessionAffinityTtl = readString(payload.ttl);
+ return clone(state.coreConfig);
+ }
+ case 'add_core_api_key': {
+ state.coreConfig.apiKeys.push({ apiKey: readString(payload.apiKey), remark: readString(payload.remark) });
+ return clone(state.coreConfig);
+ }
+ case 'update_core_api_key': {
+ const entry = state.coreConfig.apiKeys.find((item) => item.apiKey === payload.originalApiKey);
+ if (entry) Object.assign(entry, { apiKey: readString(payload.apiKey), remark: readString(payload.remark) });
+ return clone(state.coreConfig);
+ }
+ case 'delete_core_api_key': {
+ state.coreConfig.apiKeys = state.coreConfig.apiKeys.filter((item) => item.apiKey !== payload.apiKey);
+ return clone(state.coreConfig);
+ }
+ case 'set_core_management_secret_key':
+ case 'clear_core_management_secret_key': return clone(state.coreConfig);
+
+ case 'check_app_update': return clone(state.appUpdateInfo);
+ case 'get_app_update_task': return clone(state.appUpdateTask);
+ case 'start_app_update': {
+ Object.assign(state.appUpdateTask, {
+ running: true,
+ cancellable: true,
+ phase: 'downloading',
+ targetVersion: state.appUpdateInfo.latestVersion,
+ downloadedBytes: 18 * 1024 * 1024,
+ totalBytes: state.appUpdateInfo.downloadSizeBytes,
+ percent: 38,
+ message: 'Browser Mock 正在下载更新',
+ });
+ emit('app-update-progress', state.appUpdateTask);
+ return null;
+ }
+ case 'cancel_app_update': {
+ Object.assign(state.appUpdateTask, { running: false, cancellable: false, phase: 'cancelled', message: 'Browser Mock 已取消更新' });
+ emit('app-update-progress', state.appUpdateTask);
+ return null;
+ }
+ case 'check_latest_core': return clone(state.coreLatest);
+ case 'get_core_install_task': return clone(state.coreInstallTask);
+ case 'install_core_version':
+ case 'install_bundled_core': {
+ const result = {
+ version: readString(payload.version) || state.coreLatest.version,
+ assetName: state.coreLatest.assetName,
+ installDir: state.coreStatus.installDir,
+ binaryPath: 'C:\\EasyCLIProxyAPI\\cpa-core\\cli-proxy-api.exe',
+ };
+ Object.assign(state.coreStatus, { installed: true, currentVersion: result.version, binaryPath: result.binaryPath, message: 'Browser Mock 内核已安装' });
+ Object.assign(state.coreInstallTask, { running: false, cancellable: false, phase: 'completed', downloaded: 64 * 1024 * 1024, total: 64 * 1024 * 1024, percent: 100, message: 'Browser Mock 安装完成', result });
+ emit('core-install-progress', state.coreInstallTask);
+ emit('core-status-changed', state.coreStatus);
+ return clone(result);
+ }
+ case 'cancel_core_install': return null;
+ case 'detect_bundled_core': return { version: '7.3.15', assetName: 'CLIProxyAPI_7.3.15_windows_amd64.zip' };
+ case 'get_version_source_settings': return clone(state.versionSource);
+ case 'set_download_source': {
+ state.versionSource.source = readString(payload.source) || 'github';
+ emit('version-download-source-changed', state.versionSource);
+ return clone(state.versionSource);
+ }
+ case 'add_custom_download_mirror': {
+ const url = readString(payload.url);
+ if (url && !state.versionSource.customMirrors.includes(url)) state.versionSource.customMirrors.push(url);
+ return clone(state.versionSource);
+ }
+ case 'remove_custom_download_mirror': {
+ state.versionSource.customMirrors = state.versionSource.customMirrors.filter((url) => url !== payload.url);
+ if (state.versionSource.source === `custom:${payload.url}`) state.versionSource.source = 'github';
+ return clone(state.versionSource);
+ }
+ case 'set_prefer_gitcode_downloads': return clone(state.versionSource);
+
+ case 'management_request': return clone(managementResponse(state, payload));
+ case 'upload_auth_file': {
+ const name = readString(payload.name) || 'uploaded-mock.json';
+ state.authFiles.push({ name, provider: 'codex', type: 'codex', source: 'file', auth_index: `mock-upload-${state.authFiles.length + 1}`, disabled: false, priority: 0 });
+ return { ok: true, name };
+ }
+ case 'resolve_api_access_remarks': {
+ return asArray(payload.queries).map((query) => {
+ const item = asObject(query);
+ return readString(item.providerName) || 'Browser Mock Provider';
+ });
+ }
+ case 'save_api_access_remark': return null;
+ case 'provider_health_probe': return { firstTokenLatencyMs: 184, responseLatencyMs: 642 };
+
+ case 'list_oauth_browsers': return [
+ { id: 'default', label: '系统默认浏览器' },
+ { id: 'chrome', label: 'Google Chrome (Mock)' },
+ { id: 'edge', label: 'Microsoft Edge (Mock)' },
+ ];
+ case 'start_oauth_login': {
+ const provider = readString(payload.provider) || 'codex';
+ const session = `mock-oauth-${provider}-${Date.now()}`;
+ state.oauthSessions.set(session, provider);
+ return { url: `https://example.com/mock-oauth/${provider}?state=${session}`, state: session, opened: true, openError: null };
+ }
+ case 'get_oauth_status': {
+ const session = readString(payload.state);
+ const provider = state.oauthSessions.get(session);
+ if (provider && !state.authFiles.some((file) => file.name === `${provider}-mock-login.json`)) {
+ state.authFiles.push({
+ name: `${provider}-mock-login.json`, provider, type: provider, source: 'file',
+ auth_index: `mock-${provider}-${state.authFiles.length + 1}`, priority: 0, disabled: false, modtime: Date.now(),
+ });
+ }
+ return { status: 'ok', error: null };
+ }
+ case 'submit_oauth_callback': return null;
+
+ case 'get_usage_collector_status': return {
+ state: state.coreStatus.ready ? 'collecting' : 'waiting-core',
+ message: state.coreStatus.ready ? 'Browser Mock 正在采集使用记录' : '等待内核启动',
+ lastCollectedAt: new Date().toISOString(),
+ totalRecords: state.usageOverview.totalRequests,
+ };
+ case 'get_usage_overview': return clone(state.usageOverview);
+ case 'get_usage_analysis': return clone(state.analysis);
+ case 'get_usage_events': {
+ const query = asObject(payload.query);
+ const page = Math.max(1, readNumber(query.page, 1));
+ const pageSize = Math.max(1, readNumber(query.page_size, 50));
+ const start = (page - 1) * pageSize;
+ return {
+ items: clone(state.usageEvents.slice(start, start + pageSize)),
+ total: state.usageEvents.length,
+ page,
+ pageSize,
+ totalPages: Math.max(1, Math.ceil(state.usageEvents.length / pageSize)),
+ };
+ }
+ case 'get_usage_pricing': {
+ const rows = state.usagePrices.map((price, index) => ({
+ model: price.model,
+ requests: index === 0 ? 182 : 96,
+ inputTokens: index === 0 ? 820_000 : 386_000,
+ outputTokens: index === 0 ? 312_000 : 142_000,
+ cacheReadTokens: index === 0 ? 82_000 : 44_000,
+ cacheCreationTokens: index === 0 ? 0 : 18_000,
+ totalTokens: index === 0 ? 1_214_000 : 590_000,
+ estimatedCost: index === 0 ? 1.92 : 1.71,
+ price: clone(price),
+ }));
+ return { rows, totalCost: 3.63, totalRequests: 278, pricedRequests: 278, savedPrices: state.usagePrices.length };
+ }
+ case 'get_usage_storage_settings': return clone(state.usageStorage);
+ case 'save_usage_storage_settings': {
+ state.usageStorage.maxDatabaseSizeMb = readNumber(payload.maxDatabaseSizeMb);
+ state.usageStorage.deletedRecords = 0;
+ return clone(state.usageStorage);
+ }
+ case 'shrink_usage_database': {
+ const targetMb = readNumber(payload.targetDatabaseSizeMb, 64);
+ state.usageStorage.databaseSizeBytes = targetMb * 1024 * 1024;
+ state.usageStorage.deletedRecords = 42;
+ state.usageStorage.totalRecords = Math.max(0, state.usageStorage.totalRecords - 42);
+ return clone(state.usageStorage);
+ }
+ case 'repair_usage_cache_records': return { scanned: 312, repaired: 3, deleted: 1, backupPath: 'C:\\EasyCLIProxyAPI\\usage-records\\backups\\mock.sqlite' };
+ case 'save_usage_model_price': {
+ const next = asObject(payload.price ?? payload);
+ const model = readString(next.model);
+ const current = state.usagePrices.find((price) => price.model === model);
+ const normalized = {
+ model,
+ prompt: readNumber(next.prompt),
+ completion: readNumber(next.completion),
+ cacheRead: readNumber(next.cacheRead),
+ cacheCreation: readNumber(next.cacheCreation),
+ promptConfigured: true,
+ completionConfigured: true,
+ cacheReadConfigured: true,
+ cacheCreationConfigured: true,
+ source: 'manual',
+ sourceModelId: model,
+ updatedAtMs: Date.now(),
+ };
+ if (current) Object.assign(current, normalized); else state.usagePrices.push(normalized);
+ return null;
+ }
+ case 'delete_usage_model_price': {
+ state.usagePrices = state.usagePrices.filter((price) => price.model !== payload.model);
+ return null;
+ }
+ case 'preview_usage_model_prices': return {
+ source: readString(payload.source) || 'models-dev',
+ sourceUrl: 'https://example.com/browser-mock-prices.json',
+ matches: clone(state.usagePrices),
+ unmatched: ['mock-unknown-model'],
+ };
+ case 'apply_usage_model_prices': return { imported: asArray(payload.prices).length };
+
+ case 'get_agent_config_statuses':
+ case 'refresh_agent_config_statuses': return clone(state.agentStatuses);
+ case 'get_agent_models': return [
+ { name: 'gpt-5.2-codex', alias: 'codex-latest', displayName: 'GPT-5.2 Codex', isAlias: true, contextWindow: 272_000, inputModalities: ['text', 'image'] },
+ { name: 'claude-opus-4-6', displayName: 'Claude Opus 4.6', contextWindow: 200_000, inputModalities: ['text', 'image'] },
+ { name: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', contextWindow: 200_000, inputModalities: ['text', 'image'] },
+ { name: 'claude-haiku-4-5', displayName: 'Claude Haiku 4.5', contextWindow: 200_000, inputModalities: ['text', 'image'] },
+ { name: 'gemini-3-pro', displayName: 'Gemini 3 Pro', contextWindow: 1_000_000, inputModalities: ['text', 'image'] },
+ { name: 'deepseek-chat', displayName: 'DeepSeek Chat', contextWindow: 64_000, inputModalities: ['text'] },
+ ];
+ case 'update_agent_config':
+ case 'apply_agent_config_template':
+ case 'install_pi_provider':
+ case 'update_pi_provider':
+ case 'repair_pi_provider':
+ case 'set_agent_config_enabled':
+ case 'close_codex_config_modification': return createActionResult(payload);
+ case 'uninstall_pi_provider': return { ...createActionResult(payload), enabled: false, model: null };
+ case 'check_pi_provider_update': return { installedVersion: '0.4.2', latestVersion: '0.4.3', updateAvailable: true };
+ case 'clear_codex_config': return ['C:\\Users\\Mock\\.codex\\config.toml'];
+ case 'preview_agent_config_template': return { revision: 'mock-template-r1', files: ['settings.json', 'models.json'] };
+ case 'get_deepseek_harness_process_status': return clone(state.deepSeekStatus);
+ case 'stop_deepseek_harness_process': {
+ state.deepSeekStatus = { running: false, pid: null, mode: null };
+ return clone(state.deepSeekStatus);
+ }
+ case 'restart_deepseek_harness_process': {
+ state.deepSeekStatus = { running: true, pid: 43110, mode: 'web' };
+ return clone(state.deepSeekStatus);
+ }
+ case 'list_agent_config_backups': return {
+ versions: [{
+ id: 'mock-backup-2026-09-25', createdAt: isoHoursAgo(5), fileCount: 2,
+ location: 'C:\\EasyCLIProxyAPI\\agent-backups\\mock-backup-2026-09-25',
+ files: [
+ { path: 'settings.json', exists: true, size: 1_428 },
+ { path: 'models.json', exists: true, size: 3_260 },
+ ],
+ restorable: true, error: null,
+ }],
+ };
+ case 'preview_agent_config_backup': return {
+ revision: 'mock-backup-r1',
+ files: [{ path: 'settings.json', exists: true, size: 1_428 }],
+ differences: [{ file: 'settings.json', field: 'model', before: 'gpt-5.1-codex', after: 'gpt-5.2-codex' }],
+ };
+ case 'delete_agent_config_backup': return null;
+
+ case 'get_thinking_aliases': return clone(state.thinkingAliases);
+ case 'get_speed_aliases': return clone(state.speedAliases);
+ case 'get_model_alias_sources': return clone(state.aliasSources);
+ case 'get_thinking_alias_sources': return clone(state.aliasSources);
+ case 'get_speed_alias_sources': return clone(state.aliasSources);
+ case 'get_model_alias_edit_source': {
+ const alias = readString(payload.alias);
+ const entry = state.thinkingAliases.find((item) => item.alias === alias);
+ const source = state.aliasSources.find((item) => item.model === entry?.sourceModel) ?? state.aliasSources[0];
+ return { source: clone(source), revision: 'mock-alias-r1', effort: entry?.effort ?? null, fast: state.speedAliases.some((item) => item.alias === alias) };
+ }
+ case 'create_thinking_alias': {
+ const request = asObject(payload.request ?? payload);
+ const entry = {
+ sourceModel: readString(request.sourceModel ?? request.model),
+ alias: readString(request.alias),
+ effort: readString(request.effort) || null,
+ provider: readString(request.provider) || 'Browser Mock',
+ kind: readString(request.kind) || 'codex-oauth',
+ oauthChannel: readString(request.oauthChannel) || null,
+ };
+ state.thinkingAliases = [...state.thinkingAliases.filter((item) => item.alias !== entry.alias), entry];
+ return clone(state.thinkingAliases);
+ }
+ case 'delete_thinking_alias': {
+ state.thinkingAliases = state.thinkingAliases.filter((item) => item.alias !== payload.alias);
+ return clone(state.thinkingAliases);
+ }
+ case 'create_speed_alias': {
+ const request = asObject(payload.request ?? payload);
+ const entry = {
+ sourceModel: readString(request.sourceModel ?? request.model),
+ alias: readString(request.alias),
+ serviceTier: readString(request.serviceTier) || 'priority',
+ provider: readString(request.provider) || 'Browser Mock',
+ kind: readString(request.kind) || 'codex-oauth',
+ oauthChannel: readString(request.oauthChannel) || null,
+ };
+ state.speedAliases = [...state.speedAliases.filter((item) => item.alias !== entry.alias), entry];
+ return clone(state.speedAliases);
+ }
+ case 'delete_speed_alias': {
+ state.speedAliases = state.speedAliases.filter((item) => item.alias !== payload.alias);
+ return clone(state.speedAliases);
+ }
+
+ case 'get_codex_model_catalog_editor': return clone(state.codexCatalog);
+ case 'save_codex_model_catalog_editor': {
+ const request = asObject(payload.request);
+ const models = asArray(request.models).map(asObject);
+ state.codexCatalog.models = state.codexCatalog.models.map((model) => {
+ const update = models.find((item) => item.slug === model.slug);
+ return update ? { ...model, configuration: clone(asObject(update.configuration)) as typeof model.configuration, customized: true } : model;
+ });
+ state.codexCatalog.revision = `mock-catalog-${Date.now()}`;
+ return { snapshot: clone(state.codexCatalog), synchronizationError: null };
+ }
+ case 'get_deepseek_harness_model_catalog_editor': return clone(state.harnessCatalog);
+ case 'save_deepseek_harness_model_catalog_editor': {
+ const request = asObject(payload.request);
+ state.harnessCatalog = { ...state.harnessCatalog, ...clone(request), revision: `mock-harness-${Date.now()}`, configured: true } as typeof state.harnessCatalog;
+ return clone(state.harnessCatalog);
+ }
+
+ case 'list_codex_sessions': {
+ const request = asObject(payload.request);
+ const offset = Math.max(0, readNumber(request.offset));
+ const limit = Math.max(1, readNumber(request.limit, 50));
+ return {
+ codexHome: 'C:\\Users\\Mock\\.codex',
+ databasePaths: ['C:\\Users\\Mock\\.codex\\state.sqlite'],
+ sessions: clone(state.codexSessions.slice(offset, offset + limit)),
+ totalCount: state.codexSessions.length,
+ offset,
+ limit,
+ hasMore: offset + limit < state.codexSessions.length,
+ warnings: [],
+ };
+ }
+ case 'delete_codex_sessions': {
+ const request = asObject(payload.request);
+ const ids = asArray(request.sessionIds).map(String);
+ state.codexSessions = state.codexSessions.filter((session) => !ids.includes(session.id));
+ return {
+ results: ids.map((id) => ({ sessionId: id, status: 'deleted', message: 'Browser Mock 已删除', backupPath: 'C:\\Users\\Mock\\.codex\\backups\\mock.zip' })),
+ deletedCount: ids.length,
+ failedCount: 0,
+ };
+ }
+ case 'repair_codex_session_metadata': {
+ emit('codex-session-repair-progress', { phase: 'complete', percent: 100, processed: state.codexSessions.length, total: state.codexSessions.length });
+ return {
+ targetProvider: 'cpa-gui', changedRolloutFiles: 2, sqliteRowsUpdated: 2,
+ skippedLockedFiles: [], backupPath: 'C:\\Users\\Mock\\.codex\\backups\\repair.zip',
+ encryptedContentWarning: null, warnings: [],
+ };
+ }
+ case 'preview_codex_session_index_cleanup': return {
+ snapshotSha256: 'mock-snapshot-sha256',
+ candidates: [{ id: 'stale-thread-1', threadName: '已不存在的会话', updatedAt: isoHoursAgo(72) }],
+ };
+ case 'apply_codex_session_index_cleanup': return { prunedEntries: asArray(asObject(payload.request).threadIds).length, backupPath: 'C:\\Users\\Mock\\.codex\\backups\\index.json' };
+
+ case 'get_lan_ipv4': return ['192.168.100.100'];
+ default:
+ if (command.startsWith('plugin:window|') || command.startsWith('plugin:webview|')) return null;
+ console.warn(`[Browser Mock] 尚未实现命令:${command}`, rawPayload);
+ throw new Error(`Browser Mock 尚未实现命令:${command}`);
+ }
+ };
+
+ return { scenario, invoke };
+}
diff --git a/tests/browserMockRuntime.test.ts b/tests/browserMockRuntime.test.ts
new file mode 100644
index 0000000..a94a5f7
--- /dev/null
+++ b/tests/browserMockRuntime.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ createBrowserMockRuntime,
+ resolveBrowserMockOptions,
+} from '../src/mocks/browserMockRuntime';
+
+describe('browser mock options', () => {
+ test('defaults to a running core and accepts stored scenarios', () => {
+ expect(resolveBrowserMockOptions('', null)).toEqual({ mode: 'running', delayMs: 0 });
+ expect(resolveBrowserMockOptions('', 'stopped')).toEqual({ mode: 'stopped', delayMs: 0 });
+ });
+
+ test('URL options override storage and clamp delay', () => {
+ expect(resolveBrowserMockOptions('?mock=empty&mockDelay=9000', 'running'))
+ .toEqual({ mode: 'empty', delayMs: 5000 });
+ expect(resolveBrowserMockOptions('?mock=off&mockDelay=25', 'running'))
+ .toEqual({ mode: 'off', delayMs: 25 });
+ });
+});
+
+describe('browser mock runtime', () => {
+ test('models the core process lifecycle and emits status events', async () => {
+ const events: Array<{ event: string; payload: unknown }> = [];
+ const runtime = createBrowserMockRuntime('stopped', (event, payload) => {
+ events.push({ event, payload });
+ });
+
+ expect(await runtime.invoke('get_core_status')).toMatchObject({
+ installed: true,
+ running: false,
+ ready: false,
+ });
+ expect(await runtime.invoke('start_core_process')).toMatchObject({
+ running: true,
+ ready: true,
+ processId: 42817,
+ });
+ expect(events.at(-1)).toMatchObject({
+ event: 'core-status-changed',
+ payload: { running: true, ready: true },
+ });
+ });
+
+ test('persists config and management mutations in memory', async () => {
+ const runtime = createBrowserMockRuntime('running');
+
+ await runtime.invoke('add_core_api_key', { apiKey: 'sk-test', remark: 'test' });
+ expect(await runtime.invoke('get_core_config_settings')).toMatchObject({
+ apiKeys: expect.arrayContaining([{ apiKey: 'sk-test', remark: 'test' }]),
+ });
+
+ await runtime.invoke('management_request', {
+ request: {
+ method: 'PUT',
+ path: '/gemini-api-key',
+ body: [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }],
+ },
+ });
+ expect(await runtime.invoke('management_request', {
+ request: { method: 'GET', path: '/gemini-api-key' },
+ })).toEqual({
+ 'gemini-api-key': [{ 'api-key': 'AIza-test', models: [{ name: 'gemini-test' }] }],
+ });
+ });
+
+ test('provides an explicit error scenario', async () => {
+ const runtime = createBrowserMockRuntime('error');
+ await expect(runtime.invoke('get_core_status')).rejects.toThrow('Browser Mock error scenario');
+ expect(await runtime.invoke('plugin:app|version')).toBe('0.2.97-mock');
+ });
+});