mobygate 0.8.2 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,120 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.8.4] — 2026-04-28
8
+
9
+ Sonnet 1M context fix — gated billing tier mismatch. v0.8.3's "match
10
+ the opus pattern" change auto-upgraded all `claude-sonnet-4-6` requests
11
+ to `claude-sonnet-4-6[1m]`, but **Sonnet 1M context requires paid
12
+ "extra usage" billing on Claude Max plans, while Opus 1M is included.**
13
+ Users without extra-usage enabled would see silent failures whenever
14
+ their agents hit sonnet through mobygate.
15
+
16
+ Discovered via OpenClaw multi-bot configuration testing — Lux and
17
+ Mercury (configured for moby-native sonnet) couldn't make calls until
18
+ this was diagnosed.
19
+
20
+ ### Fixed
21
+
22
+ - **Sonnet routes default to 200k (Max-included), not 1M.**
23
+ `claude-sonnet-4-6` now passes through unchanged. Same for the
24
+ `claude-sonnet-4` shorthand and the `sonnet` alias.
25
+
26
+ - **Explicit 1M opt-in via new alias `claude-sonnet-4-6-1m`** — for
27
+ users who have enabled Max extra-usage and genuinely need 1M context
28
+ on sonnet sub-tasks. Maps to `claude-sonnet-4-6[1m]`.
29
+
30
+ - **`/v1/models` advertises sonnet's actual default context (200k)**
31
+ instead of the unreachable 1M. New entry `claude-sonnet-4-6-1m`
32
+ exposes the 1M variant for clients that want to opt in.
33
+
34
+ - **`sonnet-1m` shorthand alias** for clients using short model names.
35
+
36
+ ### Notes
37
+
38
+ Opus 1M context remains the default (`opus`, `claude-opus-4-7`, etc.
39
+ all map to `[1m]`) because Max plans include it. Sonnet's smaller
40
+ 1M-context allowance is a separate billing line item.
41
+
42
+ If you've enabled extra-usage on your Max plan and want all sonnet
43
+ calls at 1M, override your client's model name to `claude-sonnet-4-6-1m`
44
+ or `sonnet-1m` instead of `claude-sonnet-4-6` / `sonnet`.
45
+
46
+ ## [0.8.3] — 2026-04-28
47
+
48
+ OpenClaw .26 compatibility, sonnet 1M context, and observability for
49
+ silent model swaps. Found by upgrading mobygate to v0.8.1 on Windows
50
+ during heavy multi-agent testing.
51
+
52
+ ### Fixed
53
+
54
+ - **OpenClaw connector wrote deprecated `models.main` / `models.default`
55
+ keys**, which OpenClaw `2026.4.26+` outright rejects with `Unrecognized
56
+ keys: "main", "default"`. The gateway refused to start until
57
+ `openclaw doctor --fix` cleaned them up.
58
+
59
+ v0.8.3 connector now writes the modern path:
60
+ - `agents.defaults.model.primary` (the active default)
61
+ - `agents.defaults.models` (registered model id map)
62
+
63
+ Plus on every `plan()` it removes the deprecated top-level keys if a
64
+ previous mobygate version (v0.8.0–v0.8.2) wrote them. Re-running
65
+ `mobygate connect openclaw` after upgrading will repair any broken
66
+ config in place.
67
+
68
+ Why this didn't bite Mac users: hand-rolled configs that never went
69
+ through `mobygate connect`'s apply path were never written by the
70
+ bad code. It surfaced on Windows when `mobygate init` auto-wired
71
+ detected clients during a fresh upgrade.
72
+
73
+ - **Sonnet 4.6 silently capped at 200k context.** v0.8.2 routed
74
+ `claude-sonnet-4-6` through directly but didn't append the `[1m]`
75
+ suffix the way opus 4.7 does, so the SDK ran sonnet at the default
76
+ 200k window. Confirmed via `modelUsage.contextWindow=200000` in the
77
+ diagnostic logging.
78
+
79
+ Fix: `claude-sonnet-4-6` now maps to `claude-sonnet-4-6[1m]`. Verified
80
+ next-turn `modelUsage` shows `contextWindow: 1000000`. Matches the
81
+ capability we already advertise in `/v1/models`.
82
+
83
+ Side note: `claude-sonnet-4-6-200k` added as an explicit alias for
84
+ callers that want the cheaper 200k variant.
85
+
86
+ ### Added
87
+
88
+ - **`[model-billed]` diagnostic log line.** Every successful (non-tool-
89
+ use-aborted) request now logs the SDK's `modelUsage` map showing
90
+ what model Anthropic actually billed against. Output looks like:
91
+
92
+ ```
93
+ [model-billed] requested=claude-sonnet-4-6[1m]
94
+ modelUsage={"claude-sonnet-4-6[1m]":{"inputTokens":3,"costUSD":0.029,
95
+ "contextWindow":1000000,...}}
96
+ ```
97
+
98
+ This was originally added as a temporary diagnostic to chase whether
99
+ Anthropic was silently swapping sonnet → opus. The data confirmed
100
+ no swap is happening — but the line is too useful to remove. Surfaces
101
+ cost-per-turn, actual context window, and any future silent model
102
+ changes the SDK might introduce. Low log volume (one line per
103
+ result message, ~50% of requests since tool_use turns abort
104
+ before result).
105
+
106
+ Bonus finding from this log: the SDK transparently uses **two models
107
+ per opus turn** — a haiku for fast intermediate work and opus for
108
+ the final response. Adds ~$0.024 per opus turn beyond what the
109
+ capture summary shows. Worth knowing for cost analysis.
110
+
111
+ ### Notes
112
+
113
+ The "claude.ai/settings/usage Sonnet only stuck at 0%" mystery turned
114
+ out to be a Claude Max plan accounting design: the "Sonnet only" bar
115
+ is overflow that only ticks once "All models" is exhausted. On Max
116
+ plans, sonnet usage rolls into the "All models" bar (which was
117
+ climbing as expected). Not a mobygate bug — investigation surfaced
118
+ the v0.8.2 [1m] context omission, which IS a mobygate bug, so net
119
+ positive on the chase.
120
+
7
121
  ## [0.8.2] — 2026-04-28
8
122
 
9
123
  Multi-agent fixes. Found the day after v0.8.1 shipped, while testing
package/lib/anthropic.js CHANGED
@@ -36,17 +36,23 @@ import { v4 as uuidv4 } from 'uuid';
36
36
  * against shape variations (the Claude Agent SDK sometimes nests these
37
37
  * under `.usage`, sometimes places them flat on the message). Returns
38
38
  * a complete usage shape with cache_read / cache_creation fields zeroed
39
- * out if absent. Used by the 4 mobygate handlers to populate response
40
- * captures and dashboard cache-hit metrics.
39
+ * out if absent, plus `modelUsage` (the SDK's per-model usage breakdown,
40
+ * keyed by the actual model name Anthropic billed against — useful for
41
+ * spotting silent model fallbacks where the requested model differs
42
+ * from what Anthropic actually ran).
43
+ *
44
+ * Used by the 4 mobygate handlers to populate response captures and
45
+ * dashboard cache-hit metrics.
41
46
  */
42
47
  export function extractSdkUsage(message) {
43
- if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 };
48
+ if (!message) return { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, modelUsage: null };
44
49
  const u = message.usage || message;
45
50
  return {
46
51
  input_tokens: u.input_tokens || 0,
47
52
  output_tokens: u.output_tokens || 0,
48
53
  cache_read_input_tokens: u.cache_read_input_tokens || 0,
49
54
  cache_creation_input_tokens: u.cache_creation_input_tokens || 0,
55
+ modelUsage: message.modelUsage || null,
50
56
  };
51
57
  }
52
58
 
@@ -169,8 +169,12 @@ export const openclawConnector = {
169
169
  configPath: det.configPath,
170
170
  mobyProviderExists: !!providers[PROVIDER_NAME_OPENAI],
171
171
  mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
172
- currentMain: det.parsed?.models?.main || null,
173
- currentDefault: det.parsed?.models?.default || null,
172
+ currentPrimary: det.parsed?.agents?.defaults?.model?.primary || null,
173
+ // Old keys: still surfaced so the user/dashboard can spot if a
174
+ // pre-v0.8.3 connector run left these around (they're invalid in
175
+ // OpenClaw .26+). A non-null value here is a "needs reconnect" hint.
176
+ deprecatedMain: det.parsed?.models?.main || null,
177
+ deprecatedDefault: det.parsed?.models?.default || null,
174
178
  shadowProviders, // pre-v0.8.0 entries pointing at our base URL
175
179
  };
176
180
  },
@@ -212,14 +216,52 @@ export const openclawConnector = {
212
216
  : null;
213
217
  if (preferredProvider) {
214
218
  const target = `${preferredProvider}/claude-opus-4-7`;
215
- after.models.main = target;
216
- after.models.default = target;
219
+
220
+ // Modern OpenClaw schema (>=2026.4.x): defaults live under
221
+ // `agents.defaults.model.primary` and `agents.defaults.models`.
222
+ // The old `models.main` / `models.default` top-level keys were
223
+ // valid in earlier OpenClaw versions but `.26+` rejects them
224
+ // ("Unrecognized keys: main, default"). v0.8.0/0.8.1 connector
225
+ // wrote the old shape and broke OpenClaw `.26` startup. Fixed
226
+ // in v0.8.3 by switching to the modern path + cleaning up any
227
+ // old keys it previously wrote.
228
+ if (!after.agents) after.agents = {};
229
+ if (!after.agents.defaults) after.agents.defaults = {};
230
+ if (!after.agents.defaults.model) after.agents.defaults.model = {};
231
+ if (!after.agents.defaults.models) after.agents.defaults.models = {};
232
+
233
+ after.agents.defaults.model.primary = target;
234
+ // Register every model we surface in the provider so OpenClaw
235
+ // sees them in agents-list. Keep existing entries to preserve
236
+ // user-registered models (ollama, anthropic-direct, etc).
237
+ for (const m of MODELS_NATIVE_SURFACE) {
238
+ const id = `${preferredProvider}/${m.id}`;
239
+ if (!after.agents.defaults.models[id]) {
240
+ after.agents.defaults.models[id] = {};
241
+ }
242
+ }
243
+
244
+ // Cleanup: remove the deprecated top-level keys if a previous
245
+ // connector run (v0.8.0/0.8.1) wrote them. OpenClaw `.26`
246
+ // rejects the config outright if these are present.
247
+ if (after.models?.main !== undefined) delete after.models.main;
248
+ if (after.models?.default !== undefined) delete after.models.default;
217
249
  }
218
250
  }
219
251
 
220
252
  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 },
253
+ {
254
+ providers: before.models?.providers,
255
+ primary: before.agents?.defaults?.model?.primary,
256
+ deprecatedMain: before.models?.main,
257
+ deprecatedDefault: before.models?.default,
258
+ },
259
+ {
260
+ providers: after.models.providers,
261
+ primary: after.agents?.defaults?.model?.primary,
262
+ deprecatedMain: after.models?.main, // should be undefined post-fix
263
+ deprecatedDefault: after.models?.default, // should be undefined post-fix
264
+ },
223
265
  );
224
266
 
225
267
  return {
@@ -263,14 +305,32 @@ export const openclawConnector = {
263
305
  if (providers[name]) { delete providers[name]; changed = true; }
264
306
  }
265
307
  }
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;
308
+ // Modern path: if the agents.defaults.model.primary points at us,
309
+ // blank it out let OpenClaw fall back to whatever default the
310
+ // user has configured otherwise.
311
+ const primary = after.agents?.defaults?.model?.primary;
312
+ if (isMobyDefaultPointer(primary)) {
313
+ after.agents.defaults.model.primary = null;
314
+ changed = true;
315
+ }
316
+ // Remove our model registrations from agents.defaults.models.
317
+ if (after.agents?.defaults?.models) {
318
+ for (const key of Object.keys(after.agents.defaults.models)) {
319
+ if (isMobyDefaultPointer(key)) {
320
+ delete after.agents.defaults.models[key];
321
+ changed = true;
322
+ }
323
+ }
324
+ }
325
+ // Legacy cleanup: remove deprecated top-level main/default if present
326
+ // (left over from v0.8.0/0.8.1 connector). OpenClaw .26 rejects them
327
+ // outright, so removing on disconnect is the right move.
328
+ if (after.models?.main !== undefined) {
329
+ delete after.models.main;
270
330
  changed = true;
271
331
  }
272
- if (isMobyDefaultPointer(after.models?.default)) {
273
- after.models.default = null;
332
+ if (after.models?.default !== undefined) {
333
+ delete after.models.default;
274
334
  changed = true;
275
335
  }
276
336
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -186,13 +186,22 @@ const MODEL_MAP = {
186
186
  'claude-opus-4-7[1m]': 'claude-opus-4-7[1m]',
187
187
  'claude-opus-4-7-1m': 'claude-opus-4-7[1m]',
188
188
  'claude-opus-4-7-200k': 'claude-opus-4-7',
189
- 'claude-sonnet-4': 'claude-sonnet-4-6', // current latest sonnet
190
- 'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929', // explicit request for older 4-5
191
- 'claude-sonnet-4-6': 'claude-sonnet-4-6', // SDK now supports natively; was retired-mapped before v0.8.2
189
+ // Sonnet 1M context note: unlike Opus 1M (included in Claude Max),
190
+ // Sonnet 1M context requires paid "extra usage" on Max plans —
191
+ // routing all sonnet calls through [1m] gates them on extra-usage
192
+ // billing, so users without it see silent failures. v0.8.4 fix:
193
+ // default sonnet routes to plain `claude-sonnet-4-6` (200k, included);
194
+ // explicit 1M opt-in via the new `claude-sonnet-4-6-1m` alias.
195
+ 'claude-sonnet-4': 'claude-sonnet-4-6', // current latest sonnet, 200k (Max-included)
196
+ 'claude-sonnet-4-5': 'claude-sonnet-4-5-20250929', // explicit request for older 4-5
197
+ 'claude-sonnet-4-6': 'claude-sonnet-4-6', // 200k context (Max-included)
198
+ 'claude-sonnet-4-6-1m': 'claude-sonnet-4-6[1m]', // explicit 1M opt-in (requires Max extra-usage)
199
+ 'claude-sonnet-4-6-200k': 'claude-sonnet-4-6', // explicit 200k alias (redundant, kept for clarity)
192
200
  'claude-haiku-4': 'claude-haiku-4-5-20251001',
193
201
  'claude-haiku-4-5': 'claude-haiku-4-5-20251001',
194
- 'opus': 'claude-opus-4-7[1m]',
195
- 'sonnet': 'claude-sonnet-4-6', // current latest sonnet
202
+ 'opus': 'claude-opus-4-7[1m]', // Opus 1M is Max-included
203
+ 'sonnet': 'claude-sonnet-4-6', // 200k default; use 'sonnet-1m' for explicit 1M
204
+ 'sonnet-1m': 'claude-sonnet-4-6[1m]', // alias for 'sonnet' + explicit 1M opt-in
196
205
  'haiku': 'claude-haiku-4-5-20251001',
197
206
  };
198
207
 
@@ -590,6 +599,7 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
590
599
  outputTokens = usage.output_tokens;
591
600
  cacheReadTokens = usage.cache_read_input_tokens;
592
601
  cacheCreateTokens = usage.cache_creation_input_tokens;
602
+ console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
593
603
  break;
594
604
  }
595
605
  }
@@ -795,6 +805,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
795
805
  outputTokens = usage.output_tokens;
796
806
  cacheReadTokens = usage.cache_read_input_tokens;
797
807
  cacheCreateTokens = usage.cache_creation_input_tokens;
808
+ console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
798
809
  if (message.subtype) stopReason = message.subtype;
799
810
  break;
800
811
  }
@@ -1009,6 +1020,7 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
1009
1020
  outputTokens = usage.output_tokens;
1010
1021
  cacheReadTokens = usage.cache_read_input_tokens;
1011
1022
  cacheCreateTokens = usage.cache_creation_input_tokens;
1023
+ console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
1012
1024
  stopReason = mapStopReason(message);
1013
1025
  break;
1014
1026
  }
@@ -1254,6 +1266,7 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1254
1266
  outputTokens = usage.output_tokens;
1255
1267
  cacheReadTokens = usage.cache_read_input_tokens;
1256
1268
  cacheCreateTokens = usage.cache_creation_input_tokens;
1269
+ console.log(` [model-billed] requested=${resolvedModel} modelUsage=${JSON.stringify(usage.modelUsage || '(none)')}`);
1257
1270
  if (!toolUseEmitted) stopReason = mapStopReason(message);
1258
1271
  break;
1259
1272
  }
@@ -1559,7 +1572,10 @@ app.get('/v1/models', (_req, res) => {
1559
1572
  { id: 'claude-opus-4-7', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
1560
1573
  { id: 'claude-opus-4-7-200k', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
1561
1574
  { id: 'claude-opus-4-6', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
1562
- { id: 'claude-sonnet-4-6', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
1575
+ // Sonnet defaults to 200k (Max-included). Use claude-sonnet-4-6-1m
1576
+ // for the 1M variant, which requires paid extra usage on Max.
1577
+ { id: 'claude-sonnet-4-6', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
1578
+ { id: 'claude-sonnet-4-6-1m', object: 'model', owned_by: 'anthropic', created: now, context_length: 1000000 },
1563
1579
  { id: 'claude-haiku-4-5', object: 'model', owned_by: 'anthropic', created: now, context_length: 200000 },
1564
1580
  ],
1565
1581
  });