@rynfar/meridian 1.42.1 → 1.44.0

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 CHANGED
@@ -239,6 +239,20 @@ meridian profile add work
239
239
 
240
240
  > **⚠ Important:** Claude's OAuth reuses your browser session. Before adding a second account, sign out of claude.ai and sign into the other account first.
241
241
 
242
+ #### Headless / SSH: complete Claude OAuth with a pasted code
243
+
244
+ When you still want a normal Claude Max browser-login profile but the Meridian host cannot open a browser (SSH, WSL, containers, remote servers), use `--headless`. Meridian prints a Claude OAuth URL, prompts for the returned code, exchanges it with PKCE, and saves the resulting credentials into the profile's isolated `CLAUDE_CONFIG_DIR`:
245
+
246
+ ```bash
247
+ meridian profile add work --headless
248
+ ```
249
+
250
+ Open the printed URL in a browser, sign in to the target Claude account, then paste the returned code at Meridian's `Paste code:` prompt. For an existing browser-login profile:
251
+
252
+ ```bash
253
+ meridian profile login work --headless
254
+ ```
255
+
242
256
  #### Headless / CI: register an OAuth token
243
257
 
244
258
  When a browser isn't available (containers, CI runners, remote shells), generate a long-lived OAuth token with `claude setup-token` and register it as a profile:
@@ -269,11 +283,11 @@ You can also switch profiles from the web UI at `http://127.0.0.1:3456/profiles`
269
283
 
270
284
  | Command | Description |
271
285
  |---------|-------------|
272
- | `meridian profile add <name>` | Add a profile and authenticate via browser |
286
+ | `meridian profile add <name> [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials |
273
287
  | `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
274
288
  | `meridian profile list` | List profiles and auth status |
275
289
  | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
276
- | `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
290
+ | `meridian profile login <name> [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow |
277
291
  | `meridian profile remove <name>` | Remove a profile and its credentials |
278
292
 
279
293
  ### How it works
@@ -361,9 +375,11 @@ Add a provider to `~/.config/crush/crush.json`:
361
375
  "base_url": "http://127.0.0.1:3456",
362
376
  "api_key": "dummy",
363
377
  "models": [
364
- { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (1M)", "context_window": 1000000, "default_max_tokens": 64000, "can_reason": true, "supports_attachments": true },
365
- { "id": "claude-opus-4-6", "name": "Claude Opus 4.6 (1M)", "context_window": 1000000, "default_max_tokens": 32768, "can_reason": true, "supports_attachments": true },
366
- { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "context_window": 200000, "default_max_tokens": 16384, "can_reason": true, "supports_attachments": true }
378
+ { "id": "claude-opus-4-8", "name": "Claude Opus 4.8 (1M)", "context_window": 1000000, "default_max_tokens": 32768, "can_reason": true, "supports_attachments": true },
379
+ { "id": "claude-opus-4-7", "name": "Claude Opus 4.7 (1M)", "context_window": 1000000, "default_max_tokens": 32768, "can_reason": true, "supports_attachments": true },
380
+ { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6 (1M)", "context_window": 1000000, "default_max_tokens": 64000, "can_reason": true, "supports_attachments": true },
381
+ { "id": "claude-opus-4-6", "name": "Claude Opus 4.6 (1M)", "context_window": 1000000, "default_max_tokens": 32768, "can_reason": true, "supports_attachments": true },
382
+ { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "context_window": 200000, "default_max_tokens": 16384, "can_reason": true, "supports_attachments": true }
367
383
  ]
368
384
  }
369
385
  }
@@ -384,6 +400,8 @@ Add Meridian as a custom model provider in `~/.factory/settings.json`:
384
400
  ```json
385
401
  {
386
402
  "customModels": [
403
+ { "model": "claude-opus-4-8", "name": "Opus 4.8 (Meridian)", "provider": "anthropic", "baseUrl": "http://127.0.0.1:3456", "apiKey": "x" },
404
+ { "model": "claude-opus-4-7", "name": "Opus 4.7 (Meridian)", "provider": "anthropic", "baseUrl": "http://127.0.0.1:3456", "apiKey": "x" },
387
405
  { "model": "claude-sonnet-4-6", "name": "Sonnet 4.6 (Meridian)", "provider": "anthropic", "baseUrl": "http://127.0.0.1:3456", "apiKey": "x" },
388
406
  { "model": "claude-opus-4-6", "name": "Opus 4.6 (Meridian)", "provider": "anthropic", "baseUrl": "http://127.0.0.1:3456", "apiKey": "x" },
389
407
  { "model": "claude-haiku-4-5-20251001", "name": "Haiku 4.5 (Meridian)", "provider": "anthropic", "baseUrl": "http://127.0.0.1:3456", "apiKey": "x" }
@@ -734,11 +752,11 @@ export default {
734
752
  |---------|-------------|
735
753
  | `meridian` | Start the proxy server |
736
754
  | `meridian setup` | Configure the OpenCode plugin in `~/.config/opencode/opencode.json` |
737
- | `meridian profile add <name>` | Add a profile and authenticate via browser |
755
+ | `meridian profile add <name> [--headless]` | Add a profile and authenticate via Claude OAuth; `--headless` prints a URL, prompts for the returned code, and stores the exchanged credentials |
738
756
  | `meridian profile add <name> --oauth-token [TOKEN]` | Add a headless profile from a `claude setup-token` value (prompts when `TOKEN` is omitted) |
739
757
  | `meridian profile list` | List all profiles and their auth status |
740
758
  | `meridian profile switch <name>` | Switch the active profile (requires running proxy) |
741
- | `meridian profile login <name>` | Re-authenticate an expired profile (browser-login profiles only) |
759
+ | `meridian profile login <name> [--headless]` | Re-authenticate an expired profile (browser-login profiles only); `--headless` uses the URL/code flow |
742
760
  | `meridian profile remove <name>` | Remove a profile and its credentials |
743
761
  | `meridian refresh-token` | Manually refresh the Claude OAuth token (exits 0/1) |
744
762
 
@@ -840,6 +858,21 @@ meridian refresh-token
840
858
  curl -X POST http://127.0.0.1:3456/auth/refresh
841
859
  ```
842
860
 
861
+ **I'm getting `400 You're out of extra usage` only when tools are present. What do I do?**
862
+ First confirm the failure pattern: a tiny no-tool request succeeds, but the same client fails once it sends tool definitions. If that is the case, beta-header stripping and model fallback usually will not help because the request body still contains agentic tool context.
863
+
864
+ For the affected adapter, try disabling the connecting client's system prompt while keeping the Claude Code prompt enabled:
865
+
866
+ ```bash
867
+ curl -X PATCH http://127.0.0.1:3456/settings/api/features/pi \
868
+ -H 'Content-Type: application/json' \
869
+ -d '{"clientSystemPrompt":false,"codeSystemPrompt":true}'
870
+ ```
871
+
872
+ Replace `pi` with the adapter you use (`opencode`, `crush`, `forgecode`, `droid`, `passthrough`, or `openai`). You can make the same change in the `/settings` UI under **SDK Feature Toggles**. (The `openai` adapter — used by the `/v1/chat/completions` endpoint — already defaults `codeSystemPrompt` to off, so on that one you typically only need `clientSystemPrompt:false`.)
873
+
874
+ This keeps the SDK's preset prompt and tool bridge, but removes the external client's large agent prompt from the request. That may help when the error is triggered by the combination of tool definitions plus client prompt context. The tradeoff is that the connected agent may behave more like vanilla Claude Code because its own persona and workflow instructions are no longer included. If it still fails, the remaining options are to use fewer/no tools for that client, enable Extra Usage/API billing, or switch to a local/provider-backed model for that workflow. See [#516](https://github.com/rynfar/meridian/issues/516) for the current debugging thread.
875
+
843
876
  **I'm hitting rate limits on 1M context. What do I do?**
844
877
  Meridian defaults Sonnet to 200k context because Sonnet 1M is always billed as Extra Usage on Max plans — even when regular usage isn't exhausted. This is [Anthropic's intended billing model](https://code.claude.com/docs/en/model-config#extended-context), not a bug. Set `MERIDIAN_SONNET_MODEL=sonnet[1m]` to opt in if you have Extra Usage enabled and understand the billing implications. Opus defaults to 1M context, which is included with Max/Team/Enterprise subscriptions at no extra cost. Note: there is a [known upstream bug](https://github.com/anthropics/claude-code/issues/39841) where Claude Code incorrectly gates Opus 1M behind Extra Usage on Max — this is Anthropic's to fix.
845
878
 
@@ -1,10 +1,10 @@
1
1
  // src/proxy/tokenRefresh.ts
2
- import { execFile as execFileCb } from "child_process";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
- import { homedir, platform, userInfo } from "os";
5
- import { join, dirname, resolve } from "path";
6
- import { createHash } from "crypto";
7
- import { promisify } from "util";
2
+ import { execFile as execFileCb } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { homedir, platform, userInfo } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { promisify } from "node:util";
8
8
 
9
9
  // src/logger.ts
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -102,6 +102,7 @@ function parseKeychainValue(raw) {
102
102
  var keychainWasHexByService = new Map;
103
103
  function buildMacosStore(serviceName) {
104
104
  return {
105
+ refreshKey: `keychain:${serviceName}`,
105
106
  async read() {
106
107
  try {
107
108
  const { stdout } = await execFile("/usr/bin/security", ["find-generic-password", "-s", serviceName, "-a", userInfo().username, "-w"], { timeout: 5000 });
@@ -131,24 +132,26 @@ function buildMacosStore(serviceName) {
131
132
  }
132
133
  var macosStore = buildMacosStore(KEYCHAIN_SERVICE);
133
134
  function buildFileStore(filePath) {
135
+ const absPath = resolve(filePath);
134
136
  return {
137
+ refreshKey: `file:${absPath}`,
135
138
  async read() {
136
139
  try {
137
- if (!existsSync(filePath))
140
+ if (!existsSync(absPath))
138
141
  return null;
139
- return JSON.parse(readFileSync(filePath, "utf-8"));
142
+ return JSON.parse(readFileSync(absPath, "utf-8"));
140
143
  } catch (err) {
141
- claudeLog("token_refresh.file_read_failed", { path: filePath, error: String(err) });
144
+ claudeLog("token_refresh.file_read_failed", { path: absPath, error: String(err) });
142
145
  return null;
143
146
  }
144
147
  },
145
148
  async write(credentials) {
146
149
  try {
147
- mkdirSync(dirname(filePath), { recursive: true });
148
- writeFileSync(filePath, serializeCredentials(credentials), "utf-8");
150
+ mkdirSync(dirname(absPath), { recursive: true });
151
+ writeFileSync(absPath, serializeCredentials(credentials), "utf-8");
149
152
  return true;
150
153
  } catch (err) {
151
- claudeLog("token_refresh.file_write_failed", { path: filePath, error: String(err) });
154
+ claudeLog("token_refresh.file_write_failed", { path: absPath, error: String(err) });
152
155
  return false;
153
156
  }
154
157
  }
@@ -167,14 +170,29 @@ function createPlatformCredentialStore(opts) {
167
170
  function credentialsFilePathForProfile(claudeConfigDir) {
168
171
  return claudeConfigDir ? configDirToCredentialsFile(claudeConfigDir) : CREDENTIALS_FILE;
169
172
  }
170
- var inflightRefresh = null;
173
+ var inflightRefreshByKey = new Map;
174
+ var inflightRefreshByStore = new WeakMap;
171
175
  async function refreshOAuthToken(store) {
172
- if (inflightRefresh)
173
- return inflightRefresh;
174
- inflightRefresh = doRefresh(store ?? createPlatformCredentialStore()).finally(() => {
175
- inflightRefresh = null;
176
+ const s = store ?? createPlatformCredentialStore();
177
+ const refreshKey = s.refreshKey;
178
+ if (refreshKey) {
179
+ const inflight2 = inflightRefreshByKey.get(refreshKey);
180
+ if (inflight2)
181
+ return inflight2;
182
+ const refresh2 = doRefresh(s).finally(() => {
183
+ inflightRefreshByKey.delete(refreshKey);
184
+ });
185
+ inflightRefreshByKey.set(refreshKey, refresh2);
186
+ return refresh2;
187
+ }
188
+ const inflight = inflightRefreshByStore.get(s);
189
+ if (inflight)
190
+ return inflight;
191
+ const refresh = doRefresh(s).finally(() => {
192
+ inflightRefreshByStore.delete(s);
176
193
  });
177
- return inflightRefresh;
194
+ inflightRefreshByStore.set(s, refresh);
195
+ return refresh;
178
196
  }
179
197
  async function doRefresh(store) {
180
198
  const credentials = await store.read();
@@ -273,7 +291,6 @@ async function scheduleNext(store, bufferMs, failureRetryMs, gen) {
273
291
  if (!scheduledRefreshActive || gen !== scheduledRefreshGeneration)
274
292
  return;
275
293
  claudeLog("token_refresh.scheduled", { ok, immediate: true });
276
- console.error(`[token_refresh] scheduled refresh (immediate) ok=${ok}`);
277
294
  armTimer(ok ? 0 : failureRetryMs, store, bufferMs, failureRetryMs, gen);
278
295
  return;
279
296
  }
@@ -293,7 +310,7 @@ function isBackgroundRefreshActive() {
293
310
  return scheduledRefreshActive;
294
311
  }
295
312
  function resetInflightRefresh() {
296
- inflightRefresh = null;
313
+ inflightRefreshByKey.clear();
297
314
  }
298
315
 
299
316
  export { withClaudeLogContext, claudeLog, configDirToKeychainService, configDirToCredentialsFile, serializeCredentials, createPlatformCredentialStore, credentialsFilePathForProfile, refreshOAuthToken, ensureFreshToken, startBackgroundRefresh, stopBackgroundRefresh, isBackgroundRefreshActive, resetInflightRefresh };