@openbmb/clawxrouter 1.0.4

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.
@@ -0,0 +1,101 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ /**
9
+ * Resolve the prompts/ directory.
10
+ * Works whether running from source (src/) or compiled output (dist/src/).
11
+ */
12
+ function resolvePromptsDir(): string {
13
+ const candidates = [
14
+ resolve(__dirname, "../prompts"), // from src/ → prompts/
15
+ resolve(__dirname, "../../prompts"), // from dist/src/ → prompts/
16
+ ];
17
+ for (const dir of candidates) {
18
+ if (existsSync(dir)) return dir;
19
+ }
20
+ return candidates[0]; // fallback, will trigger per-file fallback
21
+ }
22
+
23
+ const PROMPTS_DIR = resolvePromptsDir();
24
+
25
+ /** Cache loaded prompts in memory — invalidated on dashboard save */
26
+ const cache = new Map<string, string>();
27
+
28
+ /**
29
+ * Load a prompt template from `prompts/{name}.md`.
30
+ * Returns the file content if found, otherwise returns the fallback string.
31
+ *
32
+ * Results are cached — the file is read only once per process lifetime.
33
+ */
34
+ export function loadPrompt(name: string, fallback: string): string {
35
+ const cached = cache.get(name);
36
+ if (cached !== undefined) return cached;
37
+
38
+ const filePath = resolve(PROMPTS_DIR, `${name}.md`);
39
+ let content: string;
40
+
41
+ try {
42
+ if (existsSync(filePath)) {
43
+ content = readFileSync(filePath, "utf-8").trim();
44
+ console.log(`[ClawXrouter] Loaded custom prompt: prompts/${name}.md`);
45
+ } else {
46
+ content = fallback;
47
+ }
48
+ } catch {
49
+ console.warn(`[ClawXrouter] Failed to read prompts/${name}.md, using default`);
50
+ content = fallback;
51
+ }
52
+
53
+ cache.set(name, content);
54
+ return content;
55
+ }
56
+
57
+ /**
58
+ * Load a prompt and replace `{{PLACEHOLDER}}` tokens with provided values.
59
+ */
60
+ export function loadPromptWithVars(
61
+ name: string,
62
+ fallback: string,
63
+ vars: Record<string, string>,
64
+ ): string {
65
+ let prompt = loadPrompt(name, fallback);
66
+ for (const [key, value] of Object.entries(vars)) {
67
+ prompt = prompt.replaceAll(`{{${key}}}`, value);
68
+ }
69
+ return prompt;
70
+ }
71
+
72
+ /** Invalidate a cached prompt so the next loadPrompt() re-reads from disk. */
73
+ export function invalidatePrompt(name: string): void {
74
+ cache.delete(name);
75
+ }
76
+
77
+ /**
78
+ * Write a prompt to `prompts/{name}.md` and invalidate its cache.
79
+ * Creates the prompts directory if it doesn't exist.
80
+ */
81
+ export function writePrompt(name: string, content: string): void {
82
+ mkdirSync(PROMPTS_DIR, { recursive: true });
83
+ const filePath = resolve(PROMPTS_DIR, `${name}.md`);
84
+ writeFileSync(filePath, content, "utf-8");
85
+ invalidatePrompt(name);
86
+ }
87
+
88
+ /**
89
+ * Read a prompt file directly from disk (bypasses cache).
90
+ * Returns null if the file doesn't exist.
91
+ */
92
+ export function readPromptFromDisk(name: string): string | null {
93
+ const filePath = resolve(PROMPTS_DIR, `${name}.md`);
94
+ try {
95
+ if (existsSync(filePath)) {
96
+ return readFileSync(filePath, "utf-8").trim();
97
+ }
98
+ } catch { /* file unreadable */ }
99
+ return null;
100
+ }
101
+
@@ -0,0 +1,268 @@
1
+ import type { ProxyHandle } from "./privacy-proxy.js";
2
+ import { registerModelTarget, type OriginalProviderTarget } from "./privacy-proxy.js";
3
+ import { resolveDefaultBaseUrl } from "./utils.js";
4
+
5
+ let activeProxy: ProxyHandle | null = null;
6
+
7
+ export function setActiveProxy(proxy: ProxyHandle): void {
8
+ activeProxy = proxy;
9
+ }
10
+
11
+ export const clawXrouterPrivacyProvider = {
12
+ id: "clawxrouter-privacy",
13
+ label: "ClawXrouter Privacy Proxy",
14
+ aliases: [] as string[],
15
+ envVars: [] as string[],
16
+ auth: [] as never[],
17
+ };
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Phase 1: Init-time model collection
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Mirror all model definitions from every configured provider and
25
+ * build the model→target map for proxy routing.
26
+ */
27
+ export function mirrorAllProviderModels(
28
+ config: { models?: { providers?: Record<string, { models?: unknown; baseUrl?: string; apiKey?: string; api?: string }> } },
29
+ ): unknown[] {
30
+ const seen = new Set<string>();
31
+ const mirrored: unknown[] = [];
32
+ const providers = config.models?.providers ?? {};
33
+
34
+ for (const [provName, provCfg] of Object.entries(providers)) {
35
+ if (provName === "clawxrouter-privacy") continue;
36
+ if (!provCfg.models) continue;
37
+
38
+ const target: OriginalProviderTarget = {
39
+ baseUrl: provCfg.baseUrl ?? resolveDefaultBaseUrl(provName, provCfg.api),
40
+ apiKey: provCfg.apiKey ?? "",
41
+ provider: provName,
42
+ api: provCfg.api,
43
+ };
44
+
45
+ const models = provCfg.models;
46
+ if (Array.isArray(models)) {
47
+ for (const m of models) {
48
+ const id = (m as Record<string, unknown>)?.id as string | undefined;
49
+ if (id && !seen.has(id)) {
50
+ seen.add(id);
51
+ mirrored.push(m);
52
+ registerModelTarget(id, target);
53
+ }
54
+ }
55
+ } else if (typeof models === "object" && models !== null) {
56
+ for (const [modelId, modelDef] of Object.entries(models as Record<string, unknown>)) {
57
+ if (!seen.has(modelId)) {
58
+ seen.add(modelId);
59
+ mirrored.push({ id: modelId, ...(typeof modelDef === "object" && modelDef !== null ? modelDef as Record<string, unknown> : {}) });
60
+ registerModelTarget(modelId, target);
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return mirrored;
67
+ }
68
+
69
+ /**
70
+ * Scan router tier configs for model IDs that may not be in any provider's
71
+ * explicit model list. Returns { provider, modelId } pairs.
72
+ */
73
+ export function collectTierModelIds(
74
+ pluginConfig: Record<string, unknown>,
75
+ ): Array<{ provider: string; modelId: string }> {
76
+ const privacy = (pluginConfig?.privacy ?? {}) as Record<string, unknown>;
77
+ const routers = (privacy.routers ?? {}) as Record<
78
+ string,
79
+ { options?: { tiers?: Record<string, { provider?: string; model?: string }> } }
80
+ >;
81
+ const result: Array<{ provider: string; modelId: string }> = [];
82
+
83
+ for (const reg of Object.values(routers)) {
84
+ const tiers = reg.options?.tiers;
85
+ if (!tiers) continue;
86
+ for (const tier of Object.values(tiers)) {
87
+ if (tier.provider && tier.model) {
88
+ result.push({ provider: tier.provider, modelId: tier.model });
89
+ }
90
+ }
91
+ }
92
+ return result;
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Phase 2: JIT (runtime) model registration
97
+ // ---------------------------------------------------------------------------
98
+
99
+ type ProviderCfg = Record<string, unknown> & {
100
+ models?: Array<Record<string, unknown>>;
101
+ };
102
+
103
+ /**
104
+ * Look up a model definition across all configured providers (excluding
105
+ * clawxrouter-privacy). Returns the full model object if found, null otherwise.
106
+ */
107
+ function findModelInProviders(
108
+ providers: Record<string, ProviderCfg>,
109
+ modelId: string,
110
+ preferProvider?: string,
111
+ ): Record<string, unknown> | null {
112
+ const order = preferProvider
113
+ ? [preferProvider, ...Object.keys(providers).filter((p) => p !== preferProvider && p !== "clawxrouter-privacy")]
114
+ : Object.keys(providers).filter((p) => p !== "clawxrouter-privacy");
115
+
116
+ for (const provName of order) {
117
+ const provModels = providers[provName]?.models;
118
+ if (!Array.isArray(provModels)) continue;
119
+ const found = provModels.find((m) => (m as Record<string, unknown>).id === modelId);
120
+ if (found) return { ...(found as Record<string, unknown>) };
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Build a minimal model entry that matches OpenClaw's resolveModelWithRegistry
127
+ * fallback for the given provider. This ensures contextWindow / maxTokens /
128
+ * reasoning align with what the model would get if routed directly.
129
+ */
130
+ function buildFallbackModelEntry(
131
+ providers: Record<string, ProviderCfg>,
132
+ modelId: string,
133
+ originalProvider: string,
134
+ ): Record<string, unknown> {
135
+ const origModels = providers[originalProvider]?.models;
136
+ const firstModel =
137
+ Array.isArray(origModels) && origModels.length > 0
138
+ ? (origModels[0] as Record<string, unknown>)
139
+ : null;
140
+ return {
141
+ id: modelId,
142
+ name: modelId,
143
+ ...(firstModel?.contextWindow != null ? { contextWindow: firstModel.contextWindow } : {}),
144
+ ...(firstModel?.maxTokens != null ? { maxTokens: firstModel.maxTokens } : {}),
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Ensure `modelId` is registered under the clawxrouter-privacy provider.
150
+ *
151
+ * Called at decision time (JIT) so that models selected by token-saver or
152
+ * other routers — which may not have been in the init snapshot — are available
153
+ * with correct properties before OpenClaw's resolveModel runs.
154
+ *
155
+ * Also propagates reasoning/thinking defaults into agents.defaults.models so
156
+ * thinking-model output isn't stripped.
157
+ */
158
+ export function ensureModelMirrored(
159
+ config: Record<string, unknown>,
160
+ modelId: string,
161
+ originalProvider: string,
162
+ runtimeLoadConfig?: () => Record<string, unknown> | undefined,
163
+ ): void {
164
+ const providers = (config as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
165
+ .models?.providers;
166
+ if (!providers?.["clawxrouter-privacy"]) return;
167
+
168
+ const privacyModels = providers["clawxrouter-privacy"].models;
169
+ if (!Array.isArray(privacyModels)) return;
170
+
171
+ const alreadyMirrored = privacyModels.some((m) => (m as Record<string, unknown>).id === modelId);
172
+
173
+ let source: Record<string, unknown>;
174
+ if (alreadyMirrored) {
175
+ source = privacyModels.find((m) => (m as Record<string, unknown>).id === modelId) as Record<string, unknown>;
176
+ } else {
177
+ source =
178
+ findModelInProviders(providers, modelId, originalProvider) ??
179
+ buildFallbackModelEntry(providers, modelId, originalProvider);
180
+ privacyModels.push(source);
181
+ }
182
+
183
+ // Register model→provider target for proxy routing (idempotent)
184
+ const provCfg = providers[originalProvider];
185
+ if (provCfg) {
186
+ registerModelTarget(modelId, {
187
+ baseUrl: (provCfg as Record<string, unknown>).baseUrl as string ?? resolveDefaultBaseUrl(originalProvider, (provCfg as Record<string, unknown>).api as string | undefined),
188
+ apiKey: (provCfg as Record<string, unknown>).apiKey as string ?? "",
189
+ provider: originalProvider,
190
+ api: (provCfg as Record<string, unknown>).api as string | undefined,
191
+ });
192
+ }
193
+
194
+ if (source.reasoning === true) {
195
+ propagateThinkingForModel(config, modelId);
196
+ }
197
+
198
+ // Patch the runtime config snapshot (structuredClone of api.config)
199
+ if (runtimeLoadConfig) {
200
+ try {
201
+ const rtCfg = runtimeLoadConfig();
202
+ if (rtCfg) {
203
+ if (!alreadyMirrored) {
204
+ const rtProviders = (rtCfg as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
205
+ .models?.providers;
206
+ const rtModels = rtProviders?.["clawxrouter-privacy"]?.models;
207
+ if (Array.isArray(rtModels) && !rtModels.some((m) => (m as Record<string, unknown>).id === modelId)) {
208
+ rtModels.push(source);
209
+ }
210
+ }
211
+ if (source.reasoning === true) {
212
+ propagateThinkingForModel(rtCfg, modelId);
213
+ }
214
+ }
215
+ } catch { /* best-effort */ }
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Set `params.thinking: "low"` for a clawxrouter-privacy model so the agent SDK
221
+ * enables thinking mode for reasoning models routed through the proxy.
222
+ */
223
+ function propagateThinkingForModel(
224
+ config: Record<string, unknown>,
225
+ modelId: string,
226
+ ): void {
227
+ const agents = (config as Record<string, unknown> & { agents?: Record<string, unknown> }).agents;
228
+ const defaults = agents?.defaults as Record<string, unknown> | undefined;
229
+ if (!defaults) return;
230
+ if (!defaults.models) defaults.models = {};
231
+ const modelsOverrides = defaults.models as Record<string, Record<string, unknown>>;
232
+ const proxyKey = `clawxrouter-privacy/${modelId}`;
233
+ const existing = modelsOverrides[proxyKey] ?? {};
234
+ if (!existing.params || !(existing.params as Record<string, unknown>).thinking) {
235
+ modelsOverrides[proxyKey] = {
236
+ ...existing,
237
+ params: { ...(existing.params as Record<string, unknown> ?? {}), thinking: "low" },
238
+ };
239
+ }
240
+ }
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // Helpers for hooks.ts: resolve original provider for a model
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Given a model ID selected by the pipeline (e.g. from token-saver), find
248
+ * which real provider owns it. Returns the provider name, or the supplied
249
+ * fallback if no explicit match is found.
250
+ */
251
+ export function resolveOriginalProvider(
252
+ config: Record<string, unknown>,
253
+ modelId: string,
254
+ fallbackProvider: string,
255
+ ): string {
256
+ const providers = (config as Record<string, unknown> & { models?: { providers?: Record<string, ProviderCfg> } })
257
+ .models?.providers ?? {};
258
+
259
+ for (const [provName, provCfg] of Object.entries(providers)) {
260
+ if (provName === "clawxrouter-privacy") continue;
261
+ const provModels = provCfg.models;
262
+ if (!Array.isArray(provModels)) continue;
263
+ if (provModels.some((m) => (m as Record<string, unknown>).id === modelId)) {
264
+ return provName;
265
+ }
266
+ }
267
+ return fallbackProvider;
268
+ }