claude-phone-local 2.3.1 → 2.3.3

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/.env.example CHANGED
@@ -54,6 +54,28 @@ DEFAULT_CALLER_ID=+15551234567
54
54
  # URL to your Claude API server (runs on your API server with Claude Max)
55
55
  CLAUDE_API_URL=http://10.0.0.200:3333
56
56
 
57
+ # Model the host-side wrapper (claude-api-server) asks Claude Code to use.
58
+ # Default if unset: claude-sonnet-5
59
+ # Set to a specific id for a custom / free API (OmniRoute, OpenRouter, etc.):
60
+ # CLAUDE_MODEL=gemini-2.5-flash
61
+ # CLAUDE_MODEL=deepseek-chat
62
+ # CLAUDE_MODEL=claude-3-5-haiku-20241022
63
+ # Or omit the flag entirely so Claude Code / OmniRoute uses its own default:
64
+ # CLAUDE_MODEL=default
65
+ # CLAUDE_MODEL=claude-sonnet-5
66
+
67
+ # Seconds before giving up on a phone-turn answer (default 180)
68
+ # CLAUDE_TIMEOUT=180
69
+
70
+ # ---- Custom / free API proxy (OmniRoute, OpenRouter, LiteLLM, etc.) ----
71
+ # When ANTHROPIC_BASE_URL is set, claude-api-server KEEPS ANTHROPIC_API_KEY
72
+ # so the proxy can authenticate. Without a custom base URL the key is stripped
73
+ # so Claude Code uses your Claude subscription login instead.
74
+ # ANTHROPIC_BASE_URL=https://your-omniroute-host/v1
75
+ # ANTHROPIC_API_KEY=your-proxy-key
76
+ # If the key must be kept even without ANTHROPIC_BASE_URL:
77
+ # CLAUDE_USE_API_KEY=1
78
+
57
79
  # ====================================
58
80
  # Speech-to-text / Text-to-speech mode
59
81
  # ====================================
package/README.md CHANGED
@@ -36,12 +36,38 @@ Manual, one time:
36
36
  - **Claude Code CLI**, logged in — `claude --version`
37
37
  - **Node.js 18+** on the host, for `claude-api-server`
38
38
  - A **SIP extension** on 3CX (or any SIP PBX)
39
+ - **3CX Session Border Controller (SBC)** installed on this PC — required
40
+ unless this machine is already on the same LAN as your 3CX PBX with no
41
+ NAT/firewall in between. Most setups need it. `claude-phone setup` detects
42
+ it automatically and configures around it (moves drachtio to port 5070 to
43
+ avoid the SBC's port 5060). Full instructions: [docs/SETUP.md § Install the
44
+ 3CX SBC](docs/SETUP.md#2-install-the-3cx-sbc-only-if-needed).
39
45
 
40
46
  Everything else — speech models, voices, device config — is downloaded and
41
47
  generated automatically on first run.
42
48
 
43
49
  ## Quick start
44
50
 
51
+ The npm CLI is the recommended path — it installs, prompts you through 3CX/
52
+ device config, and starts everything (including the host-side Claude wrapper)
53
+ for you:
54
+
55
+ ```bash
56
+ npm install -g claude-phone-local
57
+ claude-phone setup # interactive: 3CX domain, extension, speech mode, etc.
58
+ claude-phone start # builds + launches everything, including claude-api-server
59
+ ```
60
+
61
+ When you see `✓ All services running!` with your extension listed, call it.
62
+ Full prompt-by-prompt walkthrough (3CX extension → SBC → Docker → npm install
63
+ → setup → start): **[docs/SETUP.md](docs/SETUP.md)**.
64
+
65
+ <details>
66
+ <summary>Manual / dev setup (git clone + docker compose)</summary>
67
+
68
+ For local development on this repo, or if you'd rather manage `.env` and
69
+ `docker compose` by hand instead of the CLI:
70
+
45
71
  ```bash
46
72
  git clone <your-fork> claude-phone-local
47
73
  cd claude-phone-local
@@ -67,7 +93,10 @@ In a second terminal, start the host-side Claude wrapper:
67
93
 
68
94
  ```bash
69
95
  cd claude-api-server
70
- CLAUDE_MODEL=claude-sonnet-5 node server.js
96
+ # Default model is claude-sonnet-5. For OmniRoute / a custom API:
97
+ # CLAUDE_MODEL=default # let Claude Code pick
98
+ # CLAUDE_MODEL=gemini-2.5-flash # or whatever your proxy supports
99
+ node server.js
71
100
  ```
72
101
 
73
102
  When you see this, call your extension:
@@ -76,6 +105,8 @@ When you see this, call your extension:
76
105
  [MULTI-REGISTRAR] Maya SUCCESS - Registered as ext 17512
77
106
  ```
78
107
 
108
+ </details>
109
+
79
110
  ## Architecture
80
111
 
81
112
  One container, one host process:
@@ -106,9 +106,12 @@ function buildClaudeEnvironment() {
106
106
  CLAUDE_CODE_ENTRYPOINT: 'cli',
107
107
  };
108
108
 
109
- // CRITICAL: Remove ANTHROPIC_API_KEY so Claude CLI uses subscription auth
110
- // If ANTHROPIC_API_KEY is set (even to placeholder), CLI tries API auth instead
111
- delete env.ANTHROPIC_API_KEY;
109
+ // CRITICAL: Only remove ANTHROPIC_API_KEY when NOT using custom API proxy
110
+ // If ANTHROPIC_BASE_URL or CLAUDE_USE_API_KEY is set, keep the key for proxy auth
111
+ // Otherwise delete it so Claude CLI uses subscription auth
112
+ if (!env.ANTHROPIC_BASE_URL && !env.CLAUDE_USE_API_KEY) {
113
+ delete env.ANTHROPIC_API_KEY;
114
+ }
112
115
 
113
116
  return env;
114
117
  }
@@ -123,6 +126,9 @@ const apiKeys = Object.keys(claudeEnv).filter(k =>
123
126
  k.includes('API_KEY') || k.includes('TOKEN') || k.includes('SECRET') || k === 'PAI_DIR'
124
127
  );
125
128
  console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
129
+ console.log('[STARTUP] Claude model:', CLAUDE_MODEL || 'default (Claude Code / OmniRoute)');
130
+ console.log('[STARTUP] ANTHROPIC_BASE_URL:', claudeEnv.ANTHROPIC_BASE_URL || '(not set — subscription auth)');
131
+ console.log('[STARTUP] ANTHROPIC_API_KEY:', claudeEnv.ANTHROPIC_API_KEY ? 'kept (proxy auth)' : 'stripped (subscription auth)');
126
132
 
127
133
  // Every phone turn used to spawn a fresh CLI process. Without
128
134
  // --strict-mcp-config it tries to connect to every configured MCP server
@@ -132,8 +138,27 @@ console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
132
138
  // available over the phone.
133
139
  const STRICT_MCP = process.env.PHONE_ENABLE_MCP !== '1';
134
140
 
135
- // Model selection - Sonnet for balanced speed/quality
136
- const CLAUDE_MODEL = process.env.CLAUDE_MODEL || 'claude-sonnet-5';
141
+ /**
142
+ * Resolve the Claude model to use.
143
+ *
144
+ * OmniRoute / custom API proxies often don't support `claude-sonnet-5`.
145
+ * Set CLAUDE_MODEL to the proxy's model id, or to "" / "default" / "none"
146
+ * to omit --model entirely and let Claude Code / OmniRoute pick its own.
147
+ *
148
+ * Request bodies may also pass `model` to override per-call.
149
+ */
150
+ function resolveClaudeModel(requestModel) {
151
+ const raw = (requestModel !== undefined && requestModel !== null && String(requestModel).trim() !== '')
152
+ ? String(requestModel).trim()
153
+ : (process.env.CLAUDE_MODEL !== undefined ? process.env.CLAUDE_MODEL : 'claude-sonnet-5');
154
+ const value = String(raw).trim();
155
+ if (!value || value.toLowerCase() === 'default' || value.toLowerCase() === 'none') {
156
+ return null; // omit --model / SDK model option
157
+ }
158
+ return value;
159
+ }
160
+
161
+ const CLAUDE_MODEL = resolveClaudeModel();
137
162
 
138
163
  /**
139
164
  * Voice Context - Prepended to all voice queries
@@ -194,21 +219,25 @@ Example response:
194
219
  const callSessions = new Map();
195
220
 
196
221
  class CallSession {
197
- constructor(callId) {
222
+ constructor(callId, model = CLAUDE_MODEL) {
198
223
  this.callId = callId;
224
+ this.model = model;
199
225
  this._queue = [];
200
226
  this._queueWaiters = [];
201
227
  this._ended = false;
202
228
  this._pendingResultResolvers = [];
203
229
 
230
+ const options = {
231
+ permissionMode: 'bypassPermissions',
232
+ allowDangerouslySkipPermissions: true,
233
+ strictMcpConfig: STRICT_MCP,
234
+ };
235
+ // Omit model so Claude Code / OmniRoute uses its own default
236
+ if (model) options.model = model;
237
+
204
238
  this.query = query({
205
239
  prompt: this._messageGenerator(),
206
- options: {
207
- model: CLAUDE_MODEL,
208
- permissionMode: 'bypassPermissions',
209
- allowDangerouslySkipPermissions: true,
210
- strictMcpConfig: STRICT_MCP,
211
- },
240
+ options,
212
241
  });
213
242
 
214
243
  this._consumeLoop().catch((err) => {
@@ -276,12 +305,12 @@ class CallSession {
276
305
  }
277
306
  }
278
307
 
279
- function getOrCreateSession(callId) {
308
+ function getOrCreateSession(callId, model = CLAUDE_MODEL) {
280
309
  let session = callSessions.get(callId);
281
310
  if (!session) {
282
- session = new CallSession(callId);
311
+ session = new CallSession(callId, model);
283
312
  callSessions.set(callId, session);
284
- console.log(`[${new Date().toISOString()}] SDK session started: ${callId}`);
313
+ console.log(`[${new Date().toISOString()}] SDK session started: ${callId} (model=${model || 'default'})`);
285
314
  }
286
315
  return session;
287
316
  }
@@ -321,15 +350,16 @@ function parseClaudeStdout(stdout) {
321
350
  // Session storage for the one-shot /ask-structured path only.
322
351
  const structuredSessions = new Map();
323
352
 
324
- function runClaudeOnce({ fullPrompt, callId, timestamp }) {
353
+ function runClaudeOnce({ fullPrompt, callId, timestamp, model = CLAUDE_MODEL }) {
325
354
  const startTime = Date.now();
326
355
 
327
356
  const args = [
328
357
  '--dangerously-skip-permissions',
329
358
  ...(STRICT_MCP ? ['--strict-mcp-config'] : []),
330
359
  '-p', fullPrompt,
331
- '--model', CLAUDE_MODEL
332
360
  ];
361
+ // Omit --model so Claude Code / OmniRoute uses its own default
362
+ if (model) args.push('--model', model);
333
363
 
334
364
  if (callId) {
335
365
  if (structuredSessions.has(callId)) {
@@ -384,7 +414,8 @@ app.use((req, res, next) => {
384
414
  * {
385
415
  * "prompt": "What Docker containers are running?",
386
416
  * "callId": "optional-call-uuid",
387
- * "devicePrompt": "optional device-specific prompt"
417
+ * "devicePrompt": "optional device-specific prompt",
418
+ * "model": "optional model override (or \"default\" to omit --model)"
388
419
  * }
389
420
  *
390
421
  * Response:
@@ -401,9 +432,10 @@ app.use((req, res, next) => {
401
432
  * - This allows each device (NAS, Proxmox, etc.) to have its own identity and skills
402
433
  */
403
434
  app.post('/ask', async (req, res) => {
404
- const { prompt, callId, devicePrompt } = req.body;
435
+ const { prompt, callId, devicePrompt, model: requestModel } = req.body;
405
436
  const startTime = Date.now();
406
437
  const timestamp = new Date().toISOString();
438
+ const model = resolveClaudeModel(requestModel);
407
439
 
408
440
  if (!prompt) {
409
441
  return res.status(400).json({
@@ -413,7 +445,7 @@ app.post('/ask', async (req, res) => {
413
445
  }
414
446
 
415
447
  console.log(`[${timestamp}] QUERY: "${prompt.substring(0, 100)}..."`);
416
- console.log(`[${timestamp}] MODEL: ${CLAUDE_MODEL}`);
448
+ console.log(`[${timestamp}] MODEL: ${model || 'default (Claude Code / OmniRoute)'}`);
417
449
  console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${callId ? callSessions.has(callId) : false}`);
418
450
  console.log(`[${timestamp}] DEVICE PROMPT: ${devicePrompt ? 'Yes (' + devicePrompt.substring(0, 30) + '...)' : 'No'}`);
419
451
 
@@ -429,7 +461,7 @@ app.post('/ask', async (req, res) => {
429
461
  * every turn would just be redundant tokens (the CLI's --resume worked
430
462
  * the same way: system framing lived in turn 1's prompt).
431
463
  */
432
- const session = callId ? getOrCreateSession(callId) : null;
464
+ const session = callId ? getOrCreateSession(callId, model) : null;
433
465
 
434
466
  let fullPrompt = '';
435
467
  if (!session || session._sentContext !== true) {
@@ -441,7 +473,7 @@ app.post('/ask', async (req, res) => {
441
473
  }
442
474
  fullPrompt += prompt;
443
475
 
444
- const activeSession = session || getOrCreateSession(`__oneshot_${startTime}_${Math.random().toString(36).slice(2)}`);
476
+ const activeSession = session || getOrCreateSession(`__oneshot_${startTime}_${Math.random().toString(36).slice(2)}`, model);
445
477
 
446
478
  const result = await activeSession.sendMessage(fullPrompt);
447
479
 
@@ -512,9 +544,11 @@ app.post('/ask-structured', async (req, res) => {
512
544
  schema = {},
513
545
  includeVoiceContext = false,
514
546
  maxRetries = 1,
547
+ model: requestModel,
515
548
  } = req.body || {};
516
549
 
517
550
  const timestamp = new Date().toISOString();
551
+ const model = resolveClaudeModel(requestModel);
518
552
 
519
553
  if (!prompt) {
520
554
  return res.status(400).json({ success: false, error: 'Missing prompt in request body' });
@@ -535,7 +569,7 @@ app.post('/ask-structured', async (req, res) => {
535
569
  });
536
570
 
537
571
  console.log(`[${timestamp}] STRUCTURED QUERY: "${String(prompt).substring(0, 100)}..."`);
538
- console.log(`[${timestamp}] MODEL: ${CLAUDE_MODEL}`);
572
+ console.log(`[${timestamp}] MODEL: ${model || 'default (Claude Code / OmniRoute)'}`);
539
573
  console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${callId ? (structuredSessions.has(callId) ? 'yes' : 'no') : 'none'}`);
540
574
 
541
575
  try {
@@ -547,7 +581,7 @@ app.post('/ask-structured', async (req, res) => {
547
581
 
548
582
  for (let attempt = 0; attempt <= retries; attempt++) {
549
583
  attemptsMade = attempt + 1;
550
- const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp });
584
+ const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp, model });
551
585
  totalDuration += duration_ms;
552
586
 
553
587
  if (code !== 0) {
@@ -652,6 +686,8 @@ app.get('/health', (req, res) => {
652
686
  res.json({
653
687
  status: 'ok',
654
688
  service: 'claude-api-server',
689
+ model: CLAUDE_MODEL || 'default',
690
+ proxyAuth: Boolean(claudeEnv.ANTHROPIC_BASE_URL || claudeEnv.CLAUDE_USE_API_KEY),
655
691
  timestamp: new Date().toISOString()
656
692
  });
657
693
  });