ccnew 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +107 -0
  2. package/build/icon.ico +0 -0
  3. package/build/icon.png +0 -0
  4. package/core/apply.js +152 -0
  5. package/core/backup.js +53 -0
  6. package/core/constants.js +78 -0
  7. package/core/desktop-service.js +403 -0
  8. package/core/desktop-state.js +1021 -0
  9. package/core/index.js +1468 -0
  10. package/core/paths.js +99 -0
  11. package/core/presets.js +171 -0
  12. package/core/probe.js +70 -0
  13. package/core/routing.js +334 -0
  14. package/core/store.js +218 -0
  15. package/core/utils.js +225 -0
  16. package/core/writers/codex.js +102 -0
  17. package/core/writers/index.js +16 -0
  18. package/core/writers/openclaw.js +93 -0
  19. package/core/writers/opencode.js +91 -0
  20. package/desktop/assets/fml-icon.png +0 -0
  21. package/desktop/assets/march-mark.svg +26 -0
  22. package/desktop/main.js +275 -0
  23. package/desktop/preload.cjs +67 -0
  24. package/desktop/preload.js +49 -0
  25. package/desktop/renderer/app.js +327 -0
  26. package/desktop/renderer/index.html +130 -0
  27. package/desktop/renderer/styles.css +490 -0
  28. package/package.json +111 -0
  29. package/scripts/build-web.mjs +95 -0
  30. package/scripts/desktop-dev.mjs +90 -0
  31. package/scripts/desktop-pack-win.mjs +81 -0
  32. package/scripts/postinstall.mjs +49 -0
  33. package/scripts/prepublish-check.mjs +57 -0
  34. package/scripts/serve-site.mjs +51 -0
  35. package/site/app.js +10 -0
  36. package/site/assets/fml-icon.png +0 -0
  37. package/site/assets/march-mark.svg +26 -0
  38. package/site/index.html +337 -0
  39. package/site/styles.css +840 -0
  40. package/src/App.tsx +1557 -0
  41. package/src/components/layout/app-sidebar.tsx +103 -0
  42. package/src/components/layout/top-toolbar.tsx +44 -0
  43. package/src/components/layout/workspace-tabs.tsx +32 -0
  44. package/src/components/providers/inspector-panel.tsx +84 -0
  45. package/src/components/providers/metric-strip.tsx +26 -0
  46. package/src/components/providers/provider-editor.tsx +87 -0
  47. package/src/components/providers/provider-table.tsx +85 -0
  48. package/src/components/ui/logo-mark.tsx +32 -0
  49. package/src/features/mcp/mcp-view.tsx +45 -0
  50. package/src/features/prompts/prompts-view.tsx +40 -0
  51. package/src/features/providers/providers-view.tsx +40 -0
  52. package/src/features/providers/types.ts +26 -0
  53. package/src/features/skills/skills-view.tsx +44 -0
  54. package/src/hooks/use-control-workspace.ts +235 -0
  55. package/src/index.css +22 -0
  56. package/src/lib/client.ts +726 -0
  57. package/src/lib/query-client.ts +3 -0
  58. package/src/lib/workspace-sections.ts +34 -0
  59. package/src/main.tsx +14 -0
  60. package/src/types.ts +137 -0
  61. package/src/vite-env.d.ts +64 -0
  62. package/src-tauri/README.md +11 -0
package/core/paths.js ADDED
@@ -0,0 +1,99 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export function getHomeDir() {
5
+ return process.env.MARCH_HOME ? path.resolve(process.env.MARCH_HOME) : os.homedir();
6
+ }
7
+
8
+ export function getMarchDir() {
9
+ return path.join(getHomeDir(), ".march");
10
+ }
11
+
12
+ export function getMarchStorePath(platform) {
13
+ return path.join(getMarchDir(), `${platform}.json`);
14
+ }
15
+
16
+ export function getMarchDesktopStatePath() {
17
+ return path.join(getMarchDir(), "desktop-state.json");
18
+ }
19
+
20
+ export function getMarchRoutingPath() {
21
+ return path.join(getMarchDir(), "routing.json");
22
+ }
23
+
24
+ export function getMarchPresetPath() {
25
+ return path.join(getMarchDir(), "presets.json");
26
+ }
27
+
28
+ export function getMarchManagedDir() {
29
+ return path.join(getMarchDir(), "managed");
30
+ }
31
+
32
+ export function getMarchManagedPromptDir(platform) {
33
+ return path.join(getMarchManagedDir(), "prompts", platform);
34
+ }
35
+
36
+ export function getBackupDirRoot() {
37
+ return path.join(getMarchDir(), "backups");
38
+ }
39
+
40
+ export function getCodexDir() {
41
+ return path.join(getHomeDir(), ".codex");
42
+ }
43
+
44
+ export function getCodexConfigPath() {
45
+ return path.join(getCodexDir(), "config.toml");
46
+ }
47
+
48
+ export function getCodexAuthPath() {
49
+ return path.join(getCodexDir(), "auth.json");
50
+ }
51
+
52
+ export function getOpenCodeDir() {
53
+ return path.join(getHomeDir(), ".config", "opencode");
54
+ }
55
+
56
+ export function getOpenCodeConfigPath() {
57
+ return path.join(getOpenCodeDir(), "opencode.json");
58
+ }
59
+
60
+ export function getOpenCodeAgentsPath() {
61
+ return path.join(getOpenCodeDir(), "AGENTS.md");
62
+ }
63
+
64
+ export function getOpenCodeSkillsDir() {
65
+ return path.join(getOpenCodeDir(), "skills");
66
+ }
67
+
68
+ export function getOpenClawDir() {
69
+ return path.join(getHomeDir(), ".openclaw");
70
+ }
71
+
72
+ export function getOpenClawConfigPath() {
73
+ return path.join(getOpenClawDir(), "openclaw.json");
74
+ }
75
+
76
+ export function getOpenClawModelsPath() {
77
+ return path.join(getOpenClawDir(), "agents", "main", "agent", "models.json");
78
+ }
79
+
80
+ export function getOpenClawSkillsDir() {
81
+ return path.join(getOpenClawDir(), "skills");
82
+ }
83
+
84
+ export function getAgentsSkillsDir() {
85
+ return path.join(getHomeDir(), ".agents", "skills");
86
+ }
87
+
88
+ export function getPlatformTargetFiles(platform) {
89
+ switch (platform) {
90
+ case "codex":
91
+ return [getMarchStorePath(platform), getCodexConfigPath(), getCodexAuthPath()];
92
+ case "opencode":
93
+ return [getMarchStorePath(platform), getOpenCodeConfigPath(), getOpenCodeAgentsPath()];
94
+ case "openclaw":
95
+ return [getMarchStorePath(platform), getOpenClawConfigPath(), getOpenClawModelsPath()];
96
+ default:
97
+ throw new Error(`Unsupported platform: ${platform}`);
98
+ }
99
+ }
@@ -0,0 +1,171 @@
1
+ import { BUILTIN_PRESETS, DEFAULT_PRIMARY_MODEL } from "./constants.js";
2
+ import { getMarchDir, getMarchPresetPath } from "./paths.js";
3
+ import { buildOpenClawBaseUrl, ensureDir, normalizeBaseUrl, readJson, writeJson } from "./utils.js";
4
+
5
+ function createEmptyPresetStore() {
6
+ return {
7
+ presets: []
8
+ };
9
+ }
10
+
11
+ function toNormalizedPreset(input, fallback = {}) {
12
+ const name = `${input?.name || fallback?.name || ""}`.trim();
13
+ if (!name) {
14
+ throw new Error("Preset name cannot be empty");
15
+ }
16
+
17
+ const providerName = `${input?.providerName || fallback?.providerName || name}`.trim() || name;
18
+ const commonBaseUrl = normalizeBaseUrl(
19
+ `${input?.commonBaseUrl || input?.baseUrl || fallback?.commonBaseUrl || fallback?.baseUrl || ""}`.trim()
20
+ );
21
+ if (!commonBaseUrl) {
22
+ throw new Error("Preset base URL cannot be empty");
23
+ }
24
+
25
+ const openclawBaseUrl = normalizeBaseUrl(
26
+ `${input?.openclawBaseUrl || fallback?.openclawBaseUrl || buildOpenClawBaseUrl(commonBaseUrl)}`.trim()
27
+ );
28
+
29
+ return {
30
+ name,
31
+ providerName,
32
+ commonBaseUrl,
33
+ openclawBaseUrl,
34
+ model: `${input?.model || fallback?.model || DEFAULT_PRIMARY_MODEL}`.trim() || DEFAULT_PRIMARY_MODEL
35
+ };
36
+ }
37
+
38
+ function normalizeStoreEntry(entry) {
39
+ const preset = toNormalizedPreset(entry, entry);
40
+ return {
41
+ ...preset,
42
+ createdAt: entry?.createdAt || null,
43
+ updatedAt: entry?.updatedAt || null
44
+ };
45
+ }
46
+
47
+ function loadCustomPresetStore() {
48
+ const raw = readJson(getMarchPresetPath(), createEmptyPresetStore());
49
+ return {
50
+ presets: Array.isArray(raw?.presets) ? raw.presets.map((item) => normalizeStoreEntry(item)) : []
51
+ };
52
+ }
53
+
54
+ function saveCustomPresetStore(store) {
55
+ ensureDir(getMarchDir());
56
+ writeJson(getMarchPresetPath(), {
57
+ presets: Array.isArray(store.presets) ? store.presets : []
58
+ });
59
+ }
60
+
61
+ function listBuiltinPresets() {
62
+ return BUILTIN_PRESETS.map((preset) => ({
63
+ ...toNormalizedPreset(preset, preset),
64
+ source: "builtin",
65
+ readonly: true,
66
+ createdAt: null,
67
+ updatedAt: null
68
+ }));
69
+ }
70
+
71
+ function listCustomPresets() {
72
+ return loadCustomPresetStore().presets.map((preset) => ({
73
+ ...preset,
74
+ source: "custom",
75
+ readonly: false
76
+ }));
77
+ }
78
+
79
+ function nameKey(name) {
80
+ return `${name || ""}`.trim().toLowerCase();
81
+ }
82
+
83
+ export function listPresets() {
84
+ const merged = [...listBuiltinPresets(), ...listCustomPresets()];
85
+ const seen = new Set();
86
+ const result = [];
87
+
88
+ // custom preset with same name overrides builtin view
89
+ for (const preset of [...merged].reverse()) {
90
+ const key = nameKey(preset.name);
91
+ if (!key || seen.has(key)) {
92
+ continue;
93
+ }
94
+ seen.add(key);
95
+ result.push(preset);
96
+ }
97
+
98
+ return result.reverse();
99
+ }
100
+
101
+ export function getPreset(name) {
102
+ const key = nameKey(name);
103
+ if (!key) {
104
+ return null;
105
+ }
106
+
107
+ return listPresets().find((preset) => nameKey(preset.name) === key) || null;
108
+ }
109
+
110
+ export function upsertPreset(input) {
111
+ const normalized = toNormalizedPreset(input, input);
112
+ const store = loadCustomPresetStore();
113
+ const targetKey = nameKey(normalized.name);
114
+ const existing = store.presets.find((preset) => nameKey(preset.name) === targetKey);
115
+ const now = new Date().toISOString();
116
+
117
+ if (existing) {
118
+ const next = {
119
+ ...existing,
120
+ ...normalized,
121
+ updatedAt: now
122
+ };
123
+ store.presets = store.presets.map((preset) =>
124
+ nameKey(preset.name) === targetKey ? next : preset
125
+ );
126
+ saveCustomPresetStore(store);
127
+ return {
128
+ ...next,
129
+ source: "custom",
130
+ readonly: false
131
+ };
132
+ }
133
+
134
+ const created = {
135
+ ...normalized,
136
+ createdAt: now,
137
+ updatedAt: now
138
+ };
139
+ store.presets.push(created);
140
+ saveCustomPresetStore(store);
141
+ return {
142
+ ...created,
143
+ source: "custom",
144
+ readonly: false
145
+ };
146
+ }
147
+
148
+ export function removePreset(name) {
149
+ const key = nameKey(name);
150
+ if (!key) {
151
+ throw new Error("Preset name cannot be empty");
152
+ }
153
+
154
+ const store = loadCustomPresetStore();
155
+ const existing = store.presets.find((preset) => nameKey(preset.name) === key);
156
+ if (!existing) {
157
+ const builtin = listBuiltinPresets().find((preset) => nameKey(preset.name) === key);
158
+ if (builtin) {
159
+ throw new Error("Built-in preset is read-only");
160
+ }
161
+ throw new Error(`Preset not found: ${name}`);
162
+ }
163
+
164
+ store.presets = store.presets.filter((preset) => nameKey(preset.name) !== key);
165
+ saveCustomPresetStore(store);
166
+ return {
167
+ ...existing,
168
+ source: "custom",
169
+ readonly: false
170
+ };
171
+ }
package/core/probe.js ADDED
@@ -0,0 +1,70 @@
1
+ import { dedupeStrings, normalizeBaseUrl } from "./utils.js";
2
+
3
+ async function fetchWithTimeout(url, timeoutMs) {
4
+ const controller = new AbortController();
5
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
6
+ const startedAt = Date.now();
7
+
8
+ try {
9
+ const response = await fetch(url, {
10
+ method: "GET",
11
+ headers: {
12
+ Accept: "application/json"
13
+ },
14
+ signal: controller.signal
15
+ });
16
+
17
+ return {
18
+ ok: true,
19
+ url,
20
+ latency: Date.now() - startedAt,
21
+ status: response.status
22
+ };
23
+ } catch (error) {
24
+ return {
25
+ ok: false,
26
+ url,
27
+ latency: Date.now() - startedAt,
28
+ error: error.name === "AbortError" ? "timeout" : error.message
29
+ };
30
+ } finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }
34
+
35
+ export async function probeBaseUrls(baseUrls, options = {}) {
36
+ const timeoutMs = options.timeoutMs ?? 5000;
37
+ const normalized = dedupeStrings(baseUrls).map((value) => normalizeBaseUrl(value));
38
+ const results = [];
39
+
40
+ for (const baseUrl of normalized) {
41
+ const modelUrl = `${baseUrl}/models`;
42
+ const modelResult = await fetchWithTimeout(modelUrl, timeoutMs);
43
+
44
+ if (modelResult.ok) {
45
+ results.push({
46
+ ...modelResult,
47
+ baseUrl
48
+ });
49
+ continue;
50
+ }
51
+
52
+ const fallbackResult = await fetchWithTimeout(baseUrl, timeoutMs);
53
+ results.push({
54
+ ...fallbackResult,
55
+ baseUrl
56
+ });
57
+ }
58
+
59
+ return results.sort((left, right) => {
60
+ if (left.ok !== right.ok) {
61
+ return left.ok ? -1 : 1;
62
+ }
63
+
64
+ return left.latency - right.latency;
65
+ });
66
+ }
67
+
68
+ export function getBestProbeResult(results) {
69
+ return results.find((result) => result.ok) || results[0] || null;
70
+ }
@@ -0,0 +1,334 @@
1
+ import { SUPPORTED_PLATFORMS } from "./constants.js";
2
+ import { getMarchRoutingPath } from "./paths.js";
3
+ import { readJson, writeJson } from "./utils.js";
4
+
5
+ const DEFAULT_FAILOVER_THRESHOLD_MS = 1800;
6
+ const DEFAULT_MAX_FAILURES = 2;
7
+
8
+ function now() {
9
+ return new Date().toISOString();
10
+ }
11
+
12
+ function createDefaultPlatformState(platform) {
13
+ return {
14
+ platform,
15
+ proxyMode: "provider-base-url",
16
+ autoFailover: false,
17
+ failoverThresholdMs: DEFAULT_FAILOVER_THRESHOLD_MS,
18
+ maxConsecutiveFailures: DEFAULT_MAX_FAILURES,
19
+ primaryProviderId: null,
20
+ fallbackProviderIds: [],
21
+ lastFailoverAt: null
22
+ };
23
+ }
24
+
25
+ function createDefaultRoutingState() {
26
+ return {
27
+ budgetMode: "tiered",
28
+ monthlyBudgetUsd: null,
29
+ platforms: SUPPORTED_PLATFORMS.map((platform) => createDefaultPlatformState(platform)),
30
+ providers: {}
31
+ };
32
+ }
33
+
34
+ function sanitizePlatformState(raw, platform) {
35
+ const fallback = createDefaultPlatformState(platform);
36
+ const fallbackIds = Array.isArray(raw?.fallbackProviderIds)
37
+ ? [...new Set(raw.fallbackProviderIds.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))]
38
+ : [];
39
+
40
+ return {
41
+ platform,
42
+ proxyMode: raw?.proxyMode === "direct" ? "direct" : fallback.proxyMode,
43
+ autoFailover: typeof raw?.autoFailover === "boolean" ? raw.autoFailover : fallback.autoFailover,
44
+ failoverThresholdMs:
45
+ Number.isFinite(raw?.failoverThresholdMs) && raw.failoverThresholdMs > 0
46
+ ? Math.round(raw.failoverThresholdMs)
47
+ : fallback.failoverThresholdMs,
48
+ maxConsecutiveFailures:
49
+ Number.isFinite(raw?.maxConsecutiveFailures) && raw.maxConsecutiveFailures > 0
50
+ ? Math.round(raw.maxConsecutiveFailures)
51
+ : fallback.maxConsecutiveFailures,
52
+ primaryProviderId: typeof raw?.primaryProviderId === "string" && raw.primaryProviderId.trim() ? raw.primaryProviderId.trim() : null,
53
+ fallbackProviderIds: fallbackIds,
54
+ lastFailoverAt: typeof raw?.lastFailoverAt === "string" && raw.lastFailoverAt.trim() ? raw.lastFailoverAt : null
55
+ };
56
+ }
57
+
58
+ function sanitizeProviderMetrics(raw) {
59
+ const result = {};
60
+
61
+ for (const [key, value] of Object.entries(raw || {})) {
62
+ if (!value || typeof value !== "object") {
63
+ continue;
64
+ }
65
+
66
+ result[key] = {
67
+ providerId: typeof value.providerId === "string" ? value.providerId : null,
68
+ platform: typeof value.platform === "string" ? value.platform : null,
69
+ lastLatency: Number.isFinite(value.lastLatency) ? Math.round(value.lastLatency) : null,
70
+ lastCheckedAt: typeof value.lastCheckedAt === "string" ? value.lastCheckedAt : null,
71
+ lastError: typeof value.lastError === "string" ? value.lastError : null,
72
+ consecutiveFailures: Number.isFinite(value.consecutiveFailures) ? Math.max(0, Math.round(value.consecutiveFailures)) : 0,
73
+ lastStatus:
74
+ value.lastStatus === "healthy" || value.lastStatus === "degraded" || value.lastStatus === "offline"
75
+ ? value.lastStatus
76
+ : "unknown"
77
+ };
78
+ }
79
+
80
+ return result;
81
+ }
82
+
83
+ export function loadRoutingState() {
84
+ const raw = readJson(getMarchRoutingPath(), null);
85
+ const fallback = createDefaultRoutingState();
86
+
87
+ return {
88
+ budgetMode: raw?.budgetMode === "manual" ? "manual" : fallback.budgetMode,
89
+ monthlyBudgetUsd: Number.isFinite(raw?.monthlyBudgetUsd) && raw.monthlyBudgetUsd >= 0 ? raw.monthlyBudgetUsd : null,
90
+ platforms: SUPPORTED_PLATFORMS.map((platform) =>
91
+ sanitizePlatformState(raw?.platforms?.find?.((item) => item?.platform === platform), platform)
92
+ ),
93
+ providers: sanitizeProviderMetrics(raw?.providers)
94
+ };
95
+ }
96
+
97
+ export function saveRoutingState(state) {
98
+ writeJson(getMarchRoutingPath(), state);
99
+ }
100
+
101
+ export function getPlatformRoutingState(platform) {
102
+ return loadRoutingState().platforms.find((item) => item.platform === platform) || createDefaultPlatformState(platform);
103
+ }
104
+
105
+ export function upsertPlatformRouting(platform, input = {}) {
106
+ const state = loadRoutingState();
107
+ state.platforms = state.platforms.map((item) =>
108
+ item.platform !== platform
109
+ ? item
110
+ : sanitizePlatformState(
111
+ {
112
+ ...item,
113
+ ...input
114
+ },
115
+ platform
116
+ )
117
+ );
118
+
119
+ if (input.monthlyBudgetUsd !== undefined) {
120
+ state.monthlyBudgetUsd =
121
+ Number.isFinite(input.monthlyBudgetUsd) && input.monthlyBudgetUsd >= 0 ? input.monthlyBudgetUsd : null;
122
+ }
123
+
124
+ if (input.budgetMode !== undefined) {
125
+ state.budgetMode = input.budgetMode === "manual" ? "manual" : "tiered";
126
+ }
127
+
128
+ saveRoutingState(state);
129
+ return state;
130
+ }
131
+
132
+ export function ensureRoutingProviders(platform, providers, currentProviderId = null) {
133
+ const state = loadRoutingState();
134
+ const policy = state.platforms.find((item) => item.platform === platform) || createDefaultPlatformState(platform);
135
+ const providerIds = providers.map((item) => item.id);
136
+
137
+ let primaryProviderId = policy.primaryProviderId;
138
+ if (!primaryProviderId || !providerIds.includes(primaryProviderId)) {
139
+ primaryProviderId = currentProviderId && providerIds.includes(currentProviderId) ? currentProviderId : providerIds[0] || null;
140
+ }
141
+
142
+ const fallbackProviderIds = providerIds.filter((providerId) => providerId !== primaryProviderId);
143
+ const nextPolicy = {
144
+ ...policy,
145
+ primaryProviderId,
146
+ fallbackProviderIds
147
+ };
148
+
149
+ state.platforms = state.platforms.map((item) => (item.platform === platform ? nextPolicy : item));
150
+ saveRoutingState(state);
151
+ return nextPolicy;
152
+ }
153
+
154
+ function metricsKey(platform, providerId) {
155
+ return `${platform}:${providerId}`;
156
+ }
157
+
158
+ export function recordProbeResults(platform, providers, results) {
159
+ const state = loadRoutingState();
160
+ const timestamp = now();
161
+
162
+ for (const provider of providers) {
163
+ const result = results.find((item) => item.baseUrl === provider.baseUrl);
164
+ const key = metricsKey(platform, provider.id);
165
+ const previous = state.providers[key] || {
166
+ providerId: provider.id,
167
+ platform,
168
+ lastLatency: null,
169
+ lastCheckedAt: null,
170
+ lastError: null,
171
+ consecutiveFailures: 0,
172
+ lastStatus: "unknown"
173
+ };
174
+
175
+ if (!result) {
176
+ state.providers[key] = {
177
+ ...previous,
178
+ lastCheckedAt: timestamp
179
+ };
180
+ continue;
181
+ }
182
+
183
+ const lastStatus = !result.ok
184
+ ? "offline"
185
+ : result.latency > DEFAULT_FAILOVER_THRESHOLD_MS
186
+ ? "degraded"
187
+ : "healthy";
188
+
189
+ state.providers[key] = {
190
+ providerId: provider.id,
191
+ platform,
192
+ lastLatency: Number.isFinite(result.latency) ? Math.round(result.latency) : null,
193
+ lastCheckedAt: timestamp,
194
+ lastError: result.ok ? null : result.error || null,
195
+ consecutiveFailures: result.ok ? 0 : previous.consecutiveFailures + 1,
196
+ lastStatus
197
+ };
198
+ }
199
+
200
+ saveRoutingState(state);
201
+ return state;
202
+ }
203
+
204
+ function getProviderMetrics(state, platform, providerId) {
205
+ return (
206
+ state.providers[metricsKey(platform, providerId)] || {
207
+ providerId,
208
+ platform,
209
+ lastLatency: null,
210
+ lastCheckedAt: null,
211
+ lastError: null,
212
+ consecutiveFailures: 0,
213
+ lastStatus: "unknown"
214
+ }
215
+ );
216
+ }
217
+
218
+ export function estimateCostTier(model) {
219
+ const normalized = `${model || ""}`.toLowerCase();
220
+
221
+ if (!normalized) {
222
+ return "unknown";
223
+ }
224
+
225
+ if (normalized.includes("mini")) {
226
+ return "low";
227
+ }
228
+
229
+ if (normalized.includes("max") || normalized.includes("5.4")) {
230
+ return "high";
231
+ }
232
+
233
+ if (normalized.includes("5.3") || normalized.includes("5.2") || normalized.includes("codex")) {
234
+ return "medium";
235
+ }
236
+
237
+ return "medium";
238
+ }
239
+
240
+ export function buildRoutingSnapshot(platforms) {
241
+ const state = loadRoutingState();
242
+
243
+ return {
244
+ budgetMode: state.budgetMode,
245
+ monthlyBudgetUsd: state.monthlyBudgetUsd,
246
+ platforms: platforms.map(({ platform, providers, currentProviderId }) => {
247
+ const policy = ensureRoutingProviders(platform, providers, currentProviderId);
248
+ return {
249
+ platform,
250
+ proxyMode: policy.proxyMode,
251
+ autoFailover: policy.autoFailover,
252
+ failoverThresholdMs: policy.failoverThresholdMs,
253
+ maxConsecutiveFailures: policy.maxConsecutiveFailures,
254
+ primaryProviderId: policy.primaryProviderId,
255
+ fallbackProviderIds: policy.fallbackProviderIds,
256
+ lastFailoverAt: policy.lastFailoverAt,
257
+ providerStates: providers.map((provider, index) => {
258
+ const metrics = getProviderMetrics(state, platform, provider.id);
259
+ return {
260
+ providerId: provider.id,
261
+ failoverRank:
262
+ provider.id === policy.primaryProviderId
263
+ ? 0
264
+ : Math.max(1, policy.fallbackProviderIds.indexOf(provider.id) + 1 || index + 1),
265
+ role: provider.id === policy.primaryProviderId ? "primary" : policy.fallbackProviderIds.includes(provider.id) ? "fallback" : "standby",
266
+ health: metrics.lastStatus,
267
+ lastLatency: metrics.lastLatency,
268
+ lastCheckedAt: metrics.lastCheckedAt,
269
+ lastError: metrics.lastError,
270
+ consecutiveFailures: metrics.consecutiveFailures,
271
+ costTier: estimateCostTier(provider.model)
272
+ };
273
+ })
274
+ };
275
+ })
276
+ };
277
+ }
278
+
279
+ export function maybeFailoverPlatform(platform, providers, currentProviderId) {
280
+ const state = loadRoutingState();
281
+ const policy = state.platforms.find((item) => item.platform === platform) || createDefaultPlatformState(platform);
282
+
283
+ if (!policy.autoFailover || !currentProviderId) {
284
+ return null;
285
+ }
286
+
287
+ const currentMetrics = getProviderMetrics(state, platform, currentProviderId);
288
+ const shouldFailover =
289
+ currentMetrics.lastStatus === "offline" ||
290
+ currentMetrics.consecutiveFailures >= policy.maxConsecutiveFailures ||
291
+ (Number.isFinite(currentMetrics.lastLatency) && currentMetrics.lastLatency > policy.failoverThresholdMs);
292
+
293
+ if (!shouldFailover) {
294
+ return null;
295
+ }
296
+
297
+ const nextProvider = policy.fallbackProviderIds
298
+ .map((providerId) => providers.find((item) => item.id === providerId))
299
+ .filter(Boolean)
300
+ .find((provider) => {
301
+ const metrics = getProviderMetrics(state, platform, provider.id);
302
+ return metrics.lastStatus === "healthy" || metrics.lastStatus === "degraded";
303
+ });
304
+
305
+ if (!nextProvider || nextProvider.id === currentProviderId) {
306
+ return null;
307
+ }
308
+
309
+ state.platforms = state.platforms.map((item) =>
310
+ item.platform !== platform
311
+ ? item
312
+ : {
313
+ ...item,
314
+ primaryProviderId: nextProvider.id,
315
+ fallbackProviderIds: providers
316
+ .map((provider) => provider.id)
317
+ .filter((providerId) => providerId !== nextProvider.id),
318
+ lastFailoverAt: now()
319
+ }
320
+ );
321
+ saveRoutingState(state);
322
+
323
+ return {
324
+ previousProviderId: currentProviderId,
325
+ nextProviderId: nextProvider.id
326
+ };
327
+ }
328
+
329
+ export function setPrimaryProvider(platform, providerId, providerIds) {
330
+ return upsertPlatformRouting(platform, {
331
+ primaryProviderId: providerId,
332
+ fallbackProviderIds: providerIds.filter((item) => item !== providerId)
333
+ });
334
+ }