claude-code-proxy/shared/frontend/formatters.ts
sid 8e550b9785 Local fork: hardening + ops improvements (timeout knob, demotion, /livez, drain)
This commit captures both the prior accumulated work-in-progress
(framework migration web/→svelte/, postgres storage, conversation
viewer, dashboard auth, OpenAPI spec, integration tests) AND today's
operational improvements layered on top. History wasn't checkpointed
incrementally; happy to split it via interactive rebase if a reviewer
wants smaller commits.

Today's changes (in addition to the older WIP):

1. Configurable upstream response-header timeout
   - ANTHROPIC_RESPONSE_HEADER_TIMEOUT env (default 300s)
   - Replaces hardcoded 300s in provider/anthropic.go that was firing
     on opus + 1M-context + extended thinking non-streaming requests
   - Files: internal/config/config.go, internal/provider/anthropic.go

2. Structured forward-error diagnostic logging
   - When a forward to Anthropic fails, log a single key=value line
     with request_id, model, stream, body_bytes, has_thinking,
     anthropic_beta, query, elapsed, ctx_err — alongside the existing
     human-readable error line for back-compat
   - Files: internal/handler/handlers.go (logForwardFailure)

3. Full SSE protocol passthrough + Flusher fix
   - handler/handlers.go: forward all SSE lines verbatim (event:, id:,
     retry:, : comments, blank-line terminators), not only data:.
     Previous code produced malformed SSE for strict parsers.
   - middleware/logging.go: explicit Flush() method on responseWriter.
     Embedding http.ResponseWriter (interface) does not auto-promote
     Flush(), so every w.(http.Flusher) check in the streaming
     handler was returning ok=false and SSE writes buffered in net/http
     until the body closed.

4. Non-streaming → streaming demotion (feature-flagged)
   - ANTHROPIC_DEMOTE_NONSTREAMING env (default false)
   - When enabled and the routed provider is anthropic, force stream=true
     upstream for clients that asked for stream=false. Receive SSE,
     accumulate via accumulateSSEToMessage (handles text, tool_use with
     partial_json reassembly, thinking, signature, citations_delta,
     usage merge), and synthesize a single non-streaming JSON response.
   - Eliminates the ResponseHeaderTimeout class of failure entirely.
   - Body rewrite uses json.Decoder + UseNumber() to preserve integer
     precision in unknown nested fields (tool inputs from prior turns).
   - Files: internal/config/config.go, internal/handler/handlers.go,
     cmd/proxy/main.go, cmd/proxy/main_test.go

5. Live operational state: /livez gauge + graceful drain
   - New internal/runtime package: atomic in-flight counter + draining flag
   - New middleware/inflight.go: increments runtime gauge, applied to
     /v1/* subrouter so Messages, ChatCompletions, and ProxyPassthrough
     are all counted
   - /v1/* moved to a gorilla/mux subrouter so the InFlight middleware
     applies surgically; /health, /livez, /openapi.* remain on parent
     router (unauthenticated, uncounted)
   - Health handler returns 503 draining when runtime.IsDraining() is
     true, so Traefik stops routing to a slot before drain begins
   - New /livez handler returns {status, in_flight, draining, timestamp}
   - SIGTERM handler in main.go: SetDraining(true), poll for in_flight==0
     with 32-min ceiling and 1s tick (logs every 10s), then srv.Shutdown
   - Auth bypass list extended with /livez
   - Files: internal/runtime/runtime.go (new),
     internal/middleware/inflight.go (new),
     internal/middleware/auth.go,
     internal/handler/handlers.go (Health, Livez, runtime import),
     cmd/proxy/main.go (subrouter, drain loop)

6. OpenAPI spec updates
   - Document Health 503 response and new DrainingResponse schema
   - Add /livez path with LivezResponse schema
   - Files: internal/handler/openapi.go

Verified: go build ./... clean, go test ./... all pass, go vet clean.
Three rounds of codex peer review across changes 1-5; all feedback
addressed (citations_delta, json.Number precision, drain-loop logging
via lastLog timestamp, PathPrefix tightened to "/v1/").
2026-05-02 15:15:58 -06:00

382 lines
13 KiB
TypeScript

import type { MessageContent, TextContentBlock } from './types';
/**
* Utility functions for formatting and displaying data
*/
/**
* Safely converts unknown values to a formatted string for display
*/
export function formatValue(value: unknown): string {
if (value === null) return 'null';
if (value === undefined) return 'undefined';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
/**
* Formats JSON with proper indentation and returns a formatted string
* Set maxLength to 0 or Infinity for no truncation
*/
export function formatJSON(obj: unknown, maxLength: number = 50000): string {
try {
const jsonString = JSON.stringify(obj, null, 2);
if (maxLength > 0 && maxLength < Infinity && jsonString.length > maxLength) {
return jsonString.substring(0, maxLength) + '\n... (truncated - ' + jsonString.length.toLocaleString() + ' total chars)';
}
return jsonString;
} catch {
return String(obj);
}
}
/**
* Formats JSON without truncation
*/
export function formatJSONFull(obj: unknown): string {
try {
return JSON.stringify(obj, null, 2);
} catch {
return String(obj);
}
}
/**
* Escapes HTML characters to prevent XSS.
*/
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* Formats large text with proper line breaks and structure.
* Supports markdown-like syntax: headings, bold, italic, inline code,
* fenced code blocks, bullet/numbered lists, horizontal rules, and URLs.
*/
export function formatLargeText(text: string): string {
if (!text) return '';
const codeBlocks: string[] = [];
const withPlaceholders = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, _lang, code) => {
const idx = codeBlocks.length;
codeBlocks.push(
`<pre class="bg-gray-900 text-gray-100 rounded-lg p-4 text-sm font-mono overflow-x-auto my-3 border border-gray-700"><code>${escapeHtml(code.replace(/\n$/, ''))}</code></pre>`
);
return `\x00CODEBLOCK_${idx}\x00`;
});
const escaped = escapeHtml(withPlaceholders);
const lines = escaped.split('\n');
const outputLines: string[] = [];
let inList = false;
let listType: 'ul' | 'ol' = 'ul';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const cbMatch = line.match(/\x00CODEBLOCK_(\d+)\x00/);
if (cbMatch) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
outputLines.push(codeBlocks[parseInt(cbMatch[1], 10)]);
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
outputLines.push('<hr class="my-4 border-gray-300">');
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
if (inList) {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
const level = headingMatch[1].length;
const headingText = headingMatch[2];
const sizes: Record<number, string> = {
1: 'text-xl font-bold text-gray-900 mt-5 mb-2',
2: 'text-lg font-bold text-gray-900 mt-4 mb-2',
3: 'text-base font-semibold text-gray-800 mt-3 mb-1',
4: 'text-sm font-semibold text-gray-800 mt-2 mb-1',
5: 'text-sm font-medium text-gray-700 mt-2 mb-1',
6: 'text-xs font-medium text-gray-700 mt-2 mb-1',
};
outputLines.push(`<div class="${sizes[level] || sizes[3]}">${applyInlineFormatting(headingText)}</div>`);
continue;
}
const bulletMatch = line.match(/^(\s*)[-*+]\s+(.+)$/);
if (bulletMatch) {
if (!inList || listType !== 'ul') {
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
outputLines.push('<ul class="list-disc list-inside space-y-1 my-2 text-gray-700">');
inList = true;
listType = 'ul';
}
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(bulletMatch[2])}</li>`);
continue;
}
const numMatch = line.match(/^(\s*)\d+[.)]\s+(.+)$/);
if (numMatch) {
if (!inList || listType !== 'ol') {
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
outputLines.push('<ol class="list-decimal list-inside space-y-1 my-2 text-gray-700">');
inList = true;
listType = 'ol';
}
outputLines.push(`<li class="leading-relaxed">${applyInlineFormatting(numMatch[2])}</li>`);
continue;
}
if (inList && line.trim() !== '') {
outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
inList = false;
}
if (line.trim() === '') {
outputLines.push('<div class="my-3"></div>');
continue;
}
outputLines.push(`<div class="leading-relaxed">${applyInlineFormatting(line)}</div>`);
}
if (inList) outputLines.push(listType === 'ul' ? '</ul>' : '</ol>');
return outputLines.join('\n');
}
function applyInlineFormatting(text: string): string {
return text
.replace(/\*\*([^*]+)\*\*/g, '<strong class="font-semibold text-gray-900">$1</strong>')
.replace(/\*([^*]+)\*/g, '<em class="italic text-gray-700">$1</em>')
.replace(/`([^`]+)`/g, '<code class="bg-gray-100 text-gray-800 px-1 py-0.5 rounded text-[0.85em] font-mono border border-gray-200">$1</code>')
.replace(/\bhttps?:\/\/[^\s<&]+/g, (url) => {
return `<a href="${url}" class="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors font-medium" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
export function isComplexObject(value: unknown): boolean {
return value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
Object.keys(value).length > 0;
}
export function truncateText(text: string, maxLength: number = 2000): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '... (' + (text.length - maxLength).toLocaleString() + ' more chars)';
}
export function formatTimestamp(timestamp: string | Date): string {
try {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: true
});
} catch {
return String(timestamp);
}
}
/**
* Format a duration in milliseconds to a human-readable short string (e.g. "3s", "5m", "2h")
*/
export function formatDuration(milliseconds: number): string {
if (milliseconds < 60000) return `${Math.round(milliseconds / 1000)}s`;
if (milliseconds < 3600000) return `${Math.round(milliseconds / 60000)}m`;
return `${Math.round(milliseconds / 3600000)}h`;
}
/**
* Format a timestamp string to a short time (e.g. "02:30 PM")
*/
export function formatTime(timestamp: string): string {
try {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
} catch {
return timestamp;
}
}
/**
* Format a timestamp to date string using toLocaleDateString()
*/
export function formatDate(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleDateString();
} catch {
return String(timestamp);
}
}
/**
* Format a timestamp to time string using toLocaleTimeString()
*/
export function formatTimeOfDay(timestamp: string | Date): string {
try {
return new Date(timestamp).toLocaleTimeString();
} catch {
return String(timestamp);
}
}
export function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
export interface XmlSegment {
type: 'text' | 'xml';
content: string;
tag?: string;
innerContent?: string;
}
export function parseXmlBlocks(text: string): XmlSegment[] {
if (!text) return [];
const result: XmlSegment[] = [];
const tagPattern = /<([a-z][a-z0-9_-]*(?:\s[^>]*)?)>/gi;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = tagPattern.exec(text)) !== null) {
const fullOpenTag = match[0];
const tagContent = match[1];
const tagName = tagContent.split(/\s/)[0];
const openPos = match.index;
const closeTag = `</${tagName}>`;
let depth = 1;
let searchPos = openPos + fullOpenTag.length;
while (depth > 0 && searchPos < text.length) {
const nextOpen = text.indexOf(`<${tagName}`, searchPos);
const nextClose = text.indexOf(closeTag, searchPos);
if (nextClose === -1) break;
if (nextOpen !== -1 && nextOpen < nextClose) {
const charAfterName = text[nextOpen + tagName.length + 1];
if (charAfterName === '>' || charAfterName === ' ' || charAfterName === '\n') {
depth++;
}
searchPos = nextOpen + tagName.length + 2;
} else {
depth--;
if (depth === 0) {
const innerStart = openPos + fullOpenTag.length;
const innerEnd = nextClose;
const blockEnd = nextClose + closeTag.length;
if (openPos > lastIndex) {
const preceding = text.substring(lastIndex, openPos).trim();
if (preceding) result.push({ type: 'text', content: preceding });
}
result.push({
type: 'xml',
content: text.substring(openPos, blockEnd),
tag: tagName,
innerContent: text.substring(innerStart, innerEnd)
});
lastIndex = blockEnd;
tagPattern.lastIndex = blockEnd;
} else {
searchPos = nextClose + closeTag.length;
}
}
}
if (depth > 0) {
tagPattern.lastIndex = openPos + fullOpenTag.length;
}
}
if (lastIndex < text.length) {
const remaining = text.substring(lastIndex).trim();
if (remaining) result.push({ type: 'text', content: remaining });
}
return result;
}
export function hasCustomXmlBlocks(text: string): boolean {
return /<[a-z][a-z0-9_-]*(?:\s[^>]*)?>[\s\S]*?<\/[a-z][a-z0-9_-]*>/i.test(text);
}
export function getXmlTagStyle(tag: string): { bg: string; border: string; headerBg: string; text: string; icon: string } {
if (/^(system-reminder|thinking_mode|reasoning_effort|antml_thinking_mode|fast_mode_info|claude[A-Z_-])/.test(tag)) {
return { bg: 'bg-amber-50', border: 'border-amber-200', headerBg: 'bg-amber-100', text: 'text-amber-800', icon: 'settings' };
}
if (/^(functions?|function_calls?|antml_function|antml_invoke|antml_parameter|tool)/.test(tag)) {
return { bg: 'bg-emerald-50', border: 'border-emerald-200', headerBg: 'bg-emerald-100', text: 'text-emerald-800', icon: 'wrench' };
}
if (/^(local-command|command-|user-prompt)/.test(tag)) {
return { bg: 'bg-blue-50', border: 'border-blue-200', headerBg: 'bg-blue-100', text: 'text-blue-800', icon: 'terminal' };
}
if (/^(types?|examples?|skills?|context|references?)/.test(tag)) {
return { bg: 'bg-purple-50', border: 'border-purple-200', headerBg: 'bg-purple-100', text: 'text-purple-800', icon: 'database' };
}
return { bg: 'bg-gray-50', border: 'border-gray-200', headerBg: 'bg-gray-100', text: 'text-gray-700', icon: 'code' };
}
function isTextContentBlock(value: unknown): value is TextContentBlock {
return !!value && typeof value === 'object' && 'type' in value && value.type === 'text' && 'text' in value && typeof value.text === 'string';
}
export function createContentPreview(content: MessageContent | unknown, maxLength: number = 100): string {
if (typeof content === 'string') {
return content.length > maxLength ? content.substring(0, maxLength) + '...' : content;
}
if (Array.isArray(content)) {
const textContent = content.find((item) => isTextContentBlock(item))?.text || '';
if (textContent) {
return textContent.length > maxLength ? textContent.substring(0, maxLength) + '...' : textContent;
}
return `${content.length} content blocks`;
}
if (content && typeof content === 'object') {
if ('text' in content && typeof content.text === 'string') {
return content.text.length > maxLength ? content.text.substring(0, maxLength) + '...' : content.text;
}
return 'Complex content';
}
return 'No content';
}