feat(llm-client): add configurable logging system
Add a flexible logging system to the llm-client package that can be
enabled/disabled without rebuilding:
- Add Logger class with configurable enable/disable and custom logger support
- Add LogLevel, LoggerFn, LoggingConfig types
- Add `debug` option to LLMStreamRequest for per-request logging override
- Add setLogging() method for runtime enable/disable
- Replace hardcoded console.log in openai-responses provider with logger
- Add ?debug=true query param to flowchart generate endpoint
Usage:
- Per-request: llm.stream({ ..., debug: true })
- Global: llm.setLogging({ enabled: true })
- Custom logger: new LLMClient({ logging: { enabled: true, logger: fn } })
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
454f93faae
commit
cdd53f6602
|
|
@ -12,149 +12,171 @@
|
|||
* - output_delta: Partial structured output being generated
|
||||
* - complete: Generated flowchart result
|
||||
* - error: Error occurred
|
||||
*
|
||||
* Query params:
|
||||
* - debug=true: Enable verbose LLM debug logging
|
||||
*/
|
||||
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type { z } from 'zod'
|
||||
import { db, schema } from '@/db'
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { db, schema } from "@/db";
|
||||
import {
|
||||
broadcast,
|
||||
completeGeneration,
|
||||
failGeneration,
|
||||
startGeneration,
|
||||
} from '@/lib/flowchart-workshop/generation-registry'
|
||||
} from "@/lib/flowchart-workshop/generation-registry";
|
||||
import {
|
||||
GeneratedFlowchartSchema,
|
||||
getGenerationSystemPrompt,
|
||||
getSubtractionExample,
|
||||
transformLLMDefinitionToInternal,
|
||||
} from '@/lib/flowchart-workshop/llm-schemas'
|
||||
import { validateTestCasesWithCoverage } from '@/lib/flowchart-workshop/test-case-validator'
|
||||
import { llm, type StreamEvent } from '@/lib/llm'
|
||||
import { getDbUserId } from '@/lib/viewer'
|
||||
} from "@/lib/flowchart-workshop/llm-schemas";
|
||||
import { validateTestCasesWithCoverage } from "@/lib/flowchart-workshop/test-case-validator";
|
||||
import { llm, type StreamEvent } from "@/lib/llm";
|
||||
import { getDbUserId } from "@/lib/viewer";
|
||||
|
||||
type GeneratedFlowchart = z.infer<typeof GeneratedFlowchartSchema>
|
||||
type GeneratedFlowchart = z.infer<typeof GeneratedFlowchartSchema>;
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ id: string }>
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteParams) {
|
||||
const { id } = await params
|
||||
const { id } = await params;
|
||||
|
||||
// Check for debug mode via query param
|
||||
const url = new URL(request.url);
|
||||
const debug = url.searchParams.get("debug") === "true";
|
||||
|
||||
if (debug) {
|
||||
console.log("[generate] Debug mode enabled");
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Session ID required' }), {
|
||||
return new Response(JSON.stringify({ error: "Session ID required" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Authorization check
|
||||
let userId: string
|
||||
let userId: string;
|
||||
try {
|
||||
userId = await getDbUserId()
|
||||
userId = await getDbUserId();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Not authenticated' }), {
|
||||
return new Response(JSON.stringify({ error: "Not authenticated" }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Get the session
|
||||
const session = await db.query.workshopSessions.findFirst({
|
||||
where: and(eq(schema.workshopSessions.id, id), eq(schema.workshopSessions.userId, userId)),
|
||||
})
|
||||
where: and(
|
||||
eq(schema.workshopSessions.id, id),
|
||||
eq(schema.workshopSessions.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
return new Response(JSON.stringify({ error: "Session not found" }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
let topicDescription: string
|
||||
let topicDescription: string;
|
||||
try {
|
||||
const body = await request.json()
|
||||
topicDescription = body.topicDescription || session.topicDescription
|
||||
const body = await request.json();
|
||||
topicDescription = body.topicDescription || session.topicDescription;
|
||||
if (!topicDescription) {
|
||||
return new Response(JSON.stringify({ error: 'Topic description required' }), {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Topic description required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
return new Response(JSON.stringify({ error: "Invalid request body" }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Update session state
|
||||
await db
|
||||
.update(schema.workshopSessions)
|
||||
.set({
|
||||
state: 'generating',
|
||||
state: "generating",
|
||||
topicDescription,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.workshopSessions.id, id))
|
||||
.where(eq(schema.workshopSessions.id, id));
|
||||
|
||||
// Create SSE stream with resilient event sending
|
||||
// Key design: LLM processing and DB saves happen regardless of client connection
|
||||
// Client streaming is best-effort - if they disconnect, we still complete the work
|
||||
const encoder = new TextEncoder()
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let clientConnected = true
|
||||
let clientConnected = true;
|
||||
|
||||
// Resilient event sender - catches errors if client disconnected
|
||||
// This ensures LLM processing continues even if client closes browser
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
if (!clientConnected) return
|
||||
if (!clientConnected) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\n`))
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
|
||||
controller.enqueue(encoder.encode(`event: ${event}\n`));
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
// Client disconnected - mark as disconnected but continue processing
|
||||
clientConnected = false
|
||||
console.log(
|
||||
`[generate] Client disconnected during ${event} event, continuing LLM processing...`
|
||||
)
|
||||
clientConnected = false;
|
||||
if (debug) {
|
||||
console.log(`[generate] Client disconnected during ${event} event`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const closeStream = () => {
|
||||
if (!clientConnected) return
|
||||
if (!clientConnected) return;
|
||||
try {
|
||||
controller.close()
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Track LLM errors separately from client errors
|
||||
let llmError: { message: string; code?: string } | null = null
|
||||
let finalResult: GeneratedFlowchart | null = null
|
||||
let llmError: { message: string; code?: string } | null = null;
|
||||
let finalResult: GeneratedFlowchart | null = null;
|
||||
let usage: {
|
||||
promptTokens: number
|
||||
completionTokens: number
|
||||
reasoningTokens?: number
|
||||
} | null = null
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
reasoningTokens?: number;
|
||||
} | null = null;
|
||||
|
||||
// Start tracking this generation in the registry (for reconnection support)
|
||||
const generationState = startGeneration(id)
|
||||
const generationState = startGeneration(id);
|
||||
|
||||
// Throttled save of reasoning text to database (for durability)
|
||||
let lastReasoningSaveTime = 0
|
||||
const REASONING_SAVE_INTERVAL_MS = 2000 // Save reasoning every 2 seconds
|
||||
let lastReasoningSaveTime = 0;
|
||||
const REASONING_SAVE_INTERVAL_MS = 2000; // Save reasoning every 2 seconds
|
||||
|
||||
const saveReasoningToDb = async (force = false) => {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastReasoningSaveTime < REASONING_SAVE_INTERVAL_MS) {
|
||||
return // Throttle saves
|
||||
const now = Date.now();
|
||||
if (
|
||||
!force &&
|
||||
now - lastReasoningSaveTime < REASONING_SAVE_INTERVAL_MS
|
||||
) {
|
||||
return; // Throttle saves
|
||||
}
|
||||
lastReasoningSaveTime = now
|
||||
lastReasoningSaveTime = now;
|
||||
try {
|
||||
await db
|
||||
.update(schema.workshopSessions)
|
||||
|
|
@ -162,23 +184,29 @@ export async function POST(request: Request, { params }: RouteParams) {
|
|||
currentReasoningText: generationState.accumulatedReasoning,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.workshopSessions.id, id))
|
||||
.where(eq(schema.workshopSessions.id, id));
|
||||
} catch (err) {
|
||||
// Log but don't fail the stream on DB errors
|
||||
console.error('[generate] Failed to save reasoning to DB:', err)
|
||||
}
|
||||
console.error("[generate] Failed to save reasoning to DB:", err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
sendEvent('progress', { stage: 'preparing', message: 'Preparing flowchart generation...' })
|
||||
sendEvent("progress", {
|
||||
stage: "preparing",
|
||||
message: "Preparing flowchart generation...",
|
||||
});
|
||||
broadcast(id, {
|
||||
type: 'progress',
|
||||
data: { stage: 'preparing', message: 'Preparing flowchart generation...' },
|
||||
})
|
||||
type: "progress",
|
||||
data: {
|
||||
stage: "preparing",
|
||||
message: "Preparing flowchart generation...",
|
||||
},
|
||||
});
|
||||
|
||||
// Build the prompt
|
||||
const systemPrompt = getGenerationSystemPrompt()
|
||||
const examplePrompt = getSubtractionExample()
|
||||
const systemPrompt = getGenerationSystemPrompt();
|
||||
const examplePrompt = getSubtractionExample();
|
||||
|
||||
const userPrompt = `Create an interactive math flowchart for teaching the following topic:
|
||||
|
||||
|
|
@ -191,23 +219,25 @@ Create a complete, working flowchart with:
|
|||
|
||||
The flowchart should be engaging for students, with clear phases, checkpoints for important calculations, and encouraging visual elements.
|
||||
|
||||
Return the result as a JSON object matching the GeneratedFlowchartSchema.`
|
||||
Return the result as a JSON object matching the GeneratedFlowchartSchema.`;
|
||||
|
||||
// Combine system prompt with user prompt
|
||||
const fullPrompt = `${systemPrompt}\n\n${examplePrompt}\n\n---\n\n${userPrompt}`
|
||||
const fullPrompt = `${systemPrompt}\n\n${examplePrompt}\n\n---\n\n${userPrompt}`;
|
||||
|
||||
// Stream the LLM response with reasoning
|
||||
// Use debug option to enable detailed logging in the LLM client
|
||||
const llmStream = llm.stream({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.2',
|
||||
provider: "openai",
|
||||
model: "gpt-5.2",
|
||||
prompt: fullPrompt,
|
||||
schema: GeneratedFlowchartSchema,
|
||||
reasoning: {
|
||||
effort: 'medium',
|
||||
summary: 'auto',
|
||||
effort: "medium",
|
||||
summary: "auto",
|
||||
},
|
||||
timeoutMs: 300_000, // 5 minutes for complex flowchart generation
|
||||
})
|
||||
debug, // Enable LLM client debug logging if debug=true
|
||||
});
|
||||
|
||||
// Forward all stream events to the client AND broadcast to registry subscribers
|
||||
// The for-await loop processes all LLM events regardless of client state
|
||||
|
|
@ -217,64 +247,65 @@ Return the result as a JSON object matching the GeneratedFlowchartSchema.`
|
|||
unknown
|
||||
>) {
|
||||
switch (event.type) {
|
||||
case 'started': {
|
||||
case "started": {
|
||||
const startedData = {
|
||||
responseId: event.responseId,
|
||||
message: 'Generating flowchart...',
|
||||
}
|
||||
sendEvent('started', startedData)
|
||||
message: "Generating flowchart...",
|
||||
};
|
||||
sendEvent("started", startedData);
|
||||
// Note: 'started' is not broadcast - subscribers already know generation started
|
||||
break
|
||||
break;
|
||||
}
|
||||
|
||||
case 'reasoning': {
|
||||
case "reasoning": {
|
||||
const reasoningData = {
|
||||
text: event.text,
|
||||
summaryIndex: event.summaryIndex,
|
||||
isDelta: event.isDelta,
|
||||
}
|
||||
};
|
||||
// Broadcast to registry (this accumulates reasoning internally)
|
||||
broadcast(id, { type: 'reasoning', data: reasoningData })
|
||||
broadcast(id, { type: "reasoning", data: reasoningData });
|
||||
// Throttled save to database for durability (don't await - fire and forget)
|
||||
saveReasoningToDb()
|
||||
saveReasoningToDb();
|
||||
// Send to this client's SSE stream
|
||||
sendEvent('reasoning', reasoningData)
|
||||
break
|
||||
sendEvent("reasoning", reasoningData);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'output_delta': {
|
||||
case "output_delta": {
|
||||
const outputData = {
|
||||
text: event.text,
|
||||
outputIndex: event.outputIndex,
|
||||
}
|
||||
broadcast(id, { type: 'output_delta', data: outputData })
|
||||
sendEvent('output_delta', outputData)
|
||||
break
|
||||
};
|
||||
broadcast(id, { type: "output_delta", data: outputData });
|
||||
sendEvent("output_delta", outputData);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'error':
|
||||
case "error":
|
||||
console.error("[generate] LLM error:", event.message, event.code);
|
||||
// This is an LLM error, not a client error
|
||||
llmError = { message: event.message, code: event.code }
|
||||
sendEvent('error', {
|
||||
llmError = { message: event.message, code: event.code };
|
||||
sendEvent("error", {
|
||||
message: event.message,
|
||||
code: event.code,
|
||||
})
|
||||
});
|
||||
// Don't broadcast error here - we'll call failGeneration() below
|
||||
break
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
finalResult = event.data
|
||||
usage = event.usage
|
||||
break
|
||||
case "complete":
|
||||
finalResult = event.data;
|
||||
usage = event.usage;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// This catch is for unexpected errors (network issues, etc.)
|
||||
// NOT for client disconnect (those are caught in sendEvent)
|
||||
console.error('Flowchart generation error:', error)
|
||||
console.error("[generate] Stream processing error:", error);
|
||||
llmError = {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
}
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
// ALWAYS update database based on LLM result, regardless of client connection
|
||||
|
|
@ -284,43 +315,51 @@ Return the result as a JSON object matching the GeneratedFlowchartSchema.`
|
|||
await db
|
||||
.update(schema.workshopSessions)
|
||||
.set({
|
||||
state: 'initial',
|
||||
draftNotes: JSON.stringify([`Generation failed: ${llmError.message}`]),
|
||||
state: "initial",
|
||||
draftNotes: JSON.stringify([
|
||||
`Generation failed: ${llmError.message}`,
|
||||
]),
|
||||
currentReasoningText: null, // Clear reasoning on completion/error
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.workshopSessions.id, id))
|
||||
.where(eq(schema.workshopSessions.id, id));
|
||||
|
||||
// Notify registry subscribers of failure
|
||||
failGeneration(id, llmError.message)
|
||||
failGeneration(id, llmError.message);
|
||||
|
||||
sendEvent('error', { message: llmError.message })
|
||||
sendEvent("error", { message: llmError.message });
|
||||
} else if (finalResult) {
|
||||
// LLM succeeded - save the result
|
||||
sendEvent('progress', { stage: 'validating', message: 'Validating result...' })
|
||||
sendEvent("progress", {
|
||||
stage: "validating",
|
||||
message: "Validating result...",
|
||||
});
|
||||
|
||||
// Transform LLM output (array-based) to internal format (record-based)
|
||||
const internalDefinition = transformLLMDefinitionToInternal(finalResult.definition)
|
||||
const internalDefinition = transformLLMDefinitionToInternal(
|
||||
finalResult.definition,
|
||||
);
|
||||
|
||||
// Run test case validation with coverage analysis
|
||||
const validationReport = await validateTestCasesWithCoverage(
|
||||
internalDefinition,
|
||||
finalResult.mermaidContent
|
||||
)
|
||||
finalResult.mermaidContent,
|
||||
);
|
||||
|
||||
// Send validation event (regardless of pass/fail - UI will display results)
|
||||
sendEvent('validation', {
|
||||
sendEvent("validation", {
|
||||
passed: validationReport.passed,
|
||||
failedCount: validationReport.summary.failed + validationReport.summary.errors,
|
||||
failedCount:
|
||||
validationReport.summary.failed + validationReport.summary.errors,
|
||||
totalCount: validationReport.summary.total,
|
||||
coveragePercent: validationReport.coverage.coveragePercent,
|
||||
})
|
||||
});
|
||||
|
||||
// Update session with the generated content, clear reasoning
|
||||
await db
|
||||
.update(schema.workshopSessions)
|
||||
.set({
|
||||
state: 'refining',
|
||||
state: "refining",
|
||||
draftDefinitionJson: JSON.stringify(internalDefinition),
|
||||
draftMermaidContent: finalResult.mermaidContent,
|
||||
draftTitle: finalResult.title,
|
||||
|
|
@ -331,11 +370,11 @@ Return the result as a JSON object matching the GeneratedFlowchartSchema.`
|
|||
currentReasoningText: null, // Clear reasoning on completion
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.workshopSessions.id, id))
|
||||
.where(eq(schema.workshopSessions.id, id));
|
||||
|
||||
console.log(
|
||||
`[generate] Flowchart saved to DB for session ${id}${clientConnected ? '' : ' (client had disconnected)'}`
|
||||
)
|
||||
if (debug) {
|
||||
console.log(`[generate] Flowchart saved to DB for session ${id}`);
|
||||
}
|
||||
|
||||
// Build complete result for broadcasting
|
||||
const completeResult = {
|
||||
|
|
@ -347,23 +386,23 @@ Return the result as a JSON object matching the GeneratedFlowchartSchema.`
|
|||
difficulty: finalResult.difficulty,
|
||||
notes: finalResult.notes,
|
||||
usage,
|
||||
}
|
||||
};
|
||||
|
||||
// Notify registry subscribers of completion (this also broadcasts 'complete' event)
|
||||
completeGeneration(id, completeResult)
|
||||
completeGeneration(id, completeResult);
|
||||
|
||||
sendEvent('complete', completeResult)
|
||||
sendEvent("complete", completeResult);
|
||||
}
|
||||
|
||||
closeStream()
|
||||
closeStream();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* @see packages/llm-client/README.md for full documentation
|
||||
*/
|
||||
|
||||
import { LLMClient } from '@soroban/llm-client'
|
||||
import { LLMClient } from "@soroban/llm-client";
|
||||
|
||||
// Create singleton instance
|
||||
// Configuration is automatically loaded from environment variables:
|
||||
|
|
@ -30,7 +30,7 @@ import { LLMClient } from '@soroban/llm-client'
|
|||
// - LLM_OPENAI_BASE_URL: OpenAI base URL (optional)
|
||||
// - LLM_ANTHROPIC_API_KEY: Anthropic API key
|
||||
// - LLM_ANTHROPIC_BASE_URL: Anthropic base URL (optional)
|
||||
export const llm = new LLMClient()
|
||||
export const llm = new LLMClient();
|
||||
|
||||
// Re-export types and utilities for convenience
|
||||
export type {
|
||||
|
|
@ -53,7 +53,11 @@ export type {
|
|||
StreamEventOutputDelta,
|
||||
StreamEventError,
|
||||
StreamEventComplete,
|
||||
} from '@soroban/llm-client'
|
||||
// Logging types
|
||||
LogLevel,
|
||||
LoggerFn,
|
||||
LoggingConfig,
|
||||
} from "@soroban/llm-client";
|
||||
|
||||
export {
|
||||
LLMValidationError,
|
||||
|
|
@ -61,4 +65,7 @@ export {
|
|||
LLMTimeoutError,
|
||||
LLMNetworkError,
|
||||
ProviderNotConfiguredError,
|
||||
} from '@soroban/llm-client'
|
||||
// Logging
|
||||
Logger,
|
||||
defaultLogger,
|
||||
} from "@soroban/llm-client";
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
LLMRequest,
|
||||
LLMResponse,
|
||||
LLMStreamRequest,
|
||||
LoggingConfig,
|
||||
ProviderRequest,
|
||||
StreamEvent,
|
||||
ValidationFeedback,
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
isProviderConfigured,
|
||||
} from "./config";
|
||||
import { executeWithRetry } from "./retry";
|
||||
import { Logger } from "./logger";
|
||||
import { OpenAIProvider } from "./providers/openai";
|
||||
import { AnthropicProvider } from "./providers/anthropic";
|
||||
import { OpenAIResponsesProvider } from "./providers/openai-responses";
|
||||
|
|
@ -80,6 +82,7 @@ const providerFactories: Record<string, ProviderFactory> = {
|
|||
export class LLMClient {
|
||||
private readonly config: LLMClientConfig;
|
||||
private readonly providers: Map<string, LLMProvider> = new Map();
|
||||
private logger: Logger;
|
||||
|
||||
/**
|
||||
* Create a new LLM client
|
||||
|
|
@ -99,7 +102,18 @@ export class LLMClient {
|
|||
...envConfig.providers,
|
||||
...configOverrides?.providers,
|
||||
},
|
||||
logging: configOverrides?.logging ?? envConfig.logging,
|
||||
};
|
||||
this.logger = new Logger(this.config.logging);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable logging at runtime
|
||||
*/
|
||||
setLogging(config: Partial<LoggingConfig>): void {
|
||||
const newConfig = { ...this.config.logging, ...config } as LoggingConfig;
|
||||
this.config.logging = newConfig;
|
||||
this.logger = new Logger(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -158,9 +172,19 @@ export class LLMClient {
|
|||
async *stream<T extends z.ZodType>(
|
||||
request: LLMStreamRequest<T>,
|
||||
): AsyncGenerator<StreamEvent<z.infer<T>>, void, unknown> {
|
||||
// Get logger for this request (may be overridden by request.debug)
|
||||
const requestLogger = this.logger.withEnabled(request.debug);
|
||||
|
||||
const providerName = request.provider ?? this.config.defaultProvider;
|
||||
const model = request.model ?? this.getDefaultModel(providerName);
|
||||
|
||||
requestLogger.debug("Starting stream request", {
|
||||
provider: providerName,
|
||||
model,
|
||||
promptLength: request.prompt.length,
|
||||
hasImages: !!request.images?.length,
|
||||
});
|
||||
|
||||
// Currently only OpenAI Responses API supports streaming with reasoning
|
||||
if (providerName.toLowerCase() !== "openai") {
|
||||
throw new Error(
|
||||
|
|
@ -176,7 +200,10 @@ export class LLMClient {
|
|||
}
|
||||
|
||||
// Create responses provider (separate from chat completions provider)
|
||||
const responsesProvider = new OpenAIResponsesProvider(providerConfig);
|
||||
const responsesProvider = new OpenAIResponsesProvider(
|
||||
providerConfig,
|
||||
requestLogger,
|
||||
);
|
||||
|
||||
// Convert Zod schema to JSON Schema
|
||||
const jsonSchema = z.toJSONSchema(request.schema, {
|
||||
|
|
@ -192,6 +219,12 @@ export class LLMClient {
|
|||
summary: "auto" as const,
|
||||
};
|
||||
|
||||
requestLogger.debug("Streaming with config", {
|
||||
reasoning,
|
||||
timeoutMs: request.timeoutMs,
|
||||
schemaKeys: Object.keys(jsonSchema),
|
||||
});
|
||||
|
||||
// Stream the response
|
||||
yield* responsesProvider.stream<z.infer<T>>(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -55,8 +55,16 @@ export type {
|
|||
// Embedding types
|
||||
EmbeddingRequest,
|
||||
EmbeddingResponse,
|
||||
// Logging types
|
||||
LogLevel,
|
||||
LoggerFn,
|
||||
LoggingConfig,
|
||||
} from "./types";
|
||||
|
||||
// Logging utilities
|
||||
export { defaultLogger } from "./types";
|
||||
export { Logger } from "./logger";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
LLMValidationError,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import type { LogLevel, LoggerFn, LoggingConfig } from "./types";
|
||||
import { defaultLogger } from "./types";
|
||||
|
||||
/**
|
||||
* Log level priority for filtering
|
||||
*/
|
||||
const LOG_LEVEL_PRIORITY: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal logger that respects configuration
|
||||
*/
|
||||
export class Logger {
|
||||
private readonly config: LoggingConfig;
|
||||
private readonly logger: LoggerFn;
|
||||
private readonly minLevelPriority: number;
|
||||
|
||||
constructor(config?: LoggingConfig) {
|
||||
this.config = config ?? { enabled: false };
|
||||
this.logger = this.config.logger ?? defaultLogger;
|
||||
this.minLevelPriority = LOG_LEVEL_PRIORITY[this.config.minLevel ?? "debug"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if logging is enabled
|
||||
*/
|
||||
get enabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a child logger with override for enabled state
|
||||
* Used for per-request debug overrides
|
||||
*/
|
||||
withEnabled(enabled: boolean | undefined): Logger {
|
||||
if (enabled === undefined) {
|
||||
return this;
|
||||
}
|
||||
return new Logger({
|
||||
...this.config,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message if logging is enabled and level is at or above minimum
|
||||
*/
|
||||
log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
||||
if (!this.config.enabled) {
|
||||
return;
|
||||
}
|
||||
if (LOG_LEVEL_PRIORITY[level] < this.minLevelPriority) {
|
||||
return;
|
||||
}
|
||||
this.logger(level, message, data);
|
||||
}
|
||||
|
||||
/** Log at debug level */
|
||||
debug(message: string, data?: Record<string, unknown>): void {
|
||||
this.log("debug", message, data);
|
||||
}
|
||||
|
||||
/** Log at info level */
|
||||
info(message: string, data?: Record<string, unknown>): void {
|
||||
this.log("info", message, data);
|
||||
}
|
||||
|
||||
/** Log at warn level */
|
||||
warn(message: string, data?: Record<string, unknown>): void {
|
||||
this.log("warn", message, data);
|
||||
}
|
||||
|
||||
/** Log at error level */
|
||||
error(message: string, data?: Record<string, unknown>): void {
|
||||
this.log("error", message, data);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
import type { ProviderConfig, ReasoningConfig, StreamEvent } from "../types";
|
||||
import { LLMApiError, LLMTimeoutError, LLMNetworkError } from "../types";
|
||||
import { Logger } from "../logger";
|
||||
|
||||
/** Default idle timeout for streaming requests (2 minutes without data = abort) */
|
||||
const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -83,8 +84,15 @@ type ResponsesStreamEvent =
|
|||
*/
|
||||
export class OpenAIResponsesProvider {
|
||||
readonly name = "openai";
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(private readonly config: ProviderConfig) {}
|
||||
constructor(
|
||||
private readonly config: ProviderConfig,
|
||||
logger?: Logger,
|
||||
) {
|
||||
// Use provided logger or create a disabled one
|
||||
this.logger = logger ?? new Logger({ enabled: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a response from the Responses API
|
||||
|
|
@ -192,20 +200,39 @@ export class OpenAIResponsesProvider {
|
|||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${this.config.baseUrl}/responses`, {
|
||||
const fetchUrl = `${this.config.baseUrl}/responses`;
|
||||
const bodyJson = JSON.stringify(requestBody);
|
||||
this.logger.debug("Making POST request", {
|
||||
url: fetchUrl,
|
||||
bodySize: bodyJson.length,
|
||||
model: request.model,
|
||||
reasoning: request.reasoning,
|
||||
});
|
||||
const fetchStartTime = Date.now();
|
||||
response = await fetch(fetchUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
body: bodyJson,
|
||||
signal: controller.signal,
|
||||
});
|
||||
this.logger.debug("Fetch completed", {
|
||||
durationMs: Date.now() - fetchStartTime,
|
||||
status: response.status,
|
||||
});
|
||||
} catch (error) {
|
||||
clearAllTimeouts();
|
||||
this.logger.error("Fetch error", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
const timeoutValue = idleTimedOut ? idleTimeoutMs : totalTimeoutMs;
|
||||
this.logger.error("Request aborted due to timeout", {
|
||||
timeoutMs: timeoutValue,
|
||||
});
|
||||
throw new LLMTimeoutError(this.name, timeoutValue);
|
||||
}
|
||||
|
||||
|
|
@ -229,6 +256,7 @@ export class OpenAIResponsesProvider {
|
|||
}
|
||||
|
||||
// Process the SSE stream
|
||||
this.logger.debug("Starting to read SSE stream");
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
clearAllTimeouts();
|
||||
|
|
@ -239,10 +267,20 @@ export class OpenAIResponsesProvider {
|
|||
let buffer = "";
|
||||
let accumulatedOutput = "";
|
||||
let finalResponse: ResponsesStreamEvent | null = null;
|
||||
let chunkCount = 0;
|
||||
let eventCount = 0;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
chunkCount++;
|
||||
if (chunkCount <= 3 || chunkCount % 100 === 0) {
|
||||
this.logger.debug("Chunk received", {
|
||||
chunkNum: chunkCount,
|
||||
size: value?.length ?? 0,
|
||||
done,
|
||||
});
|
||||
}
|
||||
|
||||
if (done) break;
|
||||
|
||||
|
|
@ -266,9 +304,14 @@ export class OpenAIResponsesProvider {
|
|||
try {
|
||||
const event = JSON.parse(data) as ResponsesStreamEvent;
|
||||
|
||||
eventCount++;
|
||||
|
||||
// Handle different event types
|
||||
switch (event.type) {
|
||||
case "response.created":
|
||||
this.logger.info("Stream started", {
|
||||
responseId: event.response.id,
|
||||
});
|
||||
yield {
|
||||
type: "started",
|
||||
responseId: event.response.id,
|
||||
|
|
@ -285,6 +328,10 @@ export class OpenAIResponsesProvider {
|
|||
break;
|
||||
|
||||
case "response.reasoning_summary_text.done":
|
||||
this.logger.debug("Reasoning summary complete", {
|
||||
summaryIndex: event.summary_index,
|
||||
textLength: event.text.length,
|
||||
});
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: event.text,
|
||||
|
|
@ -303,10 +350,21 @@ export class OpenAIResponsesProvider {
|
|||
break;
|
||||
|
||||
case "response.completed":
|
||||
this.logger.info("Stream completed", {
|
||||
outputLength: accumulatedOutput.length,
|
||||
usage:
|
||||
finalResponse?.type === "response.completed"
|
||||
? finalResponse.response.usage
|
||||
: undefined,
|
||||
});
|
||||
finalResponse = event;
|
||||
break;
|
||||
|
||||
case "response.failed":
|
||||
this.logger.error("Stream failed", {
|
||||
message: event.error.message,
|
||||
code: event.error.code,
|
||||
});
|
||||
yield {
|
||||
type: "error",
|
||||
message: event.error.message,
|
||||
|
|
@ -315,6 +373,10 @@ export class OpenAIResponsesProvider {
|
|||
break;
|
||||
|
||||
case "error":
|
||||
this.logger.error("Stream error", {
|
||||
message: event.error.message,
|
||||
code: event.error.code ?? event.error.type,
|
||||
});
|
||||
yield {
|
||||
type: "error",
|
||||
message: event.error.message,
|
||||
|
|
@ -332,6 +394,12 @@ export class OpenAIResponsesProvider {
|
|||
// Clear timeouts on successful completion
|
||||
clearAllTimeouts();
|
||||
|
||||
this.logger.debug("Stream reading finished", {
|
||||
totalChunks: chunkCount,
|
||||
totalEvents: eventCount,
|
||||
hasFinalResponse: !!finalResponse,
|
||||
});
|
||||
|
||||
// Extract and validate the final output
|
||||
if (finalResponse && finalResponse.type === "response.completed") {
|
||||
const output = finalResponse.response.output;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,50 @@
|
|||
import type { z } from "zod";
|
||||
|
||||
// ============================================================================
|
||||
// Logging Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Log levels for LLM client logging
|
||||
*/
|
||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
/**
|
||||
* Logger function signature
|
||||
* @param level - The log level
|
||||
* @param message - The log message
|
||||
* @param data - Optional structured data to include
|
||||
*/
|
||||
export type LoggerFn = (
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
data?: Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Logging configuration for the LLM client
|
||||
*/
|
||||
export interface LoggingConfig {
|
||||
/** Enable logging globally (default: false) */
|
||||
enabled: boolean;
|
||||
/** Custom logger function (default: console-based logger) */
|
||||
logger?: LoggerFn;
|
||||
/** Minimum log level to output (default: 'debug') */
|
||||
minLevel?: LogLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default console-based logger
|
||||
*/
|
||||
export const defaultLogger: LoggerFn = (level, message, data) => {
|
||||
const prefix = `[llm-client:${level}]`;
|
||||
if (data) {
|
||||
console[level === "debug" ? "log" : level](prefix, message, data);
|
||||
} else {
|
||||
console[level === "debug" ? "log" : level](prefix, message);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider configuration loaded from environment variables
|
||||
*/
|
||||
|
|
@ -28,6 +73,8 @@ export interface LLMClientConfig {
|
|||
providers: Record<string, ProviderConfig>;
|
||||
/** Default maximum retry attempts */
|
||||
defaultMaxRetries: number;
|
||||
/** Logging configuration */
|
||||
logging?: LoggingConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -336,6 +383,11 @@ export interface LLMStreamRequest<T extends z.ZodType> {
|
|||
* Streaming requests typically take longer, so default is higher
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Enable debug logging for this request (overrides global setting)
|
||||
* Set to true to enable, false to disable, or omit to use global setting
|
||||
*/
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue