From decc0ad4c3b73a42d7a46fe49531acf307faeab2 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Thu, 14 May 2026 00:41:56 +0200 Subject: [PATCH 1/8] Add logger design spec --- .../2026-05-14-omniroute-logger-design.md | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-omniroute-logger-design.md diff --git a/docs/superpowers/specs/2026-05-14-omniroute-logger-design.md b/docs/superpowers/specs/2026-05-14-omniroute-logger-design.md new file mode 100644 index 0000000..be14557 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-omniroute-logger-design.md @@ -0,0 +1,207 @@ +# OmniRoute Plugin Logger Design + +**Date:** 2026-05-14 +**Status:** Spec Ready for Implementation +**Topic:** Proper logging implementation for opencode-omniroute-auth plugin + +## Problem Statement + +The plugin currently uses `console.log` and `console.warn` for debug output, which pollutes the OpenCode terminal. We need a proper logger that: +- Writes to the OpenCode log file instead of console +- Supports debug/warn levels with environment-based toggling +- Integrates with OpenCode's existing log format + +## Goals + +1. Replace all `console.log`/`console.warn` calls with a proper logger +2. Write logs to OpenCode's log directory (`~/.local/share/opencode/log/`) +3. Use OpenCode's native log format with `service=omniroute` +4. Keep warnings always-on, debug opt-in via `OMNIROUTE_DEBUG` env var +5. Zero I/O overhead when debug is disabled (no-op for debug calls — no file writes, but string interpolation at call site still executes) + +## Non-Goals + +- Multi-level logging (error/info/debug/trace) — only warn and debug +- Log rotation or retention policies — handled by OpenCode +- Structured logging with JSON — plain text matching OpenCode format +- Configurable log destination — always OpenCode's log directory + +## Architecture + +### Components + +#### 1. `src/logger.ts` — Logger Module + +A lightweight logger that appends to the current OpenCode log file. + +**Interface:** +```typescript +export function warn(message: string): void; +export function debug(message: string): void; +``` + +Note: Call sites must stringify any objects/arrays/errors before passing. For multi-argument calls like `console.warn(msg, errorObject)`, use template literals: `` logger.warn(`${msg}: ${errorObject}`) ``. For Error objects, use `error.message` or `error.stack` rather than `JSON.stringify(error)` (which returns `{}`). + +**Call Site Audit (existing console calls to migrate):** + +| File | Line | Current | New Level | Notes | +|------|------|---------|-----------|-------| +| `src/plugin.ts` | 46 | `console.warn(..., error)` | `warn` | Eager model fetch failed | +| `src/plugin.ts` | 102 | `console.log(...)` | `debug` | Available models list | +| `src/plugin.ts` | 104 | `console.warn(..., error)` | `warn` | Failed to fetch models | +| `src/plugin.ts` | 110 | `console.log(...)` | `debug` | Provider models hydrated | +| `src/plugin.ts` | 157 | `console.warn(..., error)` | `warn` | Unexpected error reading auth store | +| `src/plugin.ts` | 165 | `console.warn(...)` | `warn` | provider.api and options.apiMode differ | +| `src/plugin.ts` | 173 | `console.warn(...)` | `warn` | Unsupported provider.api value | +| `src/plugin.ts` | 189 | `console.warn(...)` | `warn` | Unsupported apiMode option | +| `src/plugin.ts` | 211 | `console.warn(...)` | `warn` | Ignoring unsupported baseURL protocol | +| `src/plugin.ts` | 217 | `console.warn(...)` | `warn` | Ignoring invalid baseURL | +| `src/plugin.ts` | 443 | `console.log(...)` | `debug` | Intercepting request | +| `src/plugin.ts` | 471 | `console.log(...)` | `debug` | Processing /v1/models response | +| `src/plugin.ts` | 521 | `console.log(...)` | `debug` | Sanitized Gemini tool schema keywords | +| `src/models.ts` | 56 | `console.log(...)` | `debug` | Using cached models | +| `src/models.ts` | 60 | `console.log(...)` | `debug` | Forcing model refresh | +| `src/models.ts` | 67 | `console.log(...)` | `debug` | Fetching models from URL | +| `src/models.ts` | 85 | `console.error(...)` | `warn` | Failed to fetch models (HTTP error) | +| `src/models.ts` | 96 | `console.error(...)` | `warn` | Invalid models response structure | +| `src/models.ts` | 131 | `console.log(...)` | `debug` | Successfully fetched N models | +| `src/models.ts` | 134 | `console.error(...)` | `warn` | Error fetching models | +| `src/models.ts` | 139 | `console.log(...)` | `debug` | Returning expired cached models | +| `src/models.ts` | 144 | `console.log(...)` | `debug` | Returning default models | +| `src/models.ts` | 161 | `console.log(...)` | `debug` | Model cache cleared (specific) | +| `src/models.ts` | 164 | `console.log(...)` | `debug` | All model caches cleared | +| `src/models-dev.ts` | 93 | `console.log(...)` | `debug` | Using cached models.dev data | +| `src/models-dev.ts` | 97 | `console.log(...)` | `debug` | Fetching models.dev data from URL | +| `src/models-dev.ts` | 112 | `console.warn(...)` | `warn` | Failed to fetch models.dev data | +| `src/models-dev.ts` | 120 | `console.warn(...)` | `warn` | Invalid models.dev data structure | +| `src/models-dev.ts` | 130 | `console.log(...)` | `debug` | Successfully fetched models.dev data | +| `src/models-dev.ts` | 133 | `console.warn(...)` | `warn` | Error fetching models.dev data | +| `src/models-dev.ts` | 208 | `console.log(...)` | `debug` | models.dev cache cleared | +| `src/omniroute-combos.ts` | 58 | `console.log(...)` | `debug` | Using cached combo data | +| `src/omniroute-combos.ts` | 63 | `console.log(...)` | `debug` | Fetching combo data from URL | +| `src/omniroute-combos.ts` | 79 | `console.warn(...)` | `warn` | Failed to fetch combo data | +| `src/omniroute-combos.ts` | 87 | `console.warn(...)` | `warn` | Invalid combo data structure | +| `src/omniroute-combos.ts` | 105 | `console.log(...)` | `debug` | Successfully fetched N combos | +| `src/omniroute-combos.ts` | 108 | `console.warn(...)` | `warn` | Error fetching combo data | +| `src/omniroute-combos.ts` | 120 | `console.log(...)` | `debug` | Combo cache cleared | +| `src/omniroute-combos.ts` | 141 | `console.log(...)` | `debug` | Resolved combo to N models | +| `src/omniroute-combos.ts` | 149 | `console.warn(...)` | `warn` | Unexpected model entry in combo | +| `src/omniroute-combos.ts` | 276 | `console.log(...)` | `debug` | Calculating capabilities for combo | +| `src/omniroute-combos.ts` | 291 | `console.warn(...)` | `warn` | Could not resolve underlying models | +| `src/omniroute-combos.ts` | 297 | `console.warn(...)` | `warn` | No models.dev matches found for combo | +| `src/omniroute-combos.ts` | 301 | `console.log(...)` | `debug` | Resolved N/N underlying models | +| `src/omniroute-combos.ts` | 306 | `console.log(...)` | `debug` | Calculated capabilities for combo | +| `src/omniroute-combos.ts` | 355 | `console.log(...)` | `debug` | Enriching combo model | + +**Behavior:** +- `warn()`: Always writes to log file (unless write fails) +- `debug()`: Only writes when `OMNIROUTE_DEBUG` environment variable is exactly `"1"` (strict string comparison; all other values including `"true"`, `"yes"`, `"0"`, empty string are treated as disabled) +- Both functions use synchronous I/O (`fs.appendFileSync` wrapped in `try/catch`) for simplicity and fire-and-forget semantics + +**Implementation Details:** +- Log file path is resolved once at module load and cached (wrapped in `try/catch` to prevent crash on import) +- Resolution: finds the most recent `.log` file in `~/.local/share/opencode/log/` by modification time (`mtime`) + - Only considers regular files (not directories) via `stat.isFile()` + - Tie-breaker: alphabetical sort if multiple files have identical `mtime` +- If no log file exists at module load, logger attempts re-scan on every `warn()` and `debug()` call (in case OpenCode creates a log file later) +- If cached file becomes unavailable (e.g., OpenCode rotated logs), falls back to re-scanning on next write failure + - Write failures that trigger re-scan: `ENOENT` (file deleted) + - Other failures (`EACCES`, `ENOSPC`, `EIO`): silently skip without re-scan + - Re-scan updates the cached path; the current log call uses the newly discovered file (same-call retry) +- Appends entries in OpenCode's format: `LEVEL ISO-TIMESTAMP +0ms service=omniroute MESSAGE` + - `OFFSET` is hardcoded to `+0ms` to match observed OpenCode log format +- Silently fails if log file cannot be written (don't crash the plugin) +- If no log files exist, skips logging (does not create new files to avoid conflicts with OpenCode's log rotation) + +#### 2. Updated Source Files + +Replace console calls in all source files. Mapping rules: +- `console.log()` → `logger.debug()` (informational messages) +- `console.warn()` → `logger.warn()` (warnings) +- `console.error()` → `logger.warn()` (errors that don't stop execution — plugin continues with fallbacks) + +Files to update: +- `src/plugin.ts` — 13 console statements (mix of log/warn) +- `src/models.ts` — 11 console statements (mix of log/error) +- `src/models-dev.ts` — 7 console statements (mix of log/warn) +- `src/omniroute-combos.ts` — 15 console statements (mix of log/warn) + +### Log Format + +Following OpenCode's existing format: + +``` +WARN 2026-05-14T12:34:56.789Z +0ms service=omniroute Invalid baseURL: foo://bar, using default +DEBUG 2026-05-14T12:34:56.789Z +0ms service=omniroute Available models: gpt-4o, claude-3-5-sonnet +``` + +Format: `{LEVEL} {ISO-TIMESTAMP} +0ms service=omniroute {MESSAGE}` + - `LEVEL` is right-padded to 5 characters (`WARN ` or `DEBUG`) + - Timestamp uses `Date.prototype.toISOString()` format: `YYYY-MM-DDTHH:MM:SS.sssZ` (UTC with milliseconds) + - The `+0ms` offset is hardcoded to match observed OpenCode log format (actual offset meaning is internal to OpenCode) + +### Data Flow + +``` +Plugin Code + | + v +logger.warn("Invalid baseURL") logger.debug("Fetching models") + | | + v v +Always write Check OMNIROUTE_DEBUG + | | + v v +Find current log file Find current log file + | | + v v +Append formatted entry Append formatted entry + | | + v v +~/.local/share/opencode/log/*.log +``` + +### Error Handling + +- **Log directory missing**: Silently skip logging (don't create directory) +- **Log directory not readable** (`EACCES`, `EPERM` on `readdirSync`): Silently skip logging +- **Log file not writable** (`EACCES`, `EPERM`): Silently skip (don't throw) +- **Log file deleted** (`ENOENT`): Trigger re-scan for new log file on next write +- **No log files exist**: Silently skip logging (don't create files to avoid conflicts with OpenCode's log rotation) +- **Disk full** (`ENOSPC`): Silently skip +- **Other I/O errors** (`EIO`, etc.): Silently skip + +## Testing Strategy + +### Unit Tests + +1. **Debug enabled**: Set `OMNIROUTE_DEBUG=1`, call `debug()`, verify log file contains entry +2. **Debug disabled**: Unset `OMNIROUTE_DEBUG`, call `debug()`, verify no entry written +3. **Warn always**: Call `warn()` with and without `OMNIROUTE_DEBUG`, verify always written +4. **Format correct**: Verify output matches OpenCode format with `service=omniroute` +5. **Graceful failure**: Mock unreadable log directory, verify no exceptions thrown +6. **Log rotation**: Simulate OpenCode log rotation by deleting the cached log file, then call `warn()` — verify logger re-scans directory and writes to new most-recent file + +### Integration Tests + +1. Run plugin with `OMNIROUTE_DEBUG=1`, verify debug messages appear in latest OpenCode log +2. Run plugin without env var, verify only warnings appear + +## Migration Plan + +1. Create `src/logger.ts` with warn/debug functions +2. Replace console calls in `src/plugin.ts` — remove `[OmniRoute] ` prefix from messages (redundant with `service=omniroute`) +3. Replace console calls in `src/models.ts` — remove `[OmniRoute] ` prefix +4. Replace console calls in `src/models-dev.ts` — remove `[OmniRoute] ` prefix +5. Replace console calls in `src/omniroute-combos.ts` — remove `[OmniRoute] ` prefix +6. Add unit tests for logger module +7. Verify no console calls remain: `grep -En "console\.(log|warn|error)" src/` + +## Open Questions + +None — design approved by user. + +## References + +- OpenCode log format observed in `~/.local/share/opencode/log/*.log` +- Existing plugin code in `src/plugin.ts`, `src/models.ts`, `src/models-dev.ts`, `src/omniroute-combos.ts` From 6f7e6d95c2c8b146b24b3ff6687f2868bb25f7f2 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 09:39:25 +0200 Subject: [PATCH 2/8] feat: add logger module with warn/debug levels --- src/logger.ts | 98 +++++++++++++++ test/logger.test.mjs | 293 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 src/logger.ts create mode 100644 test/logger.test.mjs diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..c8c6e92 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,98 @@ +import { appendFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const LOG_DIR = join( + process.env.XDG_DATA_HOME || join(process.env.HOME || homedir(), '.local', 'share'), + 'opencode', + 'log' +); + +function findCurrentLogFile(): string | null { + try { + if (!existsSync(LOG_DIR)) return null; + + const files = readdirSync(LOG_DIR) + .filter((f) => f.endsWith('.log')) + .map((f) => { + const path = join(LOG_DIR, f); + const stat = statSync(path); + return { path, mtime: stat.mtime.getTime(), isFile: stat.isFile() }; + }) + .filter((f) => f.isFile) + .sort((a, b) => b.mtime - a.mtime || a.path.localeCompare(b.path)); + + return files[0]?.path ?? null; + } catch { + return null; + } +} + +// Resolve log file path at module load (wrapped in try/catch per spec) +let cachedLogFile: string | null; +try { + cachedLogFile = findCurrentLogFile(); +} catch { + cachedLogFile = null; +} + +function getLogFile(): string | null { + if (cachedLogFile === null) { + // Re-scan if no file found at module load (OpenCode may create one later) + cachedLogFile = findCurrentLogFile(); + } + return cachedLogFile; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as NodeJS.ErrnoException).code === 'string' + ); +} + +function writeLog(level: string, message: string): void { + let logFile = getLogFile(); + if (!logFile) return; + + // Check if cached file still exists (handles log rotation) + if (!existsSync(logFile)) { + cachedLogFile = findCurrentLogFile(); + logFile = cachedLogFile; + if (!logFile) return; + } + + const timestamp = new Date().toISOString(); + const line = `${level.padEnd(5)} ${timestamp} +0ms service=omniroute ${message}\n`; + + try { + appendFileSync(logFile, line); + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + // Log file was deleted, re-scan + cachedLogFile = findCurrentLogFile(); + // Retry once with new file + const newLogFile = cachedLogFile; + if (newLogFile) { + try { + appendFileSync(newLogFile, line); + } catch { + // Silently fail on second attempt + } + } + } + // Silently fail for all other errors + } +} + +export function warn(message: string): void { + writeLog('WARN', message); +} + +export function debug(message: string): void { + // Strict comparison: only "1" enables debug logging + if (process.env.OMNIROUTE_DEBUG !== '1') return; + writeLog('DEBUG', message); +} diff --git a/test/logger.test.mjs b/test/logger.test.mjs new file mode 100644 index 0000000..a7e123c --- /dev/null +++ b/test/logger.test.mjs @@ -0,0 +1,293 @@ +import { test } from 'node:test'; +import assert from 'node:assert'; +import { + writeFileSync, + readFileSync, + mkdirSync, + rmSync, + statSync, + utimesSync, + chmodSync, + readdirSync, +} from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_LOG_DIR = join(__dirname, 'test-logs'); + +// Set up isolated test environment +process.env.XDG_DATA_HOME = join(TEST_LOG_DIR, 'data'); +const LOG_DIR = join(TEST_LOG_DIR, 'data', 'opencode', 'log'); + +// Helper to create a test log file with most recent mtime +function createTestLogFile(name) { + const path = join(LOG_DIR, name); + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(path, ''); + // Set mtime to now + 1s to ensure it's the most recent + const now = Date.now() / 1000; + utimesSync(path, now, now + 1); + return path; +} + +// Cleanup before and after tests +function cleanupTestLogs() { + try { + const files = readdirSync(LOG_DIR); + for (const file of files) { + if (file.startsWith('test-')) { + rmSync(join(LOG_DIR, file)); + } + } + } catch {} +} + +// Run cleanup before all tests +cleanupTestLogs(); + +// Run cleanup after all tests (using process.on since node:test doesn't have global after) +process.on('exit', cleanupTestLogs); + +test('warn() writes to log file with correct format', async () => { + const testLogFile = createTestLogFile('test-warn.log'); + + // Import fresh logger module with cache buster + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}-${Math.random()}`); + + warn('Test warning message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test warning message'), 'warn should write message'); + assert.ok(content.includes('WARN'), 'log should have WARN level'); + assert.ok(content.includes('service=omniroute'), 'log should include service tag'); + assert.ok(content.includes('+0ms'), 'log should include +0ms offset'); + assert.match( + content, + /^(WARN|DEBUG)\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \+0ms service=omniroute .+$/m, + 'log format should match spec' + ); + + rmSync(testLogFile); +}); + +test('debug() writes when OMNIROUTE_DEBUG=1', async () => { + const testLogFile = createTestLogFile('test-debug-enabled.log'); + process.env.OMNIROUTE_DEBUG = '1'; + + // Import fresh module to pick up env var + const { debug } = await import('../dist/src/logger.js#' + Date.now() + '-' + Math.random()); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test debug message'), 'debug should write when enabled'); + assert.ok(content.includes('DEBUG'), 'log should have DEBUG level'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is not set', async () => { + const testLogFile = createTestLogFile('test-debug-disabled.log'); + delete process.env.OMNIROUTE_DEBUG; + + const { debug } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when disabled'); + + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is "true"', async () => { + const testLogFile = createTestLogFile('test-debug-true.log'); + process.env.OMNIROUTE_DEBUG = 'true'; + + const { debug } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when OMNIROUTE_DEBUG is "true"'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is "0"', async () => { + const testLogFile = createTestLogFile('test-debug-zero.log'); + process.env.OMNIROUTE_DEBUG = '0'; + + const { debug } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when OMNIROUTE_DEBUG is "0"'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('warn() always writes regardless of OMNIROUTE_DEBUG', async () => { + const testLogFile = createTestLogFile('test-warn-always.log'); + delete process.env.OMNIROUTE_DEBUG; + + const { warn } = await import('../dist/src/logger.js#' + Date.now() + '-' + Math.random()); + + warn('Test warning message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test warning message'), 'warn should always write'); + + rmSync(testLogFile); +}); + +test('logger handles missing log directory gracefully', async () => { + const originalXdg = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = '/nonexistent/path'; + + const { warn } = await import('../dist/src/logger.js#' + Date.now() + '-' + Math.random()); + + // Should not throw + warn('Test message'); + } finally { + process.env.XDG_DATA_HOME = originalXdg; + } +}); + +test('logger handles log file rotation', async () => { + const oldLogFile = createTestLogFile('test-old.log'); + + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + warn('First message'); + + // Simulate log rotation: delete old file, create new one + rmSync(oldLogFile); + const newLogFile = createTestLogFile('test-new.log'); + + warn('Second message after rotation'); + + const content = readFileSync(newLogFile, 'utf-8'); + assert.ok( + content.includes('Second message after rotation'), + 'should write to new log file after rotation' + ); + + rmSync(newLogFile); +}); + +test('logger re-scans when no log file exists at module load', async () => { + // Ensure no test log files exist in LOG_DIR + cleanupTestLogs(); + + // Import logger when no log file exists + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + // Create log file after module load + const testLogFile = createTestLogFile('test-rescan.log'); + + warn('Message after log file created'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Message after log file created'), 'should re-scan and write to new log file'); + + rmSync(testLogFile); +}); + +test('logger silently skips on non-ENOENT write errors', async () => { + // Create a read-only log file (skip on Windows where chmod behaves differently) + if (process.platform === 'win32') { + return; + } + + const testLogFile = createTestLogFile('test-readonly.log'); + chmodSync(testLogFile, 0o444); + + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + // Should not throw even though file is read-only + warn('Test read-only message'); + + // Restore permissions and verify nothing was written + chmodSync(testLogFile, 0o644); + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'should not write to read-only file'); + + rmSync(testLogFile); +}); + +test('logger silently skips on unreadable log directory', async () => { + // Skip on Windows where chmod behaves differently + if (process.platform === 'win32') { + return; + } + + // Create a log directory that is not readable + const unreadableDir = join(TEST_LOG_DIR, 'unreadable'); + const logSubdir = join(unreadableDir, 'opencode', 'log'); + mkdirSync(logSubdir, { recursive: true }); + + const originalXdg = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = unreadableDir; + chmodSync(logSubdir, 0o000); + + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + + // Should not throw even though directory is unreadable + warn('Test unreadable directory'); + } finally { + process.env.XDG_DATA_HOME = originalXdg; + chmodSync(logSubdir, 0o755); + rmSync(unreadableDir, { recursive: true }); + } +}); + +test('logger excludes directories with .log suffix', async () => { + // Create a directory named like a log file + const fakeDir = join(LOG_DIR, 'fake-dir.log'); + mkdirSync(fakeDir, { recursive: true }); + + // Create a real log file + const testLogFile = createTestLogFile('test-real.log'); + + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + warn('Test directory exclusion'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test directory exclusion'), 'should write to real log file, not directory'); + + rmSync(testLogFile); + rmSync(fakeDir, { recursive: true }); +}); + +test('logger uses alphabetical tie-breaker for identical mtime', async () => { + // Create two log files with identical mtime + const fileA = join(LOG_DIR, 'test-alpha.log'); + const fileB = join(LOG_DIR, 'test-beta.log'); + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(fileA, ''); + writeFileSync(fileB, ''); + const now = Date.now() / 1000; + utimesSync(fileA, now, now); + utimesSync(fileB, now, now); + + const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); + warn('Test tie-breaker'); + + // Should write to test-alpha.log (alphabetically first) + const contentA = readFileSync(fileA, 'utf-8'); + const contentB = readFileSync(fileB, 'utf-8'); + assert.ok(contentA.includes('Test tie-breaker'), 'should write to alphabetically first file'); + assert.strictEqual(contentB, '', 'should not write to second file'); + + rmSync(fileA); + rmSync(fileB); +}); From d58dd54c987b52c5cb56c9b1957222a95d201d93 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 09:42:49 +0200 Subject: [PATCH 3/8] refactor: replace console calls with logger in plugin.ts --- src/plugin.ts | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index e16e483..aed08c5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -16,6 +16,7 @@ import { OMNIROUTE_ENDPOINTS, } from './constants.js'; import { fetchModels } from './models.js'; +import { warn, debug } from './logger.js'; const OMNIROUTE_PROVIDER_NAME = 'OmniRoute'; const OMNIROUTE_PROVIDER_NPM = '@ai-sdk/openai-compatible'; @@ -43,7 +44,7 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { models = await fetchModels(runtimeConfig, auth.key, false); } } catch (error) { - console.warn('[OmniRoute] Eager model fetch failed, using defaults:', error); + warn(`Eager model fetch failed, using defaults: ${error}`); } providers[OMNIROUTE_PROVIDER_ID] = { @@ -99,15 +100,15 @@ async function loadProviderOptions( try { const forceRefresh = config.refreshOnList !== false; models = await fetchModels(config, config.apiKey, forceRefresh); - console.log(`[OmniRoute] Available models: ${models.map((model) => model.id).join(', ')}`); + debug(`Available models: ${models.map((model) => model.id).join(', ')}`); } catch (error) { - console.warn('[OmniRoute] Failed to fetch models, using defaults:', error); + warn(`Failed to fetch models, using defaults: ${error}`); models = OMNIROUTE_DEFAULT_MODELS; } replaceProviderModels(provider, toProviderModels(models, config.baseUrl)); if (isRecord(provider.models)) { - console.log(`[OmniRoute] Provider models hydrated: ${Object.keys(provider.models).length}`); + debug(`Provider models hydrated: ${Object.keys(provider.models).length}`); } return { @@ -154,7 +155,7 @@ async function readAuthFromStore( if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { return null; } - console.warn('[OmniRoute] Unexpected error reading auth store:', error); + warn(`Unexpected error reading auth store: ${error}`); return null; } } @@ -162,15 +163,13 @@ async function readAuthFromStore( function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteApiMode { if (isApiMode(api)) { if (api !== apiMode) { - console.warn( - `[OmniRoute] provider.api (${api}) and options.apiMode (${apiMode}) differ; using options.apiMode.`, - ); + warn('provider.api and options.apiMode differ; using options.apiMode'); } return apiMode; } if (typeof api === 'string') { - console.warn(`[OmniRoute] Unsupported provider.api value: ${api}. Using ${apiMode}.`); + warn(`Unsupported provider.api value. Using ${apiMode}.`); } return apiMode; @@ -186,7 +185,7 @@ function getApiMode(options?: Record): OmniRouteApiMode { return value; } - console.warn(`[OmniRoute] Unsupported apiMode option: ${String(value)}. Using chat.`); + warn('Unsupported apiMode option. Using chat.'); return 'chat'; } @@ -208,13 +207,13 @@ function getBaseUrl(options?: Record): string { try { const parsed = new URL(trimmed); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - console.warn(`[OmniRoute] Ignoring unsupported baseURL protocol: ${parsed.protocol}`); + warn(`Ignoring unsupported baseURL protocol: ${parsed.protocol}`); return OMNIROUTE_ENDPOINTS.BASE_URL; } return trimmed; } catch { - console.warn(`[OmniRoute] Ignoring invalid baseURL: ${trimmed}`); + warn(`Ignoring invalid baseURL: ${trimmed}`); return OMNIROUTE_ENDPOINTS.BASE_URL; } } @@ -440,7 +439,7 @@ function createFetchInterceptor( return fetch(input, init); } - console.log(`[OmniRoute] Intercepting request to ${url}`); + debug(`Intercepting request to ${url}`); // Merge headers from Request and init to avoid dropping existing headers const headers = new Headers(input instanceof Request ? input.headers : undefined); @@ -468,7 +467,7 @@ function createFetchInterceptor( // Handle model fetching endpoint specially if (url.includes('/v1/models') && response.ok) { - console.log('[OmniRoute] Processing /v1/models response'); + debug('Processing /v1/models response'); } return response; @@ -518,7 +517,7 @@ async function sanitizeGeminiToolSchemas( return undefined; } - console.log('[OmniRoute] Sanitized Gemini tool schema keywords'); + debug('Sanitized Gemini tool schema keywords'); return JSON.stringify(clonedPayload); } From aea82aef2aa793878f49f326fa65c07e95c1a956 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 09:45:00 +0200 Subject: [PATCH 4/8] refactor: replace console calls with logger in models.ts --- src/models.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/models.ts b/src/models.ts index 257b5cc..4eed8f0 100644 --- a/src/models.ts +++ b/src/models.ts @@ -8,6 +8,7 @@ import { import { getModelsDevIndex, normalizeModelKey } from './models-dev.js'; import type { ModelsDevIndex } from './models-dev.js'; import { enrichComboModels, clearComboCache } from './omniroute-combos.js'; +import { warn, debug } from './logger.js'; /** * Model cache entry @@ -53,18 +54,18 @@ export async function fetchModels( const cached = modelCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < cacheTtl) { - console.log('[OmniRoute] Using cached models'); + debug('Using cached models'); return cached.models; } } else { - console.log('[OmniRoute] Forcing model refresh'); + debug('Forcing model refresh'); } // Use default baseUrl if not provided to prevent undefined URL const baseUrl = config.baseUrl || OMNIROUTE_ENDPOINTS.BASE_URL; const modelsUrl = `${baseUrl}${OMNIROUTE_ENDPOINTS.MODELS}`; - console.log(`[OmniRoute] Fetching models from ${modelsUrl}`); + debug(`Fetching models from ${modelsUrl}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); @@ -82,9 +83,7 @@ export async function fetchModels( if (!response.ok) { // Sanitize error - only log status, not response body - console.error( - `[OmniRoute] Failed to fetch models: ${response.status} ${response.statusText}`, - ); + warn(`Failed to fetch models: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`); } @@ -93,7 +92,7 @@ export async function fetchModels( // Runtime validation to ensure API returns expected structure if (!rawData || typeof rawData !== 'object' || !Array.isArray(rawData.data)) { - console.error('[OmniRoute] Invalid models response structure:', rawData); + warn(`Invalid models response structure: ${JSON.stringify(rawData)}`); throw new Error('Invalid models response structure: expected { data: Array }'); } @@ -128,20 +127,20 @@ export async function fetchModels( timestamp: Date.now(), }); - console.log(`[OmniRoute] Successfully fetched ${models.length} models`); + debug(`Successfully fetched ${models.length} models`); return models; } catch (error) { - console.error('[OmniRoute] Error fetching models:', error); + warn(`Error fetching models: ${error}`); // Return cached models if available (even if expired) const cached = modelCache.get(cacheKey); if (cached) { - console.log('[OmniRoute] Returning expired cached models as fallback'); + debug('Returning expired cached models as fallback'); return cached.models; } // Return default models as last resort - console.log('[OmniRoute] Returning default models as fallback'); + debug('Returning default models as fallback'); return config.defaultModels || OMNIROUTE_DEFAULT_MODELS; } finally { // Always clear the timeout to prevent memory leaks @@ -158,10 +157,10 @@ export function clearModelCache(config?: OmniRouteConfig, apiKey?: string): void if (config && apiKey) { const cacheKey = getCacheKey(config, apiKey); modelCache.delete(cacheKey); - console.log('[OmniRoute] Model cache cleared for provided configuration'); + debug('Model cache cleared for provided configuration'); } else { modelCache.clear(); - console.log('[OmniRoute] All model caches cleared'); + debug('All model caches cleared'); } // Also clear combo cache clearComboCache(); From ffd281d65289f83577863ac319b30c1089a66694 Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 09:46:49 +0200 Subject: [PATCH 5/8] refactor: replace console calls with logger in models-dev.ts --- src/models-dev.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/models-dev.ts b/src/models-dev.ts index de5ba63..e88e13c 100644 --- a/src/models-dev.ts +++ b/src/models-dev.ts @@ -4,6 +4,7 @@ import { MODELS_DEV_CACHE_TTL, MODELS_DEV_TIMEOUT_MS, } from './constants.js'; +import { warn, debug } from './logger.js'; /** * models.dev model information @@ -90,11 +91,11 @@ export async function fetchModelsDevData( // Check cache first if (modelsDevCache && Date.now() - modelsDevCache.timestamp < cacheTtl) { - console.log('[OmniRoute] Using cached models.dev data'); + debug('Using cached models.dev data'); return modelsDevCache.data; } - console.log(`[OmniRoute] Fetching models.dev data from ${url}`); + debug(`Fetching models.dev data from ${url}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -109,7 +110,7 @@ export async function fetchModelsDevData( }); if (!response.ok) { - console.warn(`[OmniRoute] Failed to fetch models.dev data: ${response.status}`); + warn(`Failed to fetch models.dev data: ${response.status}`); return null; } @@ -117,7 +118,7 @@ export async function fetchModelsDevData( // Validate structure if (!data || typeof data !== 'object') { - console.warn('[OmniRoute] Invalid models.dev data structure'); + warn('Invalid models.dev data structure'); return null; } @@ -127,10 +128,10 @@ export async function fetchModelsDevData( timestamp: Date.now(), }; - console.log(`[OmniRoute] Successfully fetched models.dev data`); + debug('Successfully fetched models.dev data'); return data; } catch (error) { - console.warn('[OmniRoute] Error fetching models.dev data:', error); + warn(`Error fetching models.dev data: ${error}`); return null; } finally { clearTimeout(timeoutId); @@ -205,7 +206,7 @@ export async function getModelsDevIndex( */ export function clearModelsDevCache(): void { modelsDevCache = null; - console.log('[OmniRoute] models.dev cache cleared'); + debug('models.dev cache cleared'); } /** From 962224daf0778a325e476ef1aa8396d5a6c5aa6c Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 09:49:27 +0200 Subject: [PATCH 6/8] refactor: replace console calls with logger in omniroute-combos.ts --- src/omniroute-combos.ts | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/omniroute-combos.ts b/src/omniroute-combos.ts index 2767d5f..1650003 100644 --- a/src/omniroute-combos.ts +++ b/src/omniroute-combos.ts @@ -7,6 +7,7 @@ import { normalizeModelKey, } from './models-dev.js'; import { REQUEST_TIMEOUT } from './constants.js'; +import { warn, debug } from './logger.js'; /** * OmniRoute combo definition from /api/combos @@ -55,12 +56,12 @@ export async function fetchComboData( // Check cache first if (comboCache && Date.now() - comboCache.timestamp < COMBO_CACHE_TTL) { - console.log('[OmniRoute] Using cached combo data'); + debug('Using cached combo data'); return comboCache.combos; } const combosUrl = `${baseUrl.replace(/\/v1\/?$/, '').replace(/\/$/, '')}/api/combos`; - console.log(`[OmniRoute] Fetching combo data from ${combosUrl}`); + debug(`Fetching combo data from ${combosUrl}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); @@ -76,7 +77,7 @@ export async function fetchComboData( }); if (!response.ok) { - console.warn(`[OmniRoute] Failed to fetch combo data: ${response.status}`); + warn(`Failed to fetch combo data: ${response.status}`); return null; } @@ -84,7 +85,7 @@ export async function fetchComboData( // Validate structure if (!data?.combos || !Array.isArray(data.combos)) { - console.warn('[OmniRoute] Invalid combo data structure'); + warn('Invalid combo data structure'); return null; } @@ -102,10 +103,10 @@ export async function fetchComboData( timestamp: Date.now(), }; - console.log(`[OmniRoute] Successfully fetched ${comboMap.size} combos`); + debug(`Successfully fetched ${comboMap.size} combos`); return comboMap; } catch (error) { - console.warn('[OmniRoute] Error fetching combo data:', error); + warn(`Error fetching combo data: ${error}`); return null; } finally { clearTimeout(timeoutId); @@ -117,7 +118,7 @@ export async function fetchComboData( */ export function clearComboCache(): void { comboCache = null; - console.log('[OmniRoute] Combo cache cleared'); + debug('Combo cache cleared'); } /** @@ -138,7 +139,7 @@ export async function resolveUnderlyingModels( // Check if this is a combo model const combo = combos.get(modelId); if (combo) { - console.log(`[OmniRoute] Resolved combo "${modelId}" to ${combo.models.length} underlying models`); + debug(`Resolved combo "${modelId}" to ${combo.models.length} underlying models`); return combo.models .map((m) => { if (typeof m === 'string') return m; @@ -146,7 +147,7 @@ export async function resolveUnderlyingModels( const modelId = (m as Record).model ?? (m as Record).id; if (typeof modelId === 'string') return modelId; } - console.warn('[OmniRoute] Unexpected model entry in combo:', m); + warn(`Unexpected model entry in combo: ${JSON.stringify(m)}`); return null; }) .filter((m): m is string => m !== null); @@ -273,7 +274,7 @@ export async function calculateModelCapabilities( } // It's a combo - lookup all underlying models - console.log(`[OmniRoute] Calculating capabilities for combo "${model.id}" from ${underlyingModels.length} models`); + debug(`Calculating capabilities for combo "${model.id}" from ${underlyingModels.length} models`); const resolvedModels: ModelsDevModel[] = []; const unresolvedModels: string[] = []; @@ -288,23 +289,23 @@ export async function calculateModelCapabilities( } if (unresolvedModels.length > 0) { - console.warn( - `[OmniRoute] Could not resolve ${unresolvedModels.length} underlying models for "${model.id}": ${unresolvedModels.join(', ')}`, + warn( + `Could not resolve ${unresolvedModels.length} underlying models for "${model.id}": ${unresolvedModels.join(', ')}`, ); } if (resolvedModels.length === 0) { - console.warn(`[OmniRoute] No models.dev matches found for combo "${model.id}"`); + warn(`No models.dev matches found for combo "${model.id}"`); return {}; } - console.log(`[OmniRoute] Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${model.id}"`); + debug(`Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${model.id}"`); // Calculate lowest common capabilities const capabilities = calculateLowestCommonCapabilities(resolvedModels); - console.log( - `[OmniRoute] Calculated capabilities for "${model.id}": context=${capabilities.contextWindow ?? 'N/A'}, maxTokens=${capabilities.maxTokens ?? 'N/A'}, vision=${capabilities.supportsVision ?? false}, tools=${capabilities.supportsTools ?? false}`, + debug( + `Calculated capabilities for "${model.id}": context=${capabilities.contextWindow ?? 'N/A'}, maxTokens=${capabilities.maxTokens ?? 'N/A'}, vision=${capabilities.supportsVision ?? false}, tools=${capabilities.supportsTools ?? false}`, ); return capabilities; @@ -352,7 +353,7 @@ export async function enrichComboModels( return model; } - console.log(`[OmniRoute] Enriching combo model: ${model.id}`); + debug(`Enriching combo model: ${model.id}`); // Calculate capabilities for this combo const capabilities = await calculateModelCapabilities(model, config, modelsDevIndex); From 99ce143c92739f99971e7592b7cefae270390bba Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 10:19:53 +0200 Subject: [PATCH 7/8] refactor: address code review feedback and fix pre-existing test failures - Remove redundant try/catch around findCurrentLogFile() (review) - Restore specific config values in warning messages (review) - Fix models.test.mjs to only count /v1/models fetches - Return empty responses for combo/models.dev endpoints in tests - All 22 tests now passing --- .../plans/2026-05-14-omniroute-logger.md | 743 ++++++++++++++++++ src/logger.ts | 9 +- src/plugin.ts | 6 +- test/models.test.mjs | 74 +- 4 files changed, 793 insertions(+), 39 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-14-omniroute-logger.md diff --git a/docs/superpowers/plans/2026-05-14-omniroute-logger.md b/docs/superpowers/plans/2026-05-14-omniroute-logger.md new file mode 100644 index 0000000..fb37bd8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-omniroute-logger.md @@ -0,0 +1,743 @@ +# OmniRoute Plugin Logger Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace all console.log/console.warn calls with a proper logger that writes to OpenCode's log file, controlled by OMNIROUTE_DEBUG env var. + +**Architecture:** Create a lightweight logger module (`src/logger.ts`) that appends to the current OpenCode log file. Warn always writes; debug only writes when OMNIROUTE_DEBUG=1. Replace all console calls across 4 source files. + +**Tech Stack:** TypeScript, Node.js fs module, native fetch (for tests) + +--- + +## File Structure + +**New files:** +- `src/logger.ts` — Logger module with warn/debug functions +- `test/logger.test.mjs` — Unit tests for logger + +**Modified files:** +- `src/plugin.ts` — Replace 13 console calls with logger +- `src/models.ts` — Replace 11 console calls with logger +- `src/models-dev.ts` — Replace 7 console calls with logger +- `src/omniroute-combos.ts` — Replace 15 console calls with logger + +--- + +## Chunk 1: Logger Module + +### Task 1: Create Logger Module + +**Files:** +- Create: `src/logger.ts` +- Test: `test/logger.test.mjs` + +**Behavior:** +- `warn(message: string)`: Always appends to log file (unless I/O fails) +- `debug(message: string)`: Only appends when `OMNIROUTE_DEBUG === "1"` +- Log format: `WARN 2026-05-14T12:34:56.789Z +0ms service=omniroute message` +- Log file: most recent `.log` file in `~/.local/share/opencode/log/` +- Silently fail on all I/O errors (don't crash plugin) +- Cache log file path at module load, re-scan on ENOENT + +- [ ] **Step 1: Write failing tests for logger** + +```javascript +import { test } from 'node:test'; +import assert from 'node:assert'; +import { writeFileSync, readFileSync, mkdirSync, rmSync, statSync, utimesSync, chmodSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const TEST_LOG_DIR = join(__dirname, 'test-logs'); + +// Set up isolated test environment +process.env.XDG_DATA_HOME = join(TEST_LOG_DIR, 'data'); +const LOG_DIR = join(TEST_LOG_DIR, 'data', 'opencode', 'log'); + +// Helper to create a test log file with most recent mtime +function createTestLogFile(name) { + const path = join(LOG_DIR, name); + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(path, ''); + // Set mtime to now + 1s to ensure it's the most recent + const now = Date.now() / 1000; + utimesSync(path, now, now + 1); + return path; +} + +// Cleanup before and after tests +function cleanupTestLogs() { + try { + const files = readdirSync(LOG_DIR); + for (const file of files) { + if (file.startsWith('test-')) { + rmSync(join(LOG_DIR, file)); + } + } + } catch {} +} + +// Run cleanup before all tests +cleanupTestLogs(); + +// Run cleanup after all tests (using process.on since node:test doesn't have global after) +process.on('exit', cleanupTestLogs); + +test('warn() writes to log file with correct format', async () => { + const testLogFile = createTestLogFile('test-warn.log'); + + // Import fresh logger module with cache buster + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + warn('Test warning message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test warning message'), 'warn should write message'); + assert.ok(content.includes('WARN'), 'log should have WARN level'); + assert.ok(content.includes('service=omniroute'), 'log should include service tag'); + assert.ok(content.includes('+0ms'), 'log should include +0ms offset'); + assert.match(content, /^(WARN|DEBUG)\s+\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \+0ms service=omniroute .+$/m, 'log format should match spec'); + + rmSync(testLogFile); +}); + +test('debug() writes when OMNIROUTE_DEBUG=1', async () => { + const testLogFile = createTestLogFile('test-debug-enabled.log'); + process.env.OMNIROUTE_DEBUG = '1'; + + // Import fresh module to pick up env var + const { debug } = await import('../dist/src/logger.js?cache=' + Date.now()); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test debug message'), 'debug should write when enabled'); + assert.ok(content.includes('DEBUG'), 'log should have DEBUG level'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is not set', async () => { + const testLogFile = createTestLogFile('test-debug-disabled.log'); + delete process.env.OMNIROUTE_DEBUG; + + const { debug } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when disabled'); + + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is "true"', async () => { + const testLogFile = createTestLogFile('test-debug-true.log'); + process.env.OMNIROUTE_DEBUG = 'true'; + + const { debug } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when OMNIROUTE_DEBUG is "true"'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('debug() does not write when OMNIROUTE_DEBUG is "0"', async () => { + const testLogFile = createTestLogFile('test-debug-zero.log'); + process.env.OMNIROUTE_DEBUG = '0'; + + const { debug } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + debug('Test debug message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'debug should not write when OMNIROUTE_DEBUG is "0"'); + + delete process.env.OMNIROUTE_DEBUG; + rmSync(testLogFile); +}); + +test('warn() always writes regardless of OMNIROUTE_DEBUG', async () => { + const testLogFile = createTestLogFile('test-warn-always.log'); + delete process.env.OMNIROUTE_DEBUG; + + const { warn } = await import('../dist/src/logger.js?cache=' + Date.now()); + + warn('Test warning message'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test warning message'), 'warn should always write'); + + rmSync(testLogFile); +}); + +test('logger handles missing log directory gracefully', async () => { + const originalXdg = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = '/nonexistent/path'; + + const { warn } = await import('../dist/src/logger.js?cache=' + Date.now()); + + // Should not throw + warn('Test message'); + } finally { + process.env.XDG_DATA_HOME = originalXdg; + } +}); + +test('logger handles log file rotation', async () => { + const oldLogFile = createTestLogFile('test-old.log'); + + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + warn('First message'); + + // Simulate log rotation: delete old file, create new one + rmSync(oldLogFile); + const newLogFile = createTestLogFile('test-new.log'); + + warn('Second message after rotation'); + + const content = readFileSync(newLogFile, 'utf-8'); + assert.ok(content.includes('Second message after rotation'), 'should write to new log file after rotation'); + + rmSync(newLogFile); +}); + +test('logger re-scans when no log file exists at module load', async () => { + // Ensure no test log files exist in LOG_DIR + cleanupTestLogs(); + + // Import logger when no log file exists + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + // Create log file after module load + const testLogFile = createTestLogFile('test-rescan.log'); + + warn('Message after log file created'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Message after log file created'), 'should re-scan and write to new log file'); + + rmSync(testLogFile); +}); + +test('logger silently skips on non-ENOENT write errors', async () => { + // Create a read-only log file (skip on Windows where chmod behaves differently) + if (process.platform === 'win32') { + return; + } + + const testLogFile = createTestLogFile('test-readonly.log'); + chmodSync(testLogFile, 0o444); + + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + // Should not throw even though file is read-only + warn('Test read-only message'); + + // Restore permissions and verify nothing was written + chmodSync(testLogFile, 0o644); + const content = readFileSync(testLogFile, 'utf-8'); + assert.strictEqual(content, '', 'should not write to read-only file'); + + rmSync(testLogFile); +}); + +test('logger silently skips on unreadable log directory', async () => { + // Skip on Windows where chmod behaves differently + if (process.platform === 'win32') { + return; + } + + // Create a log directory that is not readable + const unreadableDir = join(TEST_LOG_DIR, 'unreadable'); + const logSubdir = join(unreadableDir, 'opencode', 'log'); + mkdirSync(logSubdir, { recursive: true }); + + const originalXdg = process.env.XDG_DATA_HOME; + try { + process.env.XDG_DATA_HOME = unreadableDir; + chmodSync(logSubdir, 0o000); + + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + + // Should not throw even though directory is unreadable + warn('Test unreadable directory'); + } finally { + process.env.XDG_DATA_HOME = originalXdg; + chmodSync(logSubdir, 0o755); + rmSync(unreadableDir, { recursive: true }); + } +}); + +test('logger excludes directories with .log suffix', async () => { + // Create a directory named like a log file + const fakeDir = join(LOG_DIR, 'fake-dir.log'); + mkdirSync(fakeDir, { recursive: true }); + + // Create a real log file + const testLogFile = createTestLogFile('test-real.log'); + + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + warn('Test directory exclusion'); + + const content = readFileSync(testLogFile, 'utf-8'); + assert.ok(content.includes('Test directory exclusion'), 'should write to real log file, not directory'); + + rmSync(testLogFile); + rmSync(fakeDir, { recursive: true }); +}); + +test('logger uses alphabetical tie-breaker for identical mtime', async () => { + // Create two log files with identical mtime + const fileA = join(LOG_DIR, 'test-alpha.log'); + const fileB = join(LOG_DIR, 'test-beta.log'); + mkdirSync(LOG_DIR, { recursive: true }); + writeFileSync(fileA, ''); + writeFileSync(fileB, ''); + const now = Date.now() / 1000; + utimesSync(fileA, now, now); + utimesSync(fileB, now, now); + + const { warn } = await import(`../dist/src/logger.js?v=${Date.now()}`); + warn('Test tie-breaker'); + + // Should write to test-alpha.log (alphabetically first) + const contentA = readFileSync(fileA, 'utf-8'); + const contentB = readFileSync(fileB, 'utf-8'); + assert.ok(contentA.includes('Test tie-breaker'), 'should write to alphabetically first file'); + assert.strictEqual(contentB, '', 'should not write to second file'); + + rmSync(fileA); + rmSync(fileB); +}); +``` + +Run: `node --test test/logger.test.mjs` +Expected: FAIL - logger module doesn't exist + +- [ ] **Step 2: Create logger.ts** + +```typescript +import { appendFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +const LOG_DIR = join(process.env.XDG_DATA_HOME || join(process.env.HOME || homedir(), '.local', 'share'), 'opencode', 'log'); + +function findCurrentLogFile(): string | null { + try { + if (!existsSync(LOG_DIR)) return null; + + const files = readdirSync(LOG_DIR) + .filter(f => f.endsWith('.log')) + .map(f => { + const path = join(LOG_DIR, f); + const stat = statSync(path); + return { path, mtime: stat.mtime.getTime(), isFile: stat.isFile() }; + }) + .filter(f => f.isFile) + .sort((a, b) => b.mtime - a.mtime || a.path.localeCompare(b.path)); + + return files[0]?.path ?? null; + } catch { + return null; + } +} + +// Resolve log file path at module load (wrapped in try/catch per spec) +let cachedLogFile: string | null; +try { + cachedLogFile = findCurrentLogFile(); +} catch { + cachedLogFile = null; +} + +function getLogFile(): string | null { + if (cachedLogFile === null) { + // Re-scan if no file found at module load (OpenCode may create one later) + cachedLogFile = findCurrentLogFile(); + } + return cachedLogFile; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + typeof (error as NodeJS.ErrnoException).code === 'string' + ); +} + +function writeLog(level: string, message: string): void { + const logFile = getLogFile(); + if (!logFile) return; + + const timestamp = new Date().toISOString(); + const line = `${level.padEnd(5)} ${timestamp} +0ms service=omniroute ${message}\n`; + + try { + appendFileSync(logFile, line); + } catch (error: unknown) { + if (isNodeError(error) && error.code === 'ENOENT') { + // Log file was deleted, re-scan + cachedLogFile = findCurrentLogFile(); + // Retry once with new file + const newLogFile = cachedLogFile; + if (newLogFile) { + try { + appendFileSync(newLogFile, line); + } catch { + // Silently fail on second attempt + } + } + } + // Silently fail for all other errors + } +} + +export function warn(message: string): void { + writeLog('WARN', message); +} + +export function debug(message: string): void { + // Strict comparison: only "1" enables debug logging + if (process.env.OMNIROUTE_DEBUG !== '1') return; + writeLog('DEBUG', message); +} +``` + +- [ ] **Step 3: Build and run tests** + +Run: `npm run build && node --test test/logger.test.mjs` +Expected: Tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/logger.ts test/logger.test.mjs +git commit -m "feat: add logger module with warn/debug levels" +``` + +--- + +## Chunk 2: Replace Console Calls in plugin.ts + +### Task 2: Migrate plugin.ts + +**Files:** +- Modify: `src/plugin.ts` + +- [ ] **Step 1: Add logger import** + +Add at top of file: +```typescript +import { warn, debug } from './logger.js'; +``` + +- [ ] **Step 2: Replace console calls** + +Replace each console call (remove `[OmniRoute] ` prefix): + +Line 46: `console.warn('[OmniRoute] Eager model fetch failed, using defaults:', error)` +→ `warn(\`Eager model fetch failed, using defaults: ${error}\`)` + +Line 102: `console.log(\`[OmniRoute] Available models: ${models.map((model) => model.id).join(', ')}\`)` +→ `debug(\`Available models: ${models.map((model) => model.id).join(', ')}\`)` + +Line 104: `console.warn('[OmniRoute] Failed to fetch models, using defaults:', error)` +→ `warn(\`Failed to fetch models, using defaults: ${error}\`)` + +Line 110: `console.log(\`[OmniRoute] Provider models hydrated: ${Object.keys(provider.models).length}\`)` +→ `debug(\`Provider models hydrated: ${Object.keys(provider.models).length}\`)` + +Line 157: `console.warn('[OmniRoute] Unexpected error reading auth store:', error)` +→ `warn(\`Unexpected error reading auth store: ${error}\`)` + +Line 165-166: `console.warn(...)` +→ `warn('provider.api and options.apiMode differ; using options.apiMode')` + +Line 173: `console.warn(...)` +→ `warn(\`Unsupported provider.api value. Using ${apiMode}.\`)` + +Line 189: `console.warn(...)` +→ `warn('Unsupported apiMode option. Using chat.')` + +Line 211: `console.warn(...)` +→ `warn(\`Ignoring unsupported baseURL protocol: ${parsed.protocol}\`)` + +Line 217: `console.warn(...)` +→ `warn(\`Ignoring invalid baseURL: ${trimmed}\`)` + +Line 443: `console.log(...)` +→ `debug(\`Intercepting request to ${url}\`)` + +Line 471: `console.log(...)` +→ `debug('Processing /v1/models response')` + +Line 521: `console.log(...)` +→ `debug('Sanitized Gemini tool schema keywords')` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` +Expected: No TypeScript errors + +- [ ] **Step 4: Commit** + +```bash +git add src/plugin.ts +git commit -m "refactor: replace console calls with logger in plugin.ts" +``` + +--- + +## Chunk 3: Replace Console Calls in models.ts + +### Task 3: Migrate models.ts + +**Files:** +- Modify: `src/models.ts` + +- [ ] **Step 1: Add logger import** + +```typescript +import { warn, debug } from './logger.js'; +``` + +- [ ] **Step 2: Replace console calls** + +Line 56: `console.log('[OmniRoute] Using cached models')` +→ `debug('Using cached models')` + +Line 60: `console.log('[OmniRoute] Forcing model refresh')` +→ `debug('Forcing model refresh')` + +Line 67: `console.log(\`[OmniRoute] Fetching models from ${modelsUrl}\`)` +→ `debug(\`Fetching models from ${modelsUrl}\`)` + +Line 85-87: `console.error(...)` +→ `warn(\`Failed to fetch models: ${response.status} ${response.statusText}\`)` + +Line 96: `console.error('[OmniRoute] Invalid models response structure:', rawData)` +→ `warn(\`Invalid models response structure: ${JSON.stringify(rawData)}\`)` + +Line 131: `console.log(\`[OmniRoute] Successfully fetched ${models.length} models\`)` +→ `debug(\`Successfully fetched ${models.length} models\`)` + +Line 134: `console.error('[OmniRoute] Error fetching models:', error)` +→ `warn(\`Error fetching models: ${error}\`)` + +Line 139: `console.log('[OmniRoute] Returning expired cached models as fallback')` +→ `debug('Returning expired cached models as fallback')` + +Line 144: `console.log('[OmniRoute] Returning default models as fallback')` +→ `debug('Returning default models as fallback')` + +Line 161: `console.log('[OmniRoute] Model cache cleared for provided configuration')` +→ `debug('Model cache cleared for provided configuration')` + +Line 164: `console.log('[OmniRoute] All model caches cleared')` +→ `debug('All model caches cleared')` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` +Expected: No TypeScript errors + +- [ ] **Step 4: Commit** + +```bash +git add src/models.ts +git commit -m "refactor: replace console calls with logger in models.ts" +``` + +--- + +## Chunk 4: Replace Console Calls in models-dev.ts + +### Task 4: Migrate models-dev.ts + +**Files:** +- Modify: `src/models-dev.ts` + +- [ ] **Step 1: Add logger import** + +```typescript +import { warn, debug } from './logger.js'; +``` + +- [ ] **Step 2: Replace console calls** + +Line 93: `console.log('[OmniRoute] Using cached models.dev data')` +→ `debug('Using cached models.dev data')` + +Line 97: `console.log(\`[OmniRoute] Fetching models.dev data from ${url}\`)` +→ `debug(\`Fetching models.dev data from ${url}\`)` + +Line 112: `console.warn(\`[OmniRoute] Failed to fetch models.dev data: ${response.status}\`)` +→ `warn(\`Failed to fetch models.dev data: ${response.status}\`)` + +Line 120: `console.warn('[OmniRoute] Invalid models.dev data structure')` +→ `warn('Invalid models.dev data structure')` + +Line 130: `console.log('[OmniRoute] Successfully fetched models.dev data')` +→ `debug('Successfully fetched models.dev data')` + +Line 133: `console.warn('[OmniRoute] Error fetching models.dev data:', error)` +→ `warn(\`Error fetching models.dev data: ${error}\`)` + +Line 208: `console.log('[OmniRoute] models.dev cache cleared')` +→ `debug('models.dev cache cleared')` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` +Expected: No TypeScript errors + +- [ ] **Step 4: Commit** + +```bash +git add src/models-dev.ts +git commit -m "refactor: replace console calls with logger in models-dev.ts" +``` + +--- + +## Chunk 5: Replace Console Calls in omniroute-combos.ts + +### Task 5: Migrate omniroute-combos.ts + +**Files:** +- Modify: `src/omniroute-combos.ts` + +- [ ] **Step 1: Add logger import** + +```typescript +import { warn, debug } from './logger.js'; +``` + +- [ ] **Step 2: Replace console calls** + +Line 58: `console.log('[OmniRoute] Using cached combo data')` +→ `debug('Using cached combo data')` + +Line 63: `console.log(\`[OmniRoute] Fetching combo data from ${combosUrl}\`)` +→ `debug(\`Fetching combo data from ${combosUrl}\`)` + +Line 79: `console.warn(\`[OmniRoute] Failed to fetch combo data: ${response.status}\`)` +→ `warn(\`Failed to fetch combo data: ${response.status}\`)` + +Line 87: `console.warn('[OmniRoute] Invalid combo data structure')` +→ `warn('Invalid combo data structure')` + +Line 105: `console.log(\`[OmniRoute] Successfully fetched ${comboMap.size} combos\`)` +→ `debug(\`Successfully fetched ${comboMap.size} combos\`)` + +Line 108: `console.warn('[OmniRoute] Error fetching combo data:', error)` +→ `warn(\`Error fetching combo data: ${error}\`)` + +Line 120: `console.log('[OmniRoute] Combo cache cleared')` +→ `debug('Combo cache cleared')` + +Line 141: `console.log(\`[OmniRoute] Resolved combo "${modelId}" to ${combo.models.length} underlying models\`)` +→ `debug(\`Resolved combo "${modelId}" to ${combo.models.length} underlying models\`)` + +Line 149: `console.warn('[OmniRoute] Unexpected model entry in combo:', m)` +→ `warn(\`Unexpected model entry in combo: ${JSON.stringify(m)}\`)` + +Line 276: `console.log(\`[OmniRoute] Calculating capabilities for combo "${model.id}" from ${underlyingModels.length} models\`)` +→ `debug(\`Calculating capabilities for combo "${model.id}" from ${underlyingModels.length} models\`)` + +Line 291-293: `console.warn(...)` +→ `warn(\`Could not resolve ${unresolvedModels.length} underlying models for "${model.id}": ${unresolvedModels.join(', ')}\`)` + +Line 297: `console.warn(\`[OmniRoute] No models.dev matches found for combo "${model.id}"\`)` +→ `warn(\`No models.dev matches found for combo "${model.id}"\`)` + +Line 301: `console.log(\`[OmniRoute] Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${model.id}"\`)` +→ `debug(\`Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${model.id}"\`)` + +Line 306-308: `console.log(...)` +→ `debug(\`Calculated capabilities for "${model.id}": context=${capabilities.contextWindow ?? 'N/A'}, maxTokens=${capabilities.maxTokens ?? 'N/A'}, vision=${capabilities.supportsVision ?? false}, tools=${capabilities.supportsTools ?? false}\`)` + +Line 355: `console.log(\`[OmniRoute] Enriching combo model: ${model.id}\`)` +→ `debug(\`Enriching combo model: ${model.id}\`)` + +- [ ] **Step 3: Build and verify** + +Run: `npm run build` +Expected: No TypeScript errors + +- [ ] **Step 4: Commit** + +```bash +git add src/omniroute-combos.ts +git commit -m "refactor: replace console calls with logger in omniroute-combos.ts" +``` + +--- + +## Chunk 6: Final Verification + +### Task 6: Verify No Console Calls Remain + +- [ ] **Step 1: Run grep to find any remaining console calls** + +```bash +grep -En "console\.(log|warn|error)" src/ +``` + +Expected: No output (no matches) + +- [ ] **Step 2: Run full test suite** + +```bash +npm test +``` + +Expected: All tests pass (or same failures as baseline) + +- [ ] **Step 3: Manual verification** + +Run plugin with OMNIROUTE_DEBUG=1 and verify debug messages appear in latest OpenCode log: +```bash +OMNIROUTE_DEBUG=1 npm test +``` + +Check log file: +```bash +tail -20 ~/.local/share/opencode/log/$(ls -t ~/.local/share/opencode/log/*.log | head -1) +``` + +Expected: See DEBUG entries with `service=omniroute` + +- [ ] **Step 4: Commit** + +```bash +git commit -m "chore: verify no console calls remain" +``` + +--- + +## Completion Checklist + +- [ ] `src/logger.ts` created with warn/debug functions +- [ ] `test/logger.test.mjs` created with unit tests +- [ ] All console calls removed from `src/plugin.ts` +- [ ] All console calls removed from `src/models.ts` +- [ ] All console calls removed from `src/models-dev.ts` +- [ ] All console calls removed from `src/omniroute-combos.ts` +- [ ] Build passes without errors +- [ ] Tests pass (or match baseline) +- [ ] Manual verification shows logs in OpenCode log file diff --git a/src/logger.ts b/src/logger.ts index c8c6e92..7ece6e6 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -28,13 +28,8 @@ function findCurrentLogFile(): string | null { } } -// Resolve log file path at module load (wrapped in try/catch per spec) -let cachedLogFile: string | null; -try { - cachedLogFile = findCurrentLogFile(); -} catch { - cachedLogFile = null; -} +// Resolve log file path at module load +let cachedLogFile: string | null = findCurrentLogFile(); function getLogFile(): string | null { if (cachedLogFile === null) { diff --git a/src/plugin.ts b/src/plugin.ts index aed08c5..e4d98b3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -163,13 +163,13 @@ async function readAuthFromStore( function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteApiMode { if (isApiMode(api)) { if (api !== apiMode) { - warn('provider.api and options.apiMode differ; using options.apiMode'); + warn(`provider.api (${api}) and options.apiMode (${apiMode}) differ; using options.apiMode`); } return apiMode; } if (typeof api === 'string') { - warn(`Unsupported provider.api value. Using ${apiMode}.`); + warn(`Unsupported provider.api value: ${api}. Using ${apiMode}.`); } return apiMode; @@ -185,7 +185,7 @@ function getApiMode(options?: Record): OmniRouteApiMode { return value; } - warn('Unsupported apiMode option. Using chat.'); + warn(`Unsupported apiMode option: ${String(value)}. Using chat.`); return 'chat'; } diff --git a/test/models.test.mjs b/test/models.test.mjs index 576f380..70137f8 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -23,16 +23,31 @@ afterEach(() => { global.fetch = ORIGINAL_FETCH; }); -test('fetchModels caches successful responses', async () => { - let calls = 0; +// Helper to create a mock fetch that only counts /v1/models calls +// and returns valid empty responses for other endpoints (combos, models.dev) +function createMockFetch() { + let modelCalls = 0; + + const mockFetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); - global.fetch = async () => { - calls += 1; + if (url.includes('/v1/models')) { + modelCalls += 1; + return new Response( + JSON.stringify({ + object: 'list', + data: [{ id: `model-${modelCalls}`, name: `Model ${modelCalls}` }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + // Return empty valid responses for other endpoints (combos, models.dev) return new Response( - JSON.stringify({ - object: 'list', - data: [{ id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini' }], - }), + JSON.stringify({ data: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, @@ -40,43 +55,44 @@ test('fetchModels caches successful responses', async () => { ); }; + return { mockFetch, getCalls: () => modelCalls }; +} + +test('fetchModels caches successful responses', async () => { + const { mockFetch, getCalls } = createMockFetch(); + global.fetch = mockFetch; + const first = await fetchModels(CONFIG, CONFIG.apiKey, false); const second = await fetchModels(CONFIG, CONFIG.apiKey, false); - assert.equal(calls, 1); - assert.equal(first[0].id, 'gpt-4.1-mini'); - assert.equal(second[0].id, 'gpt-4.1-mini'); + assert.equal(getCalls(), 1); + assert.equal(first[0].id, 'model-1'); + assert.equal(second[0].id, 'model-1'); assert.ok(getCachedModels(CONFIG, CONFIG.apiKey)); assert.equal(isCacheValid(CONFIG, CONFIG.apiKey), true); }); test('refreshModels forces refetch', async () => { - let calls = 0; - - global.fetch = async () => { - calls += 1; - return new Response( - JSON.stringify({ - object: 'list', - data: [{ id: `model-${calls}`, name: `Model ${calls}` }], - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ); - }; + const { mockFetch, getCalls } = createMockFetch(); + global.fetch = mockFetch; await fetchModels(CONFIG, CONFIG.apiKey, false); const refreshed = await refreshModels(CONFIG, CONFIG.apiKey); - assert.equal(calls, 2); + assert.equal(getCalls(), 2); assert.equal(refreshed[0].id, 'model-2'); }); test('fetchModels falls back to defaults when response shape is invalid', async () => { - global.fetch = async () => { - return new Response(JSON.stringify({ data: null }), { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response(JSON.stringify({ data: null }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); From e759db8f9f9b29a34eb4aae2798bfcacd53b72ed Mon Sep 17 00:00:00 2001 From: Sebastian Rumpf Date: Fri, 15 May 2026 11:07:41 +0200 Subject: [PATCH 8/8] chore(release): bump version to 1.1.3 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f0de7..ab17057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project are documented in this file. +## [1.1.3] - 2026-05-15 + +### Added + +- Added proper logger module (`src/logger.ts`) that writes to OpenCode's log file instead of the console. + - `warn()` always writes; `debug()` only writes when `OMNIROUTE_DEBUG=1`. + - Finds the most recent `.log` file in `~/.local/share/opencode/log/` by `mtime`. + - Re-scans for new log files when the cached file is deleted (log rotation support). + - Silently fails on all I/O errors — never crashes the plugin. +- Added 13 unit tests for the logger module (`test/logger.test.mjs`). + +### Changed + +- Replaced all 46 `console.log`/`console.warn`/`console.error` calls across 4 source files with the new logger: + - `src/plugin.ts` (13 calls) + - `src/models.ts` (11 calls) + - `src/models-dev.ts` (7 calls) + - `src/omniroute-combos.ts` (15 calls) +- Log format now matches OpenCode's native format: `WARN 2026-05-14T12:34:56.789Z +0ms service=omniroute `. + +### Fixed + +- Fixed 2 pre-existing failing model cache tests that were incorrectly counting all `fetch` calls instead of only `/v1/models` calls. + ## [1.1.2] - 2026-05-13 ### Fixed diff --git a/package.json b/package.json index a703155..525bd95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-omniroute-auth", - "version": "1.1.2", + "version": "1.1.3", "description": "OpenCode authentication plugin for OmniRoute API with /connect command and dynamic model fetching", "type": "module", "main": "./dist/index.js",