feat: direct Anthropic SDK streaming, bypass claude CLI
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
"start": "node server.js"
|
"start": "node server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
server.js
99
server.js
@@ -1,84 +1,79 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { spawn, execSync } = require('child_process');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const { createRequire } = require('module');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Resolve claude binary
|
|
||||||
let CLAUDE_BIN = 'claude';
|
|
||||||
{
|
|
||||||
const local = '/app/claude-bin/node_modules/.bin/claude';
|
|
||||||
if (fs.existsSync(local)) {
|
|
||||||
CLAUDE_BIN = local;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
CLAUDE_BIN = execSync('which claude 2>/dev/null || echo claude', { encoding: 'utf8' }).trim();
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`✓ Claude binary: ${CLAUDE_BIN}`);
|
|
||||||
|
|
||||||
const MODELS = {
|
const MODELS = {
|
||||||
sonnet: 'claude-sonnet-4-6',
|
sonnet: 'claude-sonnet-4-6',
|
||||||
haiku: 'claude-haiku-4-5-20251001',
|
haiku: 'claude-haiku-4-5-20251001',
|
||||||
};
|
};
|
||||||
|
|
||||||
app.post('/api/chat', (req, res) => {
|
function getAccessToken() {
|
||||||
|
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
||||||
|
const credsPath = path.join(configDir, '.credentials.json');
|
||||||
|
try {
|
||||||
|
const creds = JSON.parse(fs.readFileSync(credsPath, 'utf8'));
|
||||||
|
return creds?.claudeAiOauth?.accessToken || null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(accessToken) {
|
||||||
|
const Anthropic = require('@anthropic-ai/sdk');
|
||||||
|
// OAuth token: use as apiKey — SDK sends x-api-key header
|
||||||
|
// Some versions also support authToken for Bearer auth
|
||||||
|
try {
|
||||||
|
return new Anthropic({ authToken: accessToken });
|
||||||
|
} catch (e) {
|
||||||
|
return new Anthropic({ apiKey: accessToken });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/api/chat', async (req, res) => {
|
||||||
const { text, model = 'sonnet' } = req.body;
|
const { text, model = 'sonnet' } = req.body;
|
||||||
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
|
if (!text?.trim()) return res.status(400).json({ error: 'text required' });
|
||||||
|
|
||||||
const modelId = MODELS[model] || MODELS.sonnet;
|
const modelId = MODELS[model] || MODELS.sonnet;
|
||||||
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
const accessToken = getAccessToken();
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return res.status(500).json({ error: 'No credentials found in ' + (process.env.CLAUDE_CONFIG_DIR || '~/.claude') });
|
||||||
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
res.flushHeaders(); // establish SSE connection immediately
|
res.flushHeaders();
|
||||||
|
|
||||||
const env = { ...process.env, CLAUDE_CONFIG_DIR: configDir };
|
|
||||||
|
|
||||||
// heartbeat to keep connection alive during long generation
|
|
||||||
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15000);
|
||||||
|
|
||||||
const proc = spawn(CLAUDE_BIN, ['-p', text, '--model', modelId, '--output-format', 'text'], {
|
try {
|
||||||
env,
|
const client = createClient(accessToken);
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
const stream = await client.messages.stream({
|
||||||
});
|
model: modelId,
|
||||||
|
max_tokens: 8192,
|
||||||
|
messages: [{ role: 'user', content: text }],
|
||||||
|
});
|
||||||
|
|
||||||
proc.stdout.on('data', (chunk) => {
|
for await (const event of stream) {
|
||||||
res.write(`data: ${JSON.stringify({ text: chunk.toString() })}\n\n`);
|
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||||
});
|
res.write(`data: ${JSON.stringify({ text: event.delta.text })}\n\n`);
|
||||||
|
}
|
||||||
let stderrBuf = '';
|
|
||||||
proc.stderr.on('data', (chunk) => {
|
|
||||||
const s = chunk.toString();
|
|
||||||
stderrBuf += s;
|
|
||||||
console.error('[claude stderr]', s.substring(0, 500));
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
|
||||||
console.error('[spawn error]', err.message);
|
|
||||||
res.write(`data: ${JSON.stringify({ error: `spawn: ${err.message}` })}\n\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on('close', (code, signal) => {
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
console.log(`[claude close] code=${code} signal=${signal} stderr=${stderrBuf.substring(0,300)}`);
|
|
||||||
if (code !== 0) {
|
|
||||||
const msg = stderrBuf.trim() || `code=${code} signal=${signal}`;
|
|
||||||
res.write(`data: ${JSON.stringify({ error: msg.substring(0, 500) })}\n\n`);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[api error]', err.message);
|
||||||
|
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
|
||||||
|
} finally {
|
||||||
|
clearInterval(heartbeat);
|
||||||
res.write('data: [DONE]\n\n');
|
res.write('data: [DONE]\n\n');
|
||||||
res.end();
|
res.end();
|
||||||
});
|
}
|
||||||
|
|
||||||
let finished = false;
|
|
||||||
proc.on('close', () => { finished = true; });
|
|
||||||
res.on('close', () => { clearInterval(heartbeat); if (!finished) try { proc.kill(); } catch (e) {} });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(8108, () => console.log('sechenov :8108'));
|
app.listen(8108, () => console.log('sechenov :8108'));
|
||||||
|
|||||||
Reference in New Issue
Block a user