mobygate 0.7.3 → 0.8.1

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,80 @@
1
+ /**
2
+ * Client connector registry + orchestrator.
3
+ *
4
+ * Connectors auto-wire third-party clients (Hermes, OpenClaw, etc.) to
5
+ * use mobygate as their inference provider. This avoids the manual
6
+ * "find your client's config file → paste this JSON snippet → restart"
7
+ * dance for each client a user wants to connect.
8
+ *
9
+ * Each connector lives in `lib/connectors/<id>.js` and exports a
10
+ * uniform contract:
11
+ *
12
+ * - id — short stable identifier (e.g. 'hermes', 'openclaw')
13
+ * - displayName — human-readable name for prompts/logs
14
+ * - detect() — probe for the client; returns DetectionResult | null
15
+ * - inspect() — read current config; returns InspectionResult
16
+ * - plan(opts) — compute the diff to apply; returns Plan
17
+ * - apply(plan) — perform the modification atomically
18
+ * - disconnect() — remove our entries cleanly
19
+ *
20
+ * Branding: all provider entries we register use the `moby` prefix to
21
+ * make them visually identifiable. Two flavors:
22
+ * - `moby` — OpenAI-compat surface (POST /v1/chat/completions)
23
+ * - `moby-native` — Anthropic-messages surface (POST /v1/messages)
24
+ *
25
+ * Clients that only handle one wire format get whichever fits; clients
26
+ * that handle both get both registered, with `moby-native` as the
27
+ * preferred default.
28
+ */
29
+
30
+ import { hermesConnector } from './hermes.js';
31
+ import { openclawConnector } from './openclaw.js';
32
+
33
+ // Public API for any caller that wants to know where mobygate is reachable.
34
+ // Defaults match server.js's defaults; init can override via opts.
35
+ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3456';
36
+ export const DEFAULT_API_KEY = 'claude-max';
37
+
38
+ // Branded provider names. Short, distinct, unmistakably from mobygate.
39
+ export const PROVIDER_NAME_OPENAI = 'moby';
40
+ export const PROVIDER_NAME_ANTHROPIC = 'moby-native';
41
+
42
+ /**
43
+ * All registered connectors, in display order. Add new ones here.
44
+ * v0.8.0 ships with hermes + openclaw; pi-agent and others are on the
45
+ * v0.8.x backlog.
46
+ */
47
+ export const CONNECTORS = [
48
+ hermesConnector,
49
+ openclawConnector,
50
+ ];
51
+
52
+ /**
53
+ * Run detection across every registered connector. Returns an array of
54
+ * { connector, detection } where detection is the connector's
55
+ * DetectionResult (or null if not found). Connectors that throw are
56
+ * treated as "not detected" — we never let one broken adapter break
57
+ * the whole orchestrator.
58
+ */
59
+ export async function detectAll() {
60
+ const out = [];
61
+ for (const c of CONNECTORS) {
62
+ let detection = null;
63
+ try {
64
+ detection = await c.detect();
65
+ } catch (e) {
66
+ // Detection should be silent on failure — the client may simply
67
+ // not be installed. Don't surface noise.
68
+ detection = null;
69
+ }
70
+ out.push({ connector: c, detection });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Convenience: get a connector by id, or null.
77
+ */
78
+ export function getConnector(id) {
79
+ return CONNECTORS.find((c) => c.id === id) || null;
80
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * OpenClaw connector.
3
+ *
4
+ * OpenClaw is the Discord-bot agent harness. Its config lives at
5
+ * `~/.openclaw/openclaw.json` (canonical on Linux/Mac, mirrored as
6
+ * `%USERPROFILE%\.openclaw\openclaw.json` on Windows — verified
7
+ * against a real Geekom install).
8
+ *
9
+ * Unlike Hermes, OpenClaw understands BOTH wire formats — `openai-completions`
10
+ * (for legacy OpenAI-shape providers) and `anthropic-messages` (for
11
+ * Anthropic-native providers, which unlocks vision + native tools +
12
+ * thinking blocks). So we register both surfaces:
13
+ * - moby (api: openai-completions → /v1/chat/completions)
14
+ * - moby-native (api: anthropic-messages → /v1/messages)
15
+ *
16
+ * `moby-native` is set as the main + default model when setDefault is
17
+ * true — that's the wire format that gives OpenClaw the full feature
18
+ * set. `moby` stays available as fallback / OpenAI-compat clients.
19
+ *
20
+ * Config schema (inferred from the user's Geekom config):
21
+ * {
22
+ * "models": {
23
+ * "main": "<provider>/<model-id>",
24
+ * "default": "<provider>/<model-id>",
25
+ * "providers": {
26
+ * "<provider-id>": {
27
+ * "baseUrl": "...",
28
+ * "apiKey": "...",
29
+ * "api": "openai-completions" | "anthropic-messages",
30
+ * "models": [ { id, name, contextWindow, maxTokens, input, reasoning, cost }, ... ]
31
+ * }
32
+ * }
33
+ * }
34
+ * }
35
+ */
36
+
37
+ import { readFileSync, existsSync } from 'fs';
38
+ import { join } from 'path';
39
+ import { homedir } from 'os';
40
+ import { writeConfigSafe, diffSummary } from './safety.js';
41
+ import {
42
+ DEFAULT_BASE_URL,
43
+ DEFAULT_API_KEY,
44
+ PROVIDER_NAME_OPENAI,
45
+ PROVIDER_NAME_ANTHROPIC,
46
+ } from './index.js';
47
+
48
+ const OPENCLAW_HOME = process.env.OPENCLAW_HOME || join(homedir(), '.openclaw');
49
+ const OPENCLAW_CONFIG = join(OPENCLAW_HOME, 'openclaw.json');
50
+
51
+ // Probe order — first match wins. Lets us support installs that put the
52
+ // config somewhere other than ~/.openclaw/.
53
+ const CONFIG_PROBES = [
54
+ OPENCLAW_CONFIG,
55
+ // Add other plausible paths here as they're discovered. Empty list is
56
+ // fine for v0.8.0 — every install we've seen uses ~/.openclaw/.
57
+ ];
58
+
59
+ const MODELS_OPENAI_SURFACE = [
60
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max via Moby)', contextWindow: 1000000, maxTokens: 32768, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
61
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
62
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
63
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
64
+ ];
65
+
66
+ // Native surface declares text+image input and reasoning capability so
67
+ // OpenClaw will send vision content and surface thinking blocks.
68
+ const MODELS_NATIVE_SURFACE = [
69
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max, native)', contextWindow: 1000000, maxTokens: 32768, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
70
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
71
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
72
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
73
+ ];
74
+
75
+ function buildOpenAIProvider({ baseUrl, apiKey }) {
76
+ return {
77
+ baseUrl,
78
+ apiKey,
79
+ api: 'openai-completions',
80
+ models: MODELS_OPENAI_SURFACE,
81
+ };
82
+ }
83
+
84
+ function buildNativeProvider({ baseUrl, apiKey }) {
85
+ return {
86
+ baseUrl,
87
+ apiKey,
88
+ api: 'anthropic-messages',
89
+ models: MODELS_NATIVE_SURFACE,
90
+ };
91
+ }
92
+
93
+ function findConfigPath() {
94
+ for (const p of CONFIG_PROBES) {
95
+ if (existsSync(p)) return p;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function isMobyDefaultPointer(s) {
101
+ if (typeof s !== 'string') return false;
102
+ return s.startsWith(`${PROVIDER_NAME_OPENAI}/`) || s.startsWith(`${PROVIDER_NAME_ANTHROPIC}/`);
103
+ }
104
+
105
+ export const openclawConnector = {
106
+ id: 'openclaw',
107
+ displayName: 'OpenClaw',
108
+
109
+ async detect() {
110
+ const configPath = findConfigPath();
111
+ if (!configPath) return null;
112
+ let raw;
113
+ try {
114
+ raw = readFileSync(configPath, 'utf8');
115
+ } catch (e) {
116
+ return null;
117
+ }
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(raw);
121
+ } catch (e) {
122
+ return { configPath, parseError: e.message };
123
+ }
124
+ return { configPath, parsed };
125
+ },
126
+
127
+ async inspect({ baseUrl = DEFAULT_BASE_URL } = {}) {
128
+ const det = await this.detect();
129
+ if (!det) return { installed: false };
130
+ if (det.parseError) return { installed: true, parseError: det.parseError };
131
+
132
+ const providers = det.parsed?.models?.providers || {};
133
+
134
+ // Detect "shadow" providers: ones that point at our base URL but
135
+ // aren't registered under our canonical names. This catches the
136
+ // pre-v0.8.0 hand-rolled `claude-max-proxy` style configs that
137
+ // would otherwise silently bypass `mobygate connect`'s native
138
+ // surface — exactly the situation that caused OpenClaw to keep
139
+ // sending OpenAI-shape requests in the v0.8.0 → v0.8.1 era despite
140
+ // the connector having registered moby-native.
141
+ //
142
+ // For each shadow provider we report its name, current api type,
143
+ // and a recommendation. Surfacing this in inspect() (and the CLI)
144
+ // turns "why is the shape wrong?" from a forensics task into a
145
+ // single command.
146
+ const shadowProviders = [];
147
+ const baseHost = String(baseUrl).replace(/\/+$/, '');
148
+ for (const [name, p] of Object.entries(providers)) {
149
+ if (name === PROVIDER_NAME_OPENAI || name === PROVIDER_NAME_ANTHROPIC) continue;
150
+ if (!p?.baseUrl) continue;
151
+ const provHost = String(p.baseUrl).replace(/\/+$/, '');
152
+ // Match exact or with /v1 suffix; tolerate localhost vs 127.0.0.1.
153
+ const norm = (s) => s.replace('localhost', '127.0.0.1').replace(/\/v1$/, '');
154
+ if (norm(provHost) === norm(baseHost)) {
155
+ shadowProviders.push({
156
+ name,
157
+ api: p.api || '(unset)',
158
+ baseUrl: p.baseUrl,
159
+ recommendation: p.api === 'anthropic-messages'
160
+ ? `OK — already on native shape. Could rename to "${PROVIDER_NAME_ANTHROPIC}" for clarity.`
161
+ : `Flip api: "${p.api}" → "anthropic-messages" to enable cache_control + native blocks. ` +
162
+ `Or run \`mobygate connect openclaw\` to register canonical providers.`,
163
+ });
164
+ }
165
+ }
166
+
167
+ return {
168
+ installed: true,
169
+ configPath: det.configPath,
170
+ mobyProviderExists: !!providers[PROVIDER_NAME_OPENAI],
171
+ mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
172
+ currentMain: det.parsed?.models?.main || null,
173
+ currentDefault: det.parsed?.models?.default || null,
174
+ shadowProviders, // pre-v0.8.0 entries pointing at our base URL
175
+ };
176
+ },
177
+
178
+ async plan({
179
+ baseUrl = DEFAULT_BASE_URL,
180
+ apiKey = DEFAULT_API_KEY,
181
+ setDefault = true,
182
+ registerOpenAISurface = true,
183
+ registerNativeSurface = true,
184
+ } = {}) {
185
+ const det = await this.detect();
186
+ if (!det) {
187
+ return { skip: true, reason: 'OpenClaw not detected (no ~/.openclaw/openclaw.json)' };
188
+ }
189
+ if (det.parseError) {
190
+ return { skip: true, reason: `OpenClaw config is unparseable JSON: ${det.parseError}` };
191
+ }
192
+
193
+ const before = det.parsed || {};
194
+ const after = JSON.parse(JSON.stringify(before)); // deep clone
195
+
196
+ if (!after.models) after.models = {};
197
+ if (!after.models.providers) after.models.providers = {};
198
+
199
+ if (registerOpenAISurface) {
200
+ after.models.providers[PROVIDER_NAME_OPENAI] = buildOpenAIProvider({ baseUrl, apiKey });
201
+ }
202
+ if (registerNativeSurface) {
203
+ after.models.providers[PROVIDER_NAME_ANTHROPIC] = buildNativeProvider({ baseUrl, apiKey });
204
+ }
205
+
206
+ if (setDefault) {
207
+ // Prefer native if registered; fall back to openai-compat otherwise.
208
+ const preferredProvider = registerNativeSurface
209
+ ? PROVIDER_NAME_ANTHROPIC
210
+ : registerOpenAISurface
211
+ ? PROVIDER_NAME_OPENAI
212
+ : null;
213
+ if (preferredProvider) {
214
+ const target = `${preferredProvider}/claude-opus-4-7`;
215
+ after.models.main = target;
216
+ after.models.default = target;
217
+ }
218
+ }
219
+
220
+ const summary = diffSummary(
221
+ { providers: before.models?.providers, main: before.models?.main, default: before.models?.default },
222
+ { providers: after.models.providers, main: after.models.main, default: after.models.default },
223
+ );
224
+
225
+ return {
226
+ skip: false,
227
+ configPath: det.configPath,
228
+ before,
229
+ after,
230
+ summary,
231
+ warnings: [],
232
+ };
233
+ },
234
+
235
+ async apply(plan) {
236
+ if (plan.skip) return { applied: false, reason: plan.reason };
237
+ // OpenClaw uses 2-space indented JSON in its own config writes.
238
+ // Match that style so subsequent self-writes don't reformat the
239
+ // whole file.
240
+ const jsonOut = JSON.stringify(plan.after, null, 2) + '\n';
241
+ const result = writeConfigSafe(plan.configPath, jsonOut);
242
+ return {
243
+ applied: !result.unchanged,
244
+ unchanged: !!result.unchanged,
245
+ reason: result.unchanged ? 'config already up-to-date (byte-identical)' : null,
246
+ configPath: result.path,
247
+ backupPath: result.backupPath,
248
+ bytesWritten: result.bytesWritten,
249
+ };
250
+ },
251
+
252
+ async disconnect() {
253
+ const det = await this.detect();
254
+ if (!det) return { applied: false, reason: 'OpenClaw not installed' };
255
+ if (det.parseError) return { applied: false, reason: `parse error: ${det.parseError}` };
256
+ const before = det.parsed || {};
257
+ const after = JSON.parse(JSON.stringify(before));
258
+ let changed = false;
259
+
260
+ const providers = after.models?.providers;
261
+ if (providers) {
262
+ for (const name of [PROVIDER_NAME_OPENAI, PROVIDER_NAME_ANTHROPIC]) {
263
+ if (providers[name]) { delete providers[name]; changed = true; }
264
+ }
265
+ }
266
+ // If main/default was pointing at us, blank them — let the user
267
+ // re-pick rather than guess at a replacement.
268
+ if (isMobyDefaultPointer(after.models?.main)) {
269
+ after.models.main = null;
270
+ changed = true;
271
+ }
272
+ if (isMobyDefaultPointer(after.models?.default)) {
273
+ after.models.default = null;
274
+ changed = true;
275
+ }
276
+
277
+ if (!changed) return { applied: false, reason: 'No moby provider entries in OpenClaw config' };
278
+
279
+ const jsonOut = JSON.stringify(after, null, 2) + '\n';
280
+ const result = writeConfigSafe(det.configPath, jsonOut);
281
+ return {
282
+ applied: true,
283
+ configPath: result.path,
284
+ backupPath: result.backupPath,
285
+ note: (after.models?.main === null || after.models?.default === null)
286
+ ? 'Reset main/default model to null — set a new model in OpenClaw before next request.'
287
+ : null,
288
+ };
289
+ },
290
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Safety primitives for connector adapters.
3
+ *
4
+ * Every connector that modifies a third-party config file goes through
5
+ * these helpers — they enforce backup, atomic write, and a couple of
6
+ * sanity guards. Adapters MUST NOT call writeFileSync directly on a
7
+ * user's config; they MUST go through `writeConfigSafe`.
8
+ *
9
+ * The contract:
10
+ * 1. Always back up first to `<file>.mobygate-backup-<ISO-timestamp>`.
11
+ * 2. Write to a temp file in the same directory, fsync, then rename.
12
+ * Atomic rename on POSIX, near-atomic on Windows (NTFS handles it
13
+ * transparently for same-volume renames).
14
+ * 3. Verify the rename produced the expected content (read-back +
15
+ * length sanity check) before declaring success.
16
+ *
17
+ * This keeps a corrupt file or partial write from destroying a user's
18
+ * carefully-tuned client config.
19
+ */
20
+
21
+ import {
22
+ readFileSync,
23
+ writeFileSync,
24
+ renameSync,
25
+ copyFileSync,
26
+ existsSync,
27
+ statSync,
28
+ mkdirSync,
29
+ unlinkSync,
30
+ } from 'fs';
31
+ import { dirname, basename, join } from 'path';
32
+
33
+ const ISO_SAFE = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-');
34
+
35
+ /**
36
+ * Make a timestamped backup of `path`. Returns the backup path.
37
+ * No-op (returns null) if `path` doesn't exist — this lets adapters
38
+ * call `backup()` unconditionally even when they're creating a new file.
39
+ */
40
+ export function backup(path) {
41
+ if (!existsSync(path)) return null;
42
+ const dir = dirname(path);
43
+ const name = basename(path);
44
+ const backupPath = join(dir, `${name}.mobygate-backup-${ISO_SAFE()}`);
45
+ copyFileSync(path, backupPath);
46
+ return backupPath;
47
+ }
48
+
49
+ /**
50
+ * Atomically write `content` to `path`. Backs up first. Returns
51
+ * `{ path, backupPath, bytesWritten }` on success; throws on any failure
52
+ * (the original file is preserved by the backup).
53
+ *
54
+ * `content` must be a string. Adapters that work in structured formats
55
+ * (YAML, JSON) serialize before calling this.
56
+ */
57
+ export function writeConfigSafe(path, content) {
58
+ if (typeof content !== 'string') {
59
+ throw new Error(`writeConfigSafe: content must be a string (got ${typeof content})`);
60
+ }
61
+ if (content.length === 0) {
62
+ throw new Error(`writeConfigSafe: refusing to write empty content to ${path}`);
63
+ }
64
+ const dir = dirname(path);
65
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
+
67
+ // Idempotency guard: if the existing on-disk content is byte-identical
68
+ // to what we'd write, skip the rewrite entirely. This prevents
69
+ // `mobygate connect <client>` from producing spurious "(changed)" diffs
70
+ // when re-run with no real change — a real bug seen in v0.8.0 where
71
+ // diffSummary's structural comparison disagreed with actual file bytes.
72
+ if (existsSync(path)) {
73
+ try {
74
+ const current = readFileSync(path, 'utf8');
75
+ if (current === content) {
76
+ return { path, backupPath: null, bytesWritten: 0, unchanged: true };
77
+ }
78
+ } catch {
79
+ // Read failure is non-fatal — we'll fall through to the normal write
80
+ // path which will surface any real I/O problem.
81
+ }
82
+ }
83
+
84
+ const backupPath = backup(path);
85
+ const tempPath = `${path}.mobygate-tmp-${ISO_SAFE()}`;
86
+
87
+ try {
88
+ writeFileSync(tempPath, content, 'utf8');
89
+ // Sanity check: the temp file should be the size we just wrote.
90
+ const size = statSync(tempPath).size;
91
+ if (size === 0) throw new Error('temp file is empty after write');
92
+ renameSync(tempPath, path);
93
+ } catch (e) {
94
+ // Best-effort cleanup of the temp file. If rename failed mid-flight
95
+ // (rare), the original is intact via the backup.
96
+ try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch {}
97
+ throw new Error(`writeConfigSafe failed for ${path}: ${e.message}` +
98
+ (backupPath ? ` (original preserved at ${backupPath})` : ''));
99
+ }
100
+
101
+ // Final verify: read back what's on disk and confirm it matches.
102
+ const onDisk = readFileSync(path, 'utf8');
103
+ if (onDisk !== content) {
104
+ throw new Error(`writeConfigSafe verify failed for ${path}: ` +
105
+ `read-back differs from intended content` +
106
+ (backupPath ? ` (original preserved at ${backupPath})` : ''));
107
+ }
108
+
109
+ return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8'), unchanged: false };
110
+ }
111
+
112
+ /**
113
+ * Compute a human-readable summary of a planned change. Used by adapters
114
+ * to produce dry-run output. `before` and `after` are arbitrary objects;
115
+ * we don't try to be clever — just a count of top-level differences.
116
+ *
117
+ * Returns lines like:
118
+ * + providers.moby (added)
119
+ * ~ providers.moby-native (changed)
120
+ * - providers.old-thing (removed)
121
+ */
122
+ export function diffSummary(before, after, prefix = '') {
123
+ const lines = [];
124
+ const beforeKeys = new Set(Object.keys(before || {}));
125
+ const afterKeys = new Set(Object.keys(after || {}));
126
+ for (const k of afterKeys) {
127
+ const fullKey = prefix ? `${prefix}.${k}` : k;
128
+ if (!beforeKeys.has(k)) {
129
+ lines.push(`+ ${fullKey} (added)`);
130
+ } else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
131
+ lines.push(`~ ${fullKey} (changed)`);
132
+ }
133
+ }
134
+ for (const k of beforeKeys) {
135
+ if (!afterKeys.has(k)) {
136
+ const fullKey = prefix ? `${prefix}.${k}` : k;
137
+ lines.push(`- ${fullKey} (removed)`);
138
+ }
139
+ }
140
+ return lines;
141
+ }