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.
- package/README.md +107 -0
- package/build/icon.ico +0 -0
- package/build/icon.png +0 -0
- package/core/apply.js +152 -0
- package/core/backup.js +53 -0
- package/core/constants.js +78 -0
- package/core/desktop-service.js +403 -0
- package/core/desktop-state.js +1021 -0
- package/core/index.js +1468 -0
- package/core/paths.js +99 -0
- package/core/presets.js +171 -0
- package/core/probe.js +70 -0
- package/core/routing.js +334 -0
- package/core/store.js +218 -0
- package/core/utils.js +225 -0
- package/core/writers/codex.js +102 -0
- package/core/writers/index.js +16 -0
- package/core/writers/openclaw.js +93 -0
- package/core/writers/opencode.js +91 -0
- package/desktop/assets/fml-icon.png +0 -0
- package/desktop/assets/march-mark.svg +26 -0
- package/desktop/main.js +275 -0
- package/desktop/preload.cjs +67 -0
- package/desktop/preload.js +49 -0
- package/desktop/renderer/app.js +327 -0
- package/desktop/renderer/index.html +130 -0
- package/desktop/renderer/styles.css +490 -0
- package/package.json +111 -0
- package/scripts/build-web.mjs +95 -0
- package/scripts/desktop-dev.mjs +90 -0
- package/scripts/desktop-pack-win.mjs +81 -0
- package/scripts/postinstall.mjs +49 -0
- package/scripts/prepublish-check.mjs +57 -0
- package/scripts/serve-site.mjs +51 -0
- package/site/app.js +10 -0
- package/site/assets/fml-icon.png +0 -0
- package/site/assets/march-mark.svg +26 -0
- package/site/index.html +337 -0
- package/site/styles.css +840 -0
- package/src/App.tsx +1557 -0
- package/src/components/layout/app-sidebar.tsx +103 -0
- package/src/components/layout/top-toolbar.tsx +44 -0
- package/src/components/layout/workspace-tabs.tsx +32 -0
- package/src/components/providers/inspector-panel.tsx +84 -0
- package/src/components/providers/metric-strip.tsx +26 -0
- package/src/components/providers/provider-editor.tsx +87 -0
- package/src/components/providers/provider-table.tsx +85 -0
- package/src/components/ui/logo-mark.tsx +32 -0
- package/src/features/mcp/mcp-view.tsx +45 -0
- package/src/features/prompts/prompts-view.tsx +40 -0
- package/src/features/providers/providers-view.tsx +40 -0
- package/src/features/providers/types.ts +26 -0
- package/src/features/skills/skills-view.tsx +44 -0
- package/src/hooks/use-control-workspace.ts +235 -0
- package/src/index.css +22 -0
- package/src/lib/client.ts +726 -0
- package/src/lib/query-client.ts +3 -0
- package/src/lib/workspace-sections.ts +34 -0
- package/src/main.tsx +14 -0
- package/src/types.ts +137 -0
- package/src/vite-env.d.ts +64 -0
- 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
|
+
}
|
package/core/presets.js
ADDED
|
@@ -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
|
+
}
|
package/core/routing.js
ADDED
|
@@ -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
|
+
}
|