feat: add log-level system to suppress noisy fallback debug output

Replace console.debug/log/warn calls in model router, retry, and daemon
startup with a structured logger that respects a configurable log_level.
Default level is 'info', suppressing verbose fallback debug messages in
the TUI while keeping them available via config when needed.

- Add src/logger.ts with debug/info/warn/error/silent levels
- Wire log_level into config schema (default: 'info')
- Initialize log level in both daemon and TUI startup paths
- Convert all console.debug in router.ts and retry.ts to logger.debug
- Convert console.log/warn in daemon/models.ts to logger.info/warn
This commit is contained in:
William Valentin
2026-02-09 21:23:07 -08:00
parent 94946eb7a8
commit 35f4cab0dc
8 changed files with 79 additions and 16 deletions
+4
View File
@@ -1,6 +1,10 @@
# Flynn Configuration # Flynn Configuration
# Copy to ~/.config/flynn/config.yaml and customize # Copy to ~/.config/flynn/config.yaml and customize
# Log verbosity: debug | info | warn | error | silent (default: info)
# Set to 'debug' to see model fallback details.
# log_level: info
telegram: telegram:
bot_token: ${FLYNN_TELEGRAM_TOKEN} bot_token: ${FLYNN_TELEGRAM_TOKEN}
allowed_chat_ids: [] # Add your Telegram chat ID allowed_chat_ids: [] # Add your Telegram chat ID
+3
View File
@@ -4,6 +4,7 @@ import { loadConfigSafe, getConfigPath } from './shared.js';
import { existsSync, mkdirSync, readFileSync } from 'fs'; import { existsSync, mkdirSync, readFileSync } from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
import { setLogLevel } from '../logger.js';
// ANSI color codes for tool status display // ANSI color codes for tool status display
const toolColors = { const toolColors = {
@@ -43,6 +44,8 @@ export function registerTuiCommand(program: Command): void {
// Dynamic imports to keep CLI startup fast // Dynamic imports to keep CLI startup fast
const { SessionStore, SessionManager } = await import('../session/index.js'); const { SessionStore, SessionManager } = await import('../session/index.js');
setLogLevel(config.log_level);
const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js'); const { MinimalTui, startFullscreenTui } = await import('../frontends/tui/index.js');
const { NativeAgent } = await import('../backends/index.js'); const { NativeAgent } = await import('../backends/index.js');
const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } = await import('../tools/index.js'); const { ToolRegistry, ToolExecutor, allBuiltinTools, createWebSearchTools, createProcessTools, ProcessManager } = await import('../tools/index.js');
+4
View File
@@ -345,7 +345,10 @@ const sessionsSchema = z.object({
ttl: z.string().default('30d'), ttl: z.string().default('30d'),
}).default({}); }).default({});
const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error', 'silent']).default('info');
export const configSchema = z.object({ export const configSchema = z.object({
log_level: logLevelSchema,
telegram: telegramSchema, telegram: telegramSchema,
discord: discordSchema, discord: discordSchema,
slack: slackSchema, slack: slackSchema,
@@ -406,3 +409,4 @@ export type HeartbeatCheck = z.infer<typeof heartbeatCheckSchema>;
export type EmbeddingConfig = z.infer<typeof embeddingSchema>; export type EmbeddingConfig = z.infer<typeof embeddingSchema>;
export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>; export type EmbeddingProvider = z.infer<typeof embeddingProviderSchema>;
export type PairingCodeConfig = z.infer<typeof pairingSchema>; export type PairingCodeConfig = z.infer<typeof pairingSchema>;
export type LogLevel = z.infer<typeof logLevelSchema>;
+4
View File
@@ -9,6 +9,7 @@ import type { AudioTranscriptionConfig } from '../models/media.js';
import type { ToolRegistry, ToolExecutor, BrowserManager } from '../tools/index.js'; import type { ToolRegistry, ToolExecutor, BrowserManager } from '../tools/index.js';
import type { AgentConfigRegistry, AgentRouter } from '../agents/index.js'; import type { AgentConfigRegistry, AgentRouter } from '../agents/index.js';
import type { SandboxManager } from '../sandbox/index.js'; import type { SandboxManager } from '../sandbox/index.js';
import { setLogLevel } from '../logger.js';
// ── Daemon Modules ── // ── Daemon Modules ──
import { Lifecycle } from './lifecycle.js'; import { Lifecycle } from './lifecycle.js';
@@ -51,6 +52,9 @@ export interface DaemonContext {
} }
export async function startDaemon(config: Config): Promise<DaemonContext> { export async function startDaemon(config: Config): Promise<DaemonContext> {
// ── Log level ──
setLogLevel(config.log_level);
const lifecycle = new Lifecycle(); const lifecycle = new Lifecycle();
// ── Data & Sessions ── // ── Data & Sessions ──
+6 -5
View File
@@ -1,6 +1,7 @@
import type { Config, ModelConfig } from '../config/index.js'; import type { Config, ModelConfig } from '../config/index.js';
import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js'; import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClient, BedrockClient, GitHubModelsClient, ModelRouter, DEFAULT_RETRY_CONFIG } from '../models/index.js';
import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js';
import { logger } from '../logger.js';
/** /**
* Create a ModelClient from a provider config entry. * Create a ModelClient from a provider config entry.
@@ -166,7 +167,7 @@ export function createModelRouter(config: Config): ModelRouter {
// Named provider from local_providers map // Named provider from local_providers map
fallbackChain.push(createClientFromConfig(models.local_providers[providerName])); fallbackChain.push(createClientFromConfig(models.local_providers[providerName]));
} else { } else {
console.warn(`Fallback chain entry "${providerName}" not found — skipping`); logger.warn(`Fallback chain entry "${providerName}" not found — skipping`);
} }
} }
@@ -211,13 +212,13 @@ export function createModelRouter(config: Config): ModelRouter {
if (tierFallbacks.size > 0) { if (tierFallbacks.size > 0) {
const tierNames = Array.from(tierFallbacks.keys()).join(', '); const tierNames = Array.from(tierFallbacks.keys()).join(', ');
console.log(`Per-tier fallbacks configured for: ${tierNames}`); logger.info(`Per-tier fallbacks configured for: ${tierNames}`);
} }
if (autoFallbackTiers.length > 0) { if (autoFallbackTiers.length > 0) {
console.log(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`); logger.info(`Auto same-model fallback (via GitHub Models) for: ${autoFallbackTiers.join(', ')}`);
} }
console.log(`Model router: default=${models.default.provider}/${models.default.model}, ` + logger.info(`Model router: default=${models.default.provider}/${models.default.model}, ` +
`fallback=[${models.fallback_chain.join(', ')}]`); `fallback=[${models.fallback_chain.join(', ')}]`);
// Build retry config if enabled // Build retry config if enabled
@@ -230,7 +231,7 @@ export function createModelRouter(config: Config): ModelRouter {
} : undefined; } : undefined;
if (retryConfig) { if (retryConfig) {
console.log(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`); logger.info(`Retry policy: max_retries=${retryConfig.maxRetries}, initial_delay=${retryConfig.initialDelayMs}ms`);
} }
return new ModelRouter({ return new ModelRouter({
+44
View File
@@ -0,0 +1,44 @@
/** Simple log-level utility.
*
* Default level is `info`, which suppresses `debug` output.
* Set to `debug` (via config or `setLevel()`) to see all messages.
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent';
const LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
silent: 4,
};
let currentLevel: LogLevel = 'info';
export function setLogLevel(level: LogLevel): void {
currentLevel = level;
}
export function getLogLevel(): LogLevel {
return currentLevel;
}
function shouldLog(level: LogLevel): boolean {
return LEVELS[level] >= LEVELS[currentLevel];
}
export const logger = {
debug(...args: unknown[]): void {
if (shouldLog('debug')) console.debug(...args);
},
info(...args: unknown[]): void {
if (shouldLog('info')) console.log(...args);
},
warn(...args: unknown[]): void {
if (shouldLog('warn')) console.warn(...args);
},
error(...args: unknown[]): void {
if (shouldLog('error')) console.error(...args);
},
};
+3 -1
View File
@@ -1,3 +1,5 @@
import { logger } from '../logger.js';
export interface RetryConfig { export interface RetryConfig {
/** Maximum number of retry attempts (default: 3). Does not count the initial attempt. */ /** Maximum number of retry attempts (default: 3). Does not count the initial attempt. */
maxRetries: number; maxRetries: number;
@@ -60,7 +62,7 @@ export async function withRetry<T>(
const delay = Math.min(baseDelay, config.maxDelayMs); const delay = Math.min(baseDelay, config.maxDelayMs);
const jitter = delay * (0.5 + Math.random() * 0.5); // 50-100% of delay const jitter = delay * (0.5 + Math.random() * 0.5); // 50-100% of delay
console.debug( logger.debug(
`[retry] ${label ?? 'operation'} attempt ${attempt + 1}/${config.maxRetries} failed: ${lastError.message}. Retrying in ${Math.round(jitter)}ms...`, `[retry] ${label ?? 'operation'} attempt ${attempt + 1}/${config.maxRetries} failed: ${lastError.message}. Retrying in ${Math.round(jitter)}ms...`,
); );
+11 -10
View File
@@ -1,6 +1,7 @@
import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js'; import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient } from './types.js';
import { withRetry } from './retry.js'; import { withRetry } from './retry.js';
import type { RetryConfig } from './retry.js'; import type { RetryConfig } from './retry.js';
import { logger } from '../logger.js';
export type ModelTier = 'fast' | 'default' | 'complex' | 'local'; export type ModelTier = 'fast' | 'default' | 'complex' | 'local';
@@ -76,7 +77,7 @@ export class ModelRouter implements ModelClient {
return await primaryClient.chat(request); return await primaryClient.chat(request);
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error))); errors.push(error instanceof Error ? error : new Error(String(error)));
console.debug(`Primary model failed: ${errors[0].message}`); logger.debug(`Primary model failed: ${errors[0].message}`);
} }
// Try tier-specific fallbacks first // Try tier-specific fallbacks first
@@ -84,12 +85,12 @@ export class ModelRouter implements ModelClient {
for (let i = 0; i < tierFallbackList.length; i++) { for (let i = 0; i < tierFallbackList.length; i++) {
try { try {
const reason = `Primary model failed (${errors[0].message}), using tier fallback #${i + 1}`; const reason = `Primary model failed (${errors[0].message}), using tier fallback #${i + 1}`;
console.debug(reason); logger.debug(reason);
const response = await tierFallbackList[i].chat(request); const response = await tierFallbackList[i].chat(request);
return { ...response, fallback: true, fallbackReason: reason }; return { ...response, fallback: true, fallbackReason: reason };
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error))); errors.push(error instanceof Error ? error : new Error(String(error)));
console.debug(`Tier fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); logger.debug(`Tier fallback #${i + 1} failed: ${errors[errors.length - 1].message}`);
} }
} }
@@ -98,12 +99,12 @@ export class ModelRouter implements ModelClient {
const fallbackClient = this.fallbackChain[i]; const fallbackClient = this.fallbackChain[i];
try { try {
const reason = `Primary model failed (${errors[0].message}), using global fallback #${i + 1}`; const reason = `Primary model failed (${errors[0].message}), using global fallback #${i + 1}`;
console.debug(reason); logger.debug(reason);
const response = await fallbackClient.chat(request); const response = await fallbackClient.chat(request);
return { ...response, fallback: true, fallbackReason: reason }; return { ...response, fallback: true, fallbackReason: reason };
} catch (error) { } catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error))); errors.push(error instanceof Error ? error : new Error(String(error)));
console.debug(`Global fallback #${i + 1} failed: ${errors[errors.length - 1].message}`); logger.debug(`Global fallback #${i + 1} failed: ${errors[errors.length - 1].message}`);
} }
} }
@@ -121,7 +122,7 @@ export class ModelRouter implements ModelClient {
if (event.type === 'error') { if (event.type === 'error') {
hasError = true; hasError = true;
primaryError = event.error?.message ?? 'Unknown error'; primaryError = event.error?.message ?? 'Unknown error';
console.debug(`Primary stream failed: ${primaryError}`); logger.debug(`Primary stream failed: ${primaryError}`);
break; break;
} }
yield event; yield event;
@@ -139,14 +140,14 @@ export class ModelRouter implements ModelClient {
if (!fallbackClient.chatStream) continue; if (!fallbackClient.chatStream) continue;
const reason = `Primary model failed (${primaryError}), using tier fallback #${i + 1}`; const reason = `Primary model failed (${primaryError}), using tier fallback #${i + 1}`;
console.debug(reason); logger.debug(reason);
yield { type: 'fallback_warning', fallbackReason: reason }; yield { type: 'fallback_warning', fallbackReason: reason };
let hasError = false; let hasError = false;
for await (const event of fallbackClient.chatStream(request)) { for await (const event of fallbackClient.chatStream(request)) {
if (event.type === 'error') { if (event.type === 'error') {
hasError = true; hasError = true;
console.debug(`Tier fallback stream #${i + 1} failed: ${event.error?.message}`); logger.debug(`Tier fallback stream #${i + 1} failed: ${event.error?.message}`);
break; break;
} }
yield event; yield event;
@@ -161,14 +162,14 @@ export class ModelRouter implements ModelClient {
if (!fallbackClient.chatStream) continue; if (!fallbackClient.chatStream) continue;
const reason = `Primary model failed (${primaryError}), using global fallback #${i + 1}`; const reason = `Primary model failed (${primaryError}), using global fallback #${i + 1}`;
console.debug(reason); logger.debug(reason);
yield { type: 'fallback_warning', fallbackReason: reason }; yield { type: 'fallback_warning', fallbackReason: reason };
let hasError = false; let hasError = false;
for await (const event of fallbackClient.chatStream(request)) { for await (const event of fallbackClient.chatStream(request)) {
if (event.type === 'error') { if (event.type === 'error') {
hasError = true; hasError = true;
console.debug(`Global fallback stream #${i + 1} failed: ${event.error?.message}`); logger.debug(`Global fallback stream #${i + 1} failed: ${event.error?.message}`);
break; break;
} }
yield event; yield event;