LLM: streaming + rich context + 32×32 frames + proxy log
- Streaming: requestSongTweak now uses streamObject when a progress
callback is provided (via handleLlmTweak), emitting partial {rationale,
edits} and updating toast/console as text arrives; falls back to
generateObject if gateway doesn't support streaming. Models always
called via provider.chat(modelId) / /v1/chat/completions, the correct
path for meta/muse-spark-1.3-contributor via gateway.
- Rich context: spec.js gains FULL control wording (any amount/arrangement/
bpm allowed), and detailed payload — trackSummary with bandBalance,
tempo, normalization (per-feature lo/hi), sectionsDetailed (energy/
flux/centroid/flatness/width/slope/endEnergy/duration), stats
(kinds, avg/max energy, bars). LLM told You have FULL control.
- Low-res vision: new src/llm/thumbs.js — captureThumbnails(show, {8,32})
samples section midpoints, warms up state, renders via Show, box-averages
down to 32×32 JPEG data URLs, restores timeline. main.js now captures 8
frames with two animation frames of pause, then passes them as {type:
image, image: dataUrl} parts in the user message (llmTweak.js
attachFramesToMessages).
- Proxy logging (no bearer): vite.config.js llmGatewayProxy now logs
sanitized requests/responses to logs/llm-proxy.log (JSON lines, 1MB
rotation, redacts apiKey/authorization from path/body/headers) so the
coding agent can inspect prompts without re-calling. .gitignore adds
logs/.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+127
-14
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { writeFileSync, existsSync, mkdirSync, appendFileSync, statSync } from 'fs';
|
||||
|
||||
/**
|
||||
* Let the measuring pages write their results back into the source tree.
|
||||
@@ -63,8 +63,77 @@ function measurementWriter() {
|
||||
* Production builds are static; there this route does not exist and the
|
||||
* client falls through to a direct fetch. Deployments that need CORS-free
|
||||
* production should add an equivalent `/api/llm` serverless proxy.
|
||||
*
|
||||
* Also logs sanitized traffic (no Authorization) to `logs/llm-proxy.log` so
|
||||
* the coding agent can inspect prompts/responses without re-calling the LLM.
|
||||
*/
|
||||
function llmGatewayProxy() {
|
||||
const LOG_DIR = resolve(process.cwd(), 'logs');
|
||||
const LOG_FILE = resolve(LOG_DIR, 'llm-proxy.log');
|
||||
|
||||
function safeAppend(entry) {
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
||||
// Rotate: truncate if >1MB to avoid unbounded growth
|
||||
try {
|
||||
if (existsSync(LOG_FILE) && statSync(LOG_FILE).size > 1024 * 1024) {
|
||||
writeFileSync(LOG_FILE, '');
|
||||
}
|
||||
} catch { /* ignore stat/truncate errors */ }
|
||||
appendFileSync(LOG_FILE, JSON.stringify(entry) + '\n');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function sanitizePathForLog(path) {
|
||||
try {
|
||||
const u = new URL(path, 'http://localhost');
|
||||
for (const key of [...u.searchParams.keys()]) {
|
||||
const lower = key.toLowerCase();
|
||||
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
|
||||
u.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
// Fallback: strip sensitive query params via regex
|
||||
return path.replace(/([?&])(apiKey|api_key|authorization)=[^&]*/gi, '').replace(/[?&]$/, '').replace(/\?&/, '?') || '/';
|
||||
}
|
||||
}
|
||||
|
||||
function redactJsonForLog(obj) {
|
||||
if (Array.isArray(obj)) return obj.map(redactJsonForLog);
|
||||
if (obj && typeof obj === 'object') {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const lower = k.toLowerCase();
|
||||
if (lower === 'apikey' || lower === 'api_key' || lower === 'authorization') {
|
||||
out[k] = '[REDACTED]';
|
||||
} else {
|
||||
out[k] = redactJsonForLog(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function sanitizeBodyForLog(ct, raw) {
|
||||
// Don't log if content-type is missing or body is binary
|
||||
if (!ct || !ct.includes('json')) {
|
||||
if (raw && raw.length > 4096) return `[${ct || 'unknown'} body ${raw.length} bytes, not logged]`;
|
||||
try { return raw ? raw.toString('utf8').slice(0, 4096) : ''; } catch { return '[unprintable]'; }
|
||||
}
|
||||
try {
|
||||
const text = raw ? raw.toString('utf8') : '';
|
||||
const parsed = JSON.parse(text);
|
||||
// Redact sensitive keys if they somehow appear in JSON body
|
||||
const redacted = redactJsonForLog(parsed);
|
||||
return JSON.stringify(redacted).slice(0, 16384);
|
||||
} catch {
|
||||
return raw ? raw.toString('utf8').slice(0, 4096) : '';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'flow-state-llm-gateway-proxy',
|
||||
apply: 'serve',
|
||||
@@ -85,16 +154,7 @@ function llmGatewayProxy() {
|
||||
}
|
||||
|
||||
const gatewayBase = (req.headers['x-llm-gateway'] && String(req.headers['x-llm-gateway'])) || 'https://ai-gateway.vercel.sh';
|
||||
// Strip the proxy prefix; the client sends `/__llm/v1/...`,
|
||||
// so after stripping we have `/v1/...` which joins correctly.
|
||||
const pathAfter = req.url.replace(/^\/__llm/, '') || '/';
|
||||
// Preserve query string
|
||||
const targetUrl = new URL(pathAfter, gatewayBase.endsWith('/') ? gatewayBase : gatewayBase + '/');
|
||||
// But `new URL('/v1/x', 'https://host/v1')` drops the base path —
|
||||
// `/v1/x` is absolute. Since `pathAfter` already starts with `/v1`,
|
||||
// joining against the origin is correct; gatewayBase's path is just
|
||||
// the fallback origin. So rebuild against origin only.
|
||||
// Simpler: construct from origin + pathAfter
|
||||
let origin;
|
||||
try {
|
||||
origin = new URL(gatewayBase).origin;
|
||||
@@ -106,14 +166,28 @@ function llmGatewayProxy() {
|
||||
let bodyChunks = [];
|
||||
req.on('data', (c) => bodyChunks.push(c));
|
||||
req.on('end', async () => {
|
||||
const started = Date.now();
|
||||
const reqCt = req.headers['content-type'] || '';
|
||||
const rawReq = bodyChunks.length ? Buffer.concat(bodyChunks) : null;
|
||||
const reqLog = sanitizeBodyForLog(reqCt, rawReq);
|
||||
// Always log the request (sanitized — no Authorization/apiKey)
|
||||
safeAppend({
|
||||
ts: new Date().toISOString(),
|
||||
dir: '→',
|
||||
method: req.method,
|
||||
path: sanitizePathForLog(pathAfter),
|
||||
gateway: origin,
|
||||
contentType: reqCt,
|
||||
// Authorization header and apiKey query param never logged
|
||||
body: reqLog,
|
||||
});
|
||||
|
||||
try {
|
||||
const body = bodyChunks.length ? Buffer.concat(bodyChunks) : undefined;
|
||||
const body = rawReq && rawReq.length ? rawReq : undefined;
|
||||
const headers = {};
|
||||
// Forward relevant headers
|
||||
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'];
|
||||
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'];
|
||||
else if (req.headers['x-api-key']) headers['authorization'] = `Bearer ${req.headers['x-api-key']}`;
|
||||
// Also forward any x- header the gateway might care about
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
if (k.startsWith('x-') && k !== 'x-llm-gateway') headers[k] = v;
|
||||
}
|
||||
@@ -124,7 +198,6 @@ function llmGatewayProxy() {
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
|
||||
});
|
||||
|
||||
// Mirror status and content headers
|
||||
const contentType = upstream.headers.get('content-type');
|
||||
res.writeHead(upstream.status, {
|
||||
...(contentType ? { 'content-type': contentType } : {}),
|
||||
@@ -133,7 +206,47 @@ function llmGatewayProxy() {
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
res.end(buf);
|
||||
|
||||
// Log response (sanitized — still no token in body)
|
||||
const elapsed = Date.now() - started;
|
||||
let respLog = '';
|
||||
try {
|
||||
const ct = contentType || '';
|
||||
if (ct.includes('json') || ct.includes('event-stream') || buf.length < 32768) {
|
||||
const text = buf.toString('utf8');
|
||||
// For SSE streams, keep first 16k; for JSON, keep parsed form
|
||||
if (ct.includes('json')) {
|
||||
try { respLog = JSON.stringify(JSON.parse(text)).slice(0, 16384); } catch { respLog = text.slice(0, 8192); }
|
||||
} else {
|
||||
respLog = text.slice(0, 8192);
|
||||
}
|
||||
} else {
|
||||
respLog = `[${contentType} ${buf.length} bytes]`;
|
||||
}
|
||||
} catch { respLog = `[${buf.length} bytes]`; }
|
||||
|
||||
safeAppend({
|
||||
ts: new Date().toISOString(),
|
||||
dir: '←',
|
||||
status: upstream.status,
|
||||
elapsedMs: elapsed,
|
||||
contentType: contentType || '',
|
||||
body: respLog,
|
||||
});
|
||||
|
||||
// Also emit to vite's console for live tailing
|
||||
const tag = upstream.status >= 400 ? '← ERR' : '← OK';
|
||||
console.info(`[llm-proxy] ${tag} ${req.method} ${pathAfter} → ${upstream.status} (${elapsed}ms, ${buf.length} bytes)`);
|
||||
} catch (err) {
|
||||
const elapsed = Date.now() - started;
|
||||
safeAppend({
|
||||
ts: new Date().toISOString(),
|
||||
dir: '←',
|
||||
status: 502,
|
||||
elapsedMs: elapsed,
|
||||
error: String(err && err.message ? err.message : err).slice(0, 2000),
|
||||
});
|
||||
console.error(`[llm-proxy] ← 502 ${req.method} ${pathAfter}`, err);
|
||||
res.writeHead(502, { 'access-control-allow-origin': '*' });
|
||||
res.end(String(err && err.message ? err.message : err));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user