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 +22 -0
- package/README.md +32 -1
- package/claude-api-server/server.js +60 -24
- package/cli/lib/docker.js +411 -411
- package/package.json +1 -1
- package/stt-local/server.py +45 -45
- package/voice-app/DEPLOYMENT.md +2 -0
- package/voice-app/lib/claude-bridge.js +113 -113
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
|
-
|
|
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:
|
|
110
|
-
// If
|
|
111
|
-
delete
|
|
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
|
-
|
|
136
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
});
|