claude-phone-local 2.3.2 → 2.3.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/.env.example +22 -0
- package/README.md +10 -1
- package/claude-api-server/package-lock.json +0 -12
- package/claude-api-server/server.js +70 -32
- 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,6 +36,12 @@ 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.
|
|
@@ -87,7 +93,10 @@ In a second terminal, start the host-side Claude wrapper:
|
|
|
87
93
|
|
|
88
94
|
```bash
|
|
89
95
|
cd claude-api-server
|
|
90
|
-
|
|
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
|
|
91
100
|
```
|
|
92
101
|
|
|
93
102
|
When you see this, call your extension:
|
|
@@ -73,9 +73,6 @@
|
|
|
73
73
|
"cpu": [
|
|
74
74
|
"arm64"
|
|
75
75
|
],
|
|
76
|
-
"libc": [
|
|
77
|
-
"glibc"
|
|
78
|
-
],
|
|
79
76
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
80
77
|
"optional": true,
|
|
81
78
|
"os": [
|
|
@@ -89,9 +86,6 @@
|
|
|
89
86
|
"cpu": [
|
|
90
87
|
"arm64"
|
|
91
88
|
],
|
|
92
|
-
"libc": [
|
|
93
|
-
"musl"
|
|
94
|
-
],
|
|
95
89
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
96
90
|
"optional": true,
|
|
97
91
|
"os": [
|
|
@@ -105,9 +99,6 @@
|
|
|
105
99
|
"cpu": [
|
|
106
100
|
"x64"
|
|
107
101
|
],
|
|
108
|
-
"libc": [
|
|
109
|
-
"glibc"
|
|
110
|
-
],
|
|
111
102
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
112
103
|
"optional": true,
|
|
113
104
|
"os": [
|
|
@@ -121,9 +112,6 @@
|
|
|
121
112
|
"cpu": [
|
|
122
113
|
"x64"
|
|
123
114
|
],
|
|
124
|
-
"libc": [
|
|
125
|
-
"musl"
|
|
126
|
-
],
|
|
127
115
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
128
116
|
"optional": true,
|
|
129
117
|
"os": [
|
|
@@ -106,23 +106,18 @@ 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
|
}
|
|
115
118
|
|
|
116
119
|
// Pre-build the environment once at startup
|
|
117
120
|
const claudeEnv = buildClaudeEnvironment();
|
|
118
|
-
console.log('[STARTUP] Loaded environment with', Object.keys(claudeEnv).length, 'variables');
|
|
119
|
-
console.log('[STARTUP] PATH includes:', claudeEnv.PATH.split(':').slice(0, 5).join(', '), '...');
|
|
120
|
-
|
|
121
|
-
// Log which API keys are available (without showing values)
|
|
122
|
-
const apiKeys = Object.keys(claudeEnv).filter(k =>
|
|
123
|
-
k.includes('API_KEY') || k.includes('TOKEN') || k.includes('SECRET') || k === 'PAI_DIR'
|
|
124
|
-
);
|
|
125
|
-
console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
|
|
126
121
|
|
|
127
122
|
// Every phone turn used to spawn a fresh CLI process. Without
|
|
128
123
|
// --strict-mcp-config it tries to connect to every configured MCP server
|
|
@@ -132,8 +127,40 @@ console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
|
|
|
132
127
|
// available over the phone.
|
|
133
128
|
const STRICT_MCP = process.env.PHONE_ENABLE_MCP !== '1';
|
|
134
129
|
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the Claude model to use.
|
|
132
|
+
*
|
|
133
|
+
* OmniRoute / custom API proxies often don't support `claude-sonnet-5`.
|
|
134
|
+
* Set CLAUDE_MODEL to the proxy's model id, or to "" / "default" / "none"
|
|
135
|
+
* to omit --model entirely and let Claude Code / OmniRoute pick its own.
|
|
136
|
+
*
|
|
137
|
+
* Request bodies may also pass `model` to override per-call.
|
|
138
|
+
*/
|
|
139
|
+
function resolveClaudeModel(requestModel) {
|
|
140
|
+
const fallback = (process.env.ANTHROPIC_BASE_URL || process.env.CLAUDE_USE_API_KEY) ? 'default' : 'claude-sonnet-5';
|
|
141
|
+
const raw = (requestModel !== undefined && requestModel !== null && String(requestModel).trim() !== '')
|
|
142
|
+
? String(requestModel).trim()
|
|
143
|
+
: (process.env.CLAUDE_MODEL !== undefined ? process.env.CLAUDE_MODEL : fallback);
|
|
144
|
+
const value = String(raw).trim();
|
|
145
|
+
if (!value || value.toLowerCase() === 'default' || value.toLowerCase() === 'none') {
|
|
146
|
+
return null; // omit --model / SDK model option
|
|
147
|
+
}
|
|
148
|
+
return value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const CLAUDE_MODEL = resolveClaudeModel();
|
|
152
|
+
|
|
153
|
+
console.log('[STARTUP] Loaded environment with', Object.keys(claudeEnv).length, 'variables');
|
|
154
|
+
console.log('[STARTUP] PATH includes:', claudeEnv.PATH.split(':').slice(0, 5).join(', '), '...');
|
|
155
|
+
|
|
156
|
+
// Log which API keys are available (without showing values)
|
|
157
|
+
const apiKeys = Object.keys(claudeEnv).filter(k =>
|
|
158
|
+
k.includes('API_KEY') || k.includes('TOKEN') || k.includes('SECRET') || k === 'PAI_DIR'
|
|
159
|
+
);
|
|
160
|
+
console.log('[STARTUP] API keys loaded:', apiKeys.join(', '));
|
|
161
|
+
console.log('[STARTUP] Claude model:', CLAUDE_MODEL || 'default (Claude Code / OmniRoute)');
|
|
162
|
+
console.log('[STARTUP] ANTHROPIC_BASE_URL:', claudeEnv.ANTHROPIC_BASE_URL || '(not set — subscription auth)');
|
|
163
|
+
console.log('[STARTUP] ANTHROPIC_API_KEY:', claudeEnv.ANTHROPIC_API_KEY ? 'kept (proxy auth)' : 'stripped (subscription auth)');
|
|
137
164
|
|
|
138
165
|
/**
|
|
139
166
|
* Voice Context - Prepended to all voice queries
|
|
@@ -194,21 +221,25 @@ Example response:
|
|
|
194
221
|
const callSessions = new Map();
|
|
195
222
|
|
|
196
223
|
class CallSession {
|
|
197
|
-
constructor(callId) {
|
|
224
|
+
constructor(callId, model = CLAUDE_MODEL) {
|
|
198
225
|
this.callId = callId;
|
|
226
|
+
this.model = model;
|
|
199
227
|
this._queue = [];
|
|
200
228
|
this._queueWaiters = [];
|
|
201
229
|
this._ended = false;
|
|
202
230
|
this._pendingResultResolvers = [];
|
|
203
231
|
|
|
232
|
+
const options = {
|
|
233
|
+
permissionMode: 'bypassPermissions',
|
|
234
|
+
allowDangerouslySkipPermissions: true,
|
|
235
|
+
strictMcpConfig: STRICT_MCP,
|
|
236
|
+
};
|
|
237
|
+
// Omit model so Claude Code / OmniRoute uses its own default
|
|
238
|
+
if (model) options.model = model;
|
|
239
|
+
|
|
204
240
|
this.query = query({
|
|
205
241
|
prompt: this._messageGenerator(),
|
|
206
|
-
options
|
|
207
|
-
model: CLAUDE_MODEL,
|
|
208
|
-
permissionMode: 'bypassPermissions',
|
|
209
|
-
allowDangerouslySkipPermissions: true,
|
|
210
|
-
strictMcpConfig: STRICT_MCP,
|
|
211
|
-
},
|
|
242
|
+
options,
|
|
212
243
|
});
|
|
213
244
|
|
|
214
245
|
this._consumeLoop().catch((err) => {
|
|
@@ -276,12 +307,12 @@ class CallSession {
|
|
|
276
307
|
}
|
|
277
308
|
}
|
|
278
309
|
|
|
279
|
-
function getOrCreateSession(callId) {
|
|
310
|
+
function getOrCreateSession(callId, model = CLAUDE_MODEL) {
|
|
280
311
|
let session = callSessions.get(callId);
|
|
281
312
|
if (!session) {
|
|
282
|
-
session = new CallSession(callId);
|
|
313
|
+
session = new CallSession(callId, model);
|
|
283
314
|
callSessions.set(callId, session);
|
|
284
|
-
console.log(`[${new Date().toISOString()}] SDK session started: ${callId}`);
|
|
315
|
+
console.log(`[${new Date().toISOString()}] SDK session started: ${callId} (model=${model || 'default'})`);
|
|
285
316
|
}
|
|
286
317
|
return session;
|
|
287
318
|
}
|
|
@@ -321,15 +352,16 @@ function parseClaudeStdout(stdout) {
|
|
|
321
352
|
// Session storage for the one-shot /ask-structured path only.
|
|
322
353
|
const structuredSessions = new Map();
|
|
323
354
|
|
|
324
|
-
function runClaudeOnce({ fullPrompt, callId, timestamp }) {
|
|
355
|
+
function runClaudeOnce({ fullPrompt, callId, timestamp, model = CLAUDE_MODEL }) {
|
|
325
356
|
const startTime = Date.now();
|
|
326
357
|
|
|
327
358
|
const args = [
|
|
328
359
|
'--dangerously-skip-permissions',
|
|
329
360
|
...(STRICT_MCP ? ['--strict-mcp-config'] : []),
|
|
330
361
|
'-p', fullPrompt,
|
|
331
|
-
'--model', CLAUDE_MODEL
|
|
332
362
|
];
|
|
363
|
+
// Omit --model so Claude Code / OmniRoute uses its own default
|
|
364
|
+
if (model) args.push('--model', model);
|
|
333
365
|
|
|
334
366
|
if (callId) {
|
|
335
367
|
if (structuredSessions.has(callId)) {
|
|
@@ -384,7 +416,8 @@ app.use((req, res, next) => {
|
|
|
384
416
|
* {
|
|
385
417
|
* "prompt": "What Docker containers are running?",
|
|
386
418
|
* "callId": "optional-call-uuid",
|
|
387
|
-
* "devicePrompt": "optional device-specific prompt"
|
|
419
|
+
* "devicePrompt": "optional device-specific prompt",
|
|
420
|
+
* "model": "optional model override (or \"default\" to omit --model)"
|
|
388
421
|
* }
|
|
389
422
|
*
|
|
390
423
|
* Response:
|
|
@@ -401,9 +434,10 @@ app.use((req, res, next) => {
|
|
|
401
434
|
* - This allows each device (NAS, Proxmox, etc.) to have its own identity and skills
|
|
402
435
|
*/
|
|
403
436
|
app.post('/ask', async (req, res) => {
|
|
404
|
-
const { prompt, callId, devicePrompt } = req.body;
|
|
437
|
+
const { prompt, callId, devicePrompt, model: requestModel } = req.body;
|
|
405
438
|
const startTime = Date.now();
|
|
406
439
|
const timestamp = new Date().toISOString();
|
|
440
|
+
const model = resolveClaudeModel(requestModel);
|
|
407
441
|
|
|
408
442
|
if (!prompt) {
|
|
409
443
|
return res.status(400).json({
|
|
@@ -413,7 +447,7 @@ app.post('/ask', async (req, res) => {
|
|
|
413
447
|
}
|
|
414
448
|
|
|
415
449
|
console.log(`[${timestamp}] QUERY: "${prompt.substring(0, 100)}..."`);
|
|
416
|
-
console.log(`[${timestamp}] MODEL: ${
|
|
450
|
+
console.log(`[${timestamp}] MODEL: ${model || 'default (Claude Code / OmniRoute)'}`);
|
|
417
451
|
console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${callId ? callSessions.has(callId) : false}`);
|
|
418
452
|
console.log(`[${timestamp}] DEVICE PROMPT: ${devicePrompt ? 'Yes (' + devicePrompt.substring(0, 30) + '...)' : 'No'}`);
|
|
419
453
|
|
|
@@ -429,7 +463,7 @@ app.post('/ask', async (req, res) => {
|
|
|
429
463
|
* every turn would just be redundant tokens (the CLI's --resume worked
|
|
430
464
|
* the same way: system framing lived in turn 1's prompt).
|
|
431
465
|
*/
|
|
432
|
-
const session = callId ? getOrCreateSession(callId) : null;
|
|
466
|
+
const session = callId ? getOrCreateSession(callId, model) : null;
|
|
433
467
|
|
|
434
468
|
let fullPrompt = '';
|
|
435
469
|
if (!session || session._sentContext !== true) {
|
|
@@ -441,7 +475,7 @@ app.post('/ask', async (req, res) => {
|
|
|
441
475
|
}
|
|
442
476
|
fullPrompt += prompt;
|
|
443
477
|
|
|
444
|
-
const activeSession = session || getOrCreateSession(`__oneshot_${startTime}_${Math.random().toString(36).slice(2)}
|
|
478
|
+
const activeSession = session || getOrCreateSession(`__oneshot_${startTime}_${Math.random().toString(36).slice(2)}`, model);
|
|
445
479
|
|
|
446
480
|
const result = await activeSession.sendMessage(fullPrompt);
|
|
447
481
|
|
|
@@ -512,9 +546,11 @@ app.post('/ask-structured', async (req, res) => {
|
|
|
512
546
|
schema = {},
|
|
513
547
|
includeVoiceContext = false,
|
|
514
548
|
maxRetries = 1,
|
|
549
|
+
model: requestModel,
|
|
515
550
|
} = req.body || {};
|
|
516
551
|
|
|
517
552
|
const timestamp = new Date().toISOString();
|
|
553
|
+
const model = resolveClaudeModel(requestModel);
|
|
518
554
|
|
|
519
555
|
if (!prompt) {
|
|
520
556
|
return res.status(400).json({ success: false, error: 'Missing prompt in request body' });
|
|
@@ -535,7 +571,7 @@ app.post('/ask-structured', async (req, res) => {
|
|
|
535
571
|
});
|
|
536
572
|
|
|
537
573
|
console.log(`[${timestamp}] STRUCTURED QUERY: "${String(prompt).substring(0, 100)}..."`);
|
|
538
|
-
console.log(`[${timestamp}] MODEL: ${
|
|
574
|
+
console.log(`[${timestamp}] MODEL: ${model || 'default (Claude Code / OmniRoute)'}`);
|
|
539
575
|
console.log(`[${timestamp}] SESSION: callId=${callId || 'none'}, existing=${callId ? (structuredSessions.has(callId) ? 'yes' : 'no') : 'none'}`);
|
|
540
576
|
|
|
541
577
|
try {
|
|
@@ -547,7 +583,7 @@ app.post('/ask-structured', async (req, res) => {
|
|
|
547
583
|
|
|
548
584
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
549
585
|
attemptsMade = attempt + 1;
|
|
550
|
-
const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp });
|
|
586
|
+
const { code, stdout, stderr, duration_ms } = await runClaudeOnce({ fullPrompt, callId, timestamp, model });
|
|
551
587
|
totalDuration += duration_ms;
|
|
552
588
|
|
|
553
589
|
if (code !== 0) {
|
|
@@ -652,6 +688,8 @@ app.get('/health', (req, res) => {
|
|
|
652
688
|
res.json({
|
|
653
689
|
status: 'ok',
|
|
654
690
|
service: 'claude-api-server',
|
|
691
|
+
model: CLAUDE_MODEL || 'default',
|
|
692
|
+
proxyAuth: Boolean(claudeEnv.ANTHROPIC_BASE_URL || claudeEnv.CLAUDE_USE_API_KEY),
|
|
655
693
|
timestamp: new Date().toISOString()
|
|
656
694
|
});
|
|
657
695
|
});
|