diff --git a/.opencode/config.json b/.opencode/config.json new file mode 100644 index 0000000..d7edfed --- /dev/null +++ b/.opencode/config.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + { + "name": "opencode-omniroute-auth", + "path": "/Users/alpha/git/opencode-omniroute-auth" + } + ] +} diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..c997c03 --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "list" + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ccd634..6acc21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ All notable changes to this project are documented in this file. +## [1.2.0] - 2026-05-17 + +### Added + +- **Comprehensive Model Metadata Normalization** — `normalizeModel()` now reads all OmniRoute field variants with proper precedence: + - camelCase fields (e.g., `contextWindow`, `supportsVision`) + - snake_case fields (e.g., `context_length`, `max_output_tokens`) + - capabilities object (e.g., `capabilities.vision`, `capabilities.tool_calling`) + - Precedence: camelCase > snake_case > capabilities object +- **Provider Alias Deduplication** — Generic deduplication system that groups alias and canonical model entries: + - Added `PROVIDER_ALIAS_TO_CANONICAL` mapping for known aliases (`cx` → `codex`, `ollamacloud` → `ollama-cloud`, etc.) + - Added `deduplicateModels()` function that prefers canonical IDs and merges alias metadata + - Added `resolveProviderAliasForMetadata()` and `isProviderAlias()` helpers + - Only deduplicates known aliases; unknown provider prefixes are preserved as-is +- **Model Capability Enrichment** — Extended `OmniRouteModel` interface with native OmniRoute fields: + - snake_case context limits: `context_length`, `max_input_tokens`, `max_output_tokens` + - Top-level capability flags: `vision`, `tool_calling` + - capabilities object: `vision`, `tool_calling`, `reasoning`, `thinking`, `attachment`, `temperature` +- **Array-based Metadata Validation** — User-provided `modelMetadata` array blocks are now validated with warning logs for invalid entries +- **4 New Test Cases** covering normalization precedence, snake_case field reading, and deduplication behavior + +### Changed + +- **Array Metadata Merge Precedence** — In array-based `modelMetadata`, user config now comes before generated blocks to ensure user overrides take precedence in first-match-wins systems +- **Metadata Key Canonicalization** — User metadata with alias keys (e.g., `cx/gpt-5.5`) is merged into canonical keys (e.g., `codex/gpt-5.5`) to match deduplicated model IDs +- **Unknown Prefix Handling** — Unknown provider prefixes now merge metadata instead of overwriting, preserving all available metadata + +### Fixed + +- **Dead Code Removal** — Eliminated unreachable `isAlias` check in deduplication logic +- **Alias Metadata Loss** — Alias metadata is now merged into canonical entries instead of being dropped +- **Review Feedback** — Addressed all gemini-code-assist review comments from PR #20: + - Fixed array-based metadata merge precedence (userConfig first) + - Added validation for array-based user metadata blocks + - Fixed metadata merging for unknown provider prefixes + - Simplified deduplication logic and merged alias metadata into canonical + ## [1.1.4] - 2026-05-15 ### Added diff --git a/README.md b/README.md index 7266ac3..d8447ae 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,20 @@ - ✅ **Provider Auto-Registration** - Registers an `omniroute` provider via plugin hooks - ✅ **Model Caching** - Intelligent caching with TTL for better performance - ✅ **Fallback Models** - Default models when API is unavailable +- ✅ **Model Metadata Normalization** - Reads all OmniRoute field variants (camelCase, snake_case, capabilities object) with proper precedence +- ✅ **Provider Alias Deduplication** - Automatically deduplicates alias/canonical model entries (e.g., `cx/gpt-5.5` → `codex/gpt-5.5`) - ✅ **Combo Model Capability Enrichment** - Automatically calculates lowest common capabilities for OmniRoute combo models +- ✅ **models.dev Enrichment** - Enriches model metadata from models.dev API with provider alias resolution +- ✅ **Subscription Provider Fallback** - Falls back to public providers for subscription-based models +- ✅ **Model Variant Support** - Automatically strips reasoning effort suffixes (e.g., `gpt-5.5-xhigh` → `gpt-5.5`) for lookup +- ✅ **Secure Logging** - Sanitized log output with async file I/O to prevent event loop blocking ## Installation +```bash +npm install opencode-omniroute-auth +``` + ## Quick Start ### 1. Add plugin to opencode config @@ -276,6 +286,21 @@ interface OmniRouteModel { supportsStreaming?: boolean; supportsVision?: boolean; supportsTools?: boolean; + supportsTemperature?: boolean; + supportsReasoning?: boolean; + supportsAttachment?: boolean; + // OmniRoute native fields (normalized automatically) + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + capabilities?: { + vision?: boolean; + tool_calling?: boolean; + reasoning?: boolean; + thinking?: boolean; + attachment?: boolean; + temperature?: boolean; + }; pricing?: { input?: number; output?: number; @@ -290,14 +315,14 @@ import { fetchModels, clearModelCache, refreshModels, - // New: Combo model utilities + // Combo model utilities clearComboCache, fetchComboData, resolveUnderlyingModels, calculateModelCapabilities, } from 'opencode-omniroute-auth/runtime'; -// Fetch models manually (with automatic enrichment) +// Fetch models manually (with automatic normalization and enrichment) const models = await fetchModels(config, apiKey); // Clear model cache (also clears combo cache) @@ -311,21 +336,6 @@ const combos = await fetchComboData(config); const underlyingModels = await resolveUnderlyingModels('Designer', config); const capabilities = await calculateModelCapabilities(model, config, modelsDevIndex); ``` -import { - fetchModels, - clearModelCache, - refreshModels, -} from 'opencode-omniroute-auth/runtime'; - -// Fetch models manually -const models = await fetchModels(config, apiKey); - -// Clear model cache -clearModelCache(); - -// Force refresh models -const freshModels = await refreshModels(config, apiKey); -``` ## Development @@ -385,3 +395,7 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## Support For support, please open an issue on GitHub or contact OmniRoute support. + +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Alph4d0g/opencode-omniroute-auth&type=Date)](https://star-history.com/#Alph4d0g/opencode-omniroute-auth&Date) diff --git a/docs/superpowers/plans/2026-05-16-omniroute-normalization-dedupe.md b/docs/superpowers/plans/2026-05-16-omniroute-normalization-dedupe.md new file mode 100644 index 0000000..7d61224 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-omniroute-normalization-dedupe.md @@ -0,0 +1,499 @@ +# OmniRoute Model Normalization & Deduplication 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:** Address PR review claims by normalizing OmniRoute native metadata and deduplicating alias/canonical model entries. + +**Architecture:** Update `normalizeModel()` to read all OmniRoute field variants (snake_case, camelCase, capabilities object), then add a generic deduplication step that groups by canonical provider+model key and prefers canonical-prefixed IDs. Normalization happens before models.dev enrichment so no metadata is lost. + +**Tech Stack:** TypeScript ESM, Node.js native test runner + +--- + +## File Structure + +**Modified files:** +- `src/types.ts` — Add `capabilities` and snake_case fields to `OmniRouteModel` +- `src/models.ts` — Update `normalizeModel()`, add `deduplicateModels()`, wire into `fetchModels()` +- `src/constants.ts` — Add provider alias-to-canonical mapping +- `test/models.test.mjs` — Tests for normalization and deduplication + +--- + +## Chunk 1: Extend Types for OmniRoute Native Fields + +### Task 1: Add OmniRoute Native Fields to OmniRouteModel + +**Files:** +- Modify: `src/types.ts:25-50` (OmniRouteModel interface) + +**Issue:** Current `OmniRouteModel` only has camelCase fields, misses OmniRoute's snake_case and `capabilities` object. + +- [ ] **Step 1: Update OmniRouteModel interface** + +```typescript +export interface OmniRouteModel { + id: string; + name: string; + description?: string; + + // OmniRoute native fields (camelCase from API) + contextWindow?: number; + maxTokens?: number; + supportsStreaming?: boolean; + supportsVision?: boolean; + supportsTools?: boolean; + supportsTemperature?: boolean; + supportsReasoning?: boolean; + supportsAttachment?: boolean; + + // OmniRoute native fields (snake_case from API) + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + + // OmniRoute capabilities object + capabilities?: { + vision?: boolean; + tool_calling?: boolean; + reasoning?: boolean; + thinking?: boolean; + attachment?: boolean; + temperature?: boolean; + }; + + // Enriched fields from models.dev + temperature?: boolean; + reasoning?: boolean; + attachment?: boolean; + tool_call?: boolean; +} +``` + +- [ ] **Step 2: Build to verify no type errors** + +Run: `npm run build` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "types: add OmniRoute native fields (snake_case, capabilities) to OmniRouteModel" +``` + +--- + +## Chunk 2: Update normalizeModel to Read All Field Variants + +### Task 2: Comprehensive Model Normalization + +**Files:** +- Modify: `src/models.ts:123-144` (normalizeModel in fetchModels) + +**Issue:** Current normalization only reads camelCase fields, ignores snake_case and capabilities object. + +- [ ] **Step 1: Extract normalizeModel as standalone function** + +Add before `fetchModels`: + +```typescript +function normalizeModel(model: OmniRouteModel): OmniRouteModel { + const capabilities = model.capabilities && typeof model.capabilities === 'object' + ? model.capabilities + : {}; + + return { + ...model, + id: model.id, + name: model.name || model.id, + description: model.description || `OmniRoute model: ${model.id}`, + + // Context limits: prefer explicit camelCase, fallback to snake_case + contextWindow: model.contextWindow ?? model.context_length ?? model.max_input_tokens, + maxTokens: model.maxTokens ?? model.max_output_tokens, + + // Capabilities: prefer explicit camelCase, fallback to capabilities object, fallback to snake_case + supportsStreaming: model.supportsStreaming, + supportsVision: model.supportsVision ?? model.vision ?? capabilities.vision ?? capabilities.attachment, + supportsTools: model.supportsTools ?? model.tool_calling ?? capabilities.tool_calling ?? capabilities.toolcall, + supportsReasoning: model.supportsReasoning ?? model.reasoning ?? capabilities.reasoning ?? capabilities.thinking, + supportsAttachment: model.supportsAttachment ?? model.attachment ?? capabilities.attachment, + supportsTemperature: model.supportsTemperature ?? model.temperature ?? capabilities.temperature, + }; +} +``` + +- [ ] **Step 2: Update fetchModels to use normalizeModel** + +Replace the inline map with: +```typescript +.map(normalizeModel) +``` + +- [ ] **Step 3: Build and test** + +Run: `npm run build` +Expected: No errors + +Run: `npm test` +Expected: All existing tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/models.ts src/types.ts +git commit -m "feat: normalize all OmniRoute field variants (snake_case, capabilities)" +``` + +--- + +## Chunk 3: Generic Model Deduplication + +### Task 3: Create Provider Alias Map + +**Files:** +- Modify: `src/constants.ts` + +**Issue:** Need to know which provider prefixes are aliases so we can deduplicate. + +- [ ] **Step 1: Add provider alias-to-canonical mapping** + +```typescript +export const PROVIDER_ALIAS_TO_CANONICAL: Record = { + 'ollamacloud': 'ollama-cloud', + 'cc': 'claude', + 'gh': 'github', + 'cx': 'codex', + 'kr': 'kiro', + 'if': 'qoder', +}; +``` + +- [ ] **Step 2: Build** + +Run: `npm run build` +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +git add src/constants.ts +git commit -m "feat: add provider alias-to-canonical mapping for deduplication" +``` + +--- + +### Task 4: Implement Generic Deduplication + +**Files:** +- Modify: `src/models.ts` — Add `deduplicateModels()` function + +**Issue:** Alias-prefixed models appear alongside canonical-prefixed ones. + +- [ ] **Step 1: Add deduplicateModels function** + +Add after `normalizeModel`: + +```typescript +function deduplicateModels(models: OmniRouteModel[]): OmniRouteModel[] { + const seen = new Map(); + + for (const model of models) { + const parts = model.id.split('/'); + if (parts.length !== 2) { + // Not a provider/model ID, keep as-is + seen.set(model.id, model); + continue; + } + + const [providerPrefix, modelKey] = parts; + const canonicalPrefix = PROVIDER_ALIAS_TO_CANONICAL[providerPrefix] || providerPrefix; + const canonicalId = `${canonicalPrefix}/${modelKey}`; + + const existing = seen.get(canonicalId); + if (!existing) { + // First time seeing this model - store with canonical ID + seen.set(canonicalId, { + ...model, + id: canonicalId, + }); + } else { + // Already have canonical version - merge metadata, prefer non-alias + const isAlias = providerPrefix !== canonicalPrefix; + if (!isAlias) { + // This is the canonical version, overwrite alias + seen.set(canonicalId, { + ...model, + id: canonicalId, + }); + } + // If alias and we already have canonical, drop it + } + } + + return [...seen.values()]; +} +``` + +- [ ] **Step 2: Wire deduplication into fetchModels** + +After normalization, before enrichment: +```typescript +const rawModels = data.data + .filter(...) + .map(normalizeModel); + +const dedupedModels = deduplicateModels(rawModels); + +// Enrich with models.dev and combo capabilities +const models = await enrichModelMetadata(dedupedModels, config); +``` + +- [ ] **Step 3: Build and test** + +Run: `npm run build` +Expected: No errors + +Run: `npm test` +Expected: All existing tests pass + +- [ ] **Step 4: Commit** + +```bash +git add src/models.ts src/constants.ts +git commit -m "feat: deduplicate alias/canonical model entries, prefer canonical form" +``` + +--- + +## Chunk 4: Test Coverage + +### Task 5: Test Normalization of All Field Variants + +**Files:** +- Modify: `test/models.test.mjs` + +- [ ] **Step 1: Add test for snake_case field normalization** + +```javascript +test('normalizeModel reads snake_case fields', () => { + // We can't test normalizeModel directly (it's private), so test via fetchModels + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'test/model-1', + name: 'Test Model', + context_length: 128000, + max_output_tokens: 4096, + capabilities: { + vision: true, + tool_calling: true, + reasoning: true, + } + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + const model = models.find(m => m.id === 'test/model-1'); + + assert.ok(model, 'Model should be found'); + assert.equal(model.contextWindow, 128000, 'Should read context_length'); + assert.equal(model.maxTokens, 4096, 'Should read max_output_tokens'); + assert.equal(model.supportsVision, true, 'Should read capabilities.vision'); + assert.equal(model.supportsTools, true, 'Should read capabilities.tool_calling'); + assert.equal(model.supportsReasoning, true, 'Should read capabilities.reasoning'); +}); +``` + +- [ ] **Step 2: Add test for camelCase fallback** + +```javascript +test('normalizeModel prefers camelCase over snake_case', () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'test/model-2', + contextWindow: 64000, + context_length: 32000, + capabilities: { + vision: false, + } + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + const model = models.find(m => m.id === 'test/model-2'); + + assert.ok(model, 'Model should be found'); + assert.equal(model.contextWindow, 64000, 'Should prefer camelCase over snake_case'); +}); +``` + +- [ ] **Step 3: Commit** + +```bash +git add test/models.test.mjs +git commit -m "test: verify normalization of snake_case and capabilities fields" +``` + +--- + +### Task 6: Test Deduplication + +**Files:** +- Modify: `test/models.test.mjs` + +- [ ] **Step 1: Add test for alias deduplication** + +```javascript +test('deduplication removes alias when canonical exists', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'ollamacloud/deepseek-v4', + name: 'DeepSeek V4 (alias)', + context_length: 64000, + }, + { + id: 'ollama-cloud/deepseek-v4', + name: 'DeepSeek V4 (canonical)', + context_length: 128000, + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + + assert.equal(models.length, 1, 'Should deduplicate to single model'); + assert.equal(models[0].id, 'ollama-cloud/deepseek-v4', 'Should prefer canonical ID'); + assert.equal(models[0].contextWindow, 128000, 'Should use canonical metadata'); +}); +``` + +- [ ] **Step 2: Add test for dedupe with only alias present** + +```javascript +test('deduplication keeps alias when canonical is missing', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'ollamacloud/deepseek-v4', + name: 'DeepSeek V4', + context_length: 64000, + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + + assert.equal(models.length, 1, 'Should keep single model'); + assert.equal(models[0].id, 'ollama-cloud/deepseek-v4', 'Should normalize to canonical ID'); +}); +``` + +- [ ] **Step 3: Run all tests** + +Run: `npm test` +Expected: All tests pass + +- [ ] **Step 4: Commit** + +```bash +git add test/models.test.mjs +git commit -m "test: verify deduplication of alias/canonical model entries" +``` + +--- + +## Chunk 5: Final Verification + +### Task 7: Full Build and Test + +- [ ] **Step 1: Clean build** + +Run: `npm run clean && npm run build` +Expected: Success + +- [ ] **Step 2: Run all tests** + +Run: `npm test` +Expected: All tests pass (target: 42+ tests) + +- [ ] **Step 3: Type check** + +Run: `npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 4: Verify no regressions** + +Check that existing tests still pass and no functionality was broken. + +- [ ] **Step 5: Final commit summary** + +```bash +git log --oneline -25 +``` + +--- + +## Summary + +**Total tasks:** 7 (5 code + 2 test + 1 verification) +**Estimated commits:** 7 +**Estimated time:** 1-2 hours + +### Changes Summary: +1. **Types** — Added snake_case fields and capabilities object to OmniRouteModel +2. **Normalization** — normalizeModel now reads all field variants with proper precedence +3. **Constants** — Added PROVIDER_ALIAS_TO_CANONICAL mapping +4. **Deduplication** — Generic dedupe algorithm that prefers canonical IDs +5. **Tests** — Coverage for normalization and deduplication edge cases + +### Key Decisions: +- camelCase fields take precedence over snake_case (consistent with existing code) +- capabilities object takes precedence over top-level snake_case fields +- Deduplication normalizes alias IDs to canonical form even when canonical is missing +- Merge strategy: prefer canonical metadata over alias metadata diff --git a/package.json b/package.json index 9946edf..cbbe4f1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-omniroute-auth", - "version": "1.1.4", + "version": "1.2.0", "description": "OpenCode authentication plugin for OmniRoute API with /connect command and dynamic model fetching", "type": "module", "main": "./dist/index.js", diff --git a/src/constants.ts b/src/constants.ts index 04e5e7c..bf8c45d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -73,9 +73,27 @@ export const MODEL_CACHE_TTL = 5 * 60 * 1000; */ export const REQUEST_TIMEOUT = 30000; +/** + * Default model limits + */ +export const DEFAULT_CONTEXT_LIMIT = 4096; +export const DEFAULT_OUTPUT_LIMIT = 4096; + /** * models.dev enrichment defaults */ export const MODELS_DEV_DEFAULT_URL = 'https://models.dev/api.json'; export const MODELS_DEV_CACHE_TTL = 24 * 60 * 60 * 1000; export const MODELS_DEV_TIMEOUT_MS = 1000; + +/** + * Provider alias-to-canonical mapping for deduplication + */ +export const PROVIDER_ALIAS_TO_CANONICAL: Record = { + ollamacloud: 'ollama-cloud', + cc: 'claude', + gh: 'github', + cx: 'codex', + kr: 'kiro', + if: 'qoder', +}; diff --git a/src/logger.ts b/src/logger.ts index 7ece6e6..ba87fb2 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,5 @@ -import { appendFileSync, readdirSync, statSync, existsSync } from 'fs'; +import { readdirSync, statSync, existsSync } from 'fs'; +import { appendFile } from 'fs/promises'; import { join } from 'path'; import { homedir } from 'os'; @@ -32,62 +33,34 @@ function findCurrentLogFile(): string | null { let cachedLogFile: string | null = findCurrentLogFile(); function getLogFile(): string | null { - if (cachedLogFile === null) { - // Re-scan if no file found at module load (OpenCode may create one later) + if (cachedLogFile === null || !existsSync(cachedLogFile)) { + // Re-scan if no file found at module load or if cached file was deleted (log rotation) 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; - } - +function formatLogLine(level: string, message: string): string { 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 - } + return `${level.padEnd(5)} ${timestamp} +0ms service=omniroute ${message}\n`; } export function warn(message: string): void { - writeLog('WARN', message); + const logFile = getLogFile(); + if (!logFile) return; + + const line = formatLogLine('WARN', message); + // Fire-and-forget: don't await, don't crash on error + appendFile(logFile, line).catch(() => {}); } export function debug(message: string): void { // Strict comparison: only "1" enables debug logging if (process.env.OMNIROUTE_DEBUG !== '1') return; - writeLog('DEBUG', message); + + const logFile = getLogFile(); + if (!logFile) return; + + const line = formatLogLine('DEBUG', message); + appendFile(logFile, line).catch(() => {}); } diff --git a/src/models-dev.ts b/src/models-dev.ts index 1f3c983..db022fb 100644 --- a/src/models-dev.ts +++ b/src/models-dev.ts @@ -328,6 +328,9 @@ export function calculateLowestCommonCapabilities( allSupportVision = allSupportVision && supportsVision; // Tools: all must support it + // For combos: only advertise tools if ALL underlying models explicitly support them + // This is intentionally stricter than single-model defaults because a combo + // with one tool-less model cannot reliably use tools across all backends const supportsTools = model.tool_call === true; allSupportTools = allSupportTools && supportsTools; @@ -478,3 +481,4 @@ export function stripVariantSuffix(modelKey: string): { base: string; stripped: } return { base: modelKey, stripped: false }; } + diff --git a/src/models.ts b/src/models.ts index 4c0759e..cb11187 100644 --- a/src/models.ts +++ b/src/models.ts @@ -4,6 +4,7 @@ import { OMNIROUTE_ENDPOINTS, MODEL_CACHE_TTL, REQUEST_TIMEOUT, + PROVIDER_ALIAS_TO_CANONICAL, } from './constants.js'; import { getModelsDevIndex, @@ -48,6 +49,124 @@ function getCacheKey(config: OmniRouteConfig, apiKey: string): string { return `${baseUrl}:${apiKey}:${modelsDevHash}`; } +/** + * Normalize an OmniRoute model by reading all field variants + * with proper precedence: camelCase > snake_case > capabilities + */ +function normalizeModel(model: OmniRouteModel): OmniRouteModel { + const capabilities = + model.capabilities && typeof model.capabilities === 'object' + ? model.capabilities + : {}; + + return { + ...model, + id: model.id, + name: model.name || model.id, + description: model.description || `OmniRoute model: ${model.id}`, + + // Context limits: prefer explicit camelCase, fallback to snake_case + contextWindow: + model.contextWindow ?? model.context_length ?? model.max_input_tokens, + maxTokens: model.maxTokens ?? model.max_output_tokens, + + // Capabilities: prefer explicit camelCase, fallback to capabilities object, fallback to snake_case + supportsStreaming: model.supportsStreaming, + supportsVision: + model.supportsVision ?? + model.vision ?? + capabilities.vision ?? + capabilities.attachment, + supportsTools: + model.supportsTools ?? + model.tool_calling ?? + capabilities.tool_calling ?? + capabilities.toolcall, + supportsReasoning: + model.supportsReasoning ?? + model.reasoning ?? + capabilities.reasoning ?? + capabilities.thinking, + supportsAttachment: + model.supportsAttachment ?? + model.attachment ?? + capabilities.attachment, + supportsTemperature: + model.supportsTemperature ?? + model.temperature ?? + capabilities.temperature, + }; +} + +/** + * Deduplicate models by canonical provider+model key. + * Prefers canonical-prefixed IDs over aliases. + * + * NOTE: Only deduplicates known aliases (PROVIDER_ALIAS_TO_CANONICAL). + * Unknown provider prefixes are kept as-is to preserve user metadata. + */ +function deduplicateModels(models: OmniRouteModel[]): OmniRouteModel[] { + const seen = new Map(); + + for (const model of models) { + const parts = model.id.split('/'); + if (parts.length !== 2) { + // Not a provider/model ID, keep as-is + seen.set(model.id, model); + continue; + } + + const [providerPrefix, modelKey] = parts; + const canonicalPrefix = PROVIDER_ALIAS_TO_CANONICAL[providerPrefix]; + + // Only deduplicate known aliases; preserve unknown prefixes as-is + if (!canonicalPrefix) { + // Merge metadata if same unknown prefix seen again + const existing = seen.get(model.id); + seen.set(model.id, existing ? { ...existing, ...model } : model); + continue; + } + + const canonicalId = `${canonicalPrefix}/${modelKey}`; + + const existing = seen.get(canonicalId); + if (!existing) { + // First time seeing this model - store with canonical ID + seen.set(canonicalId, { + ...model, + id: canonicalId, + }); + } else { + // Merge alias metadata into existing, preferring existing (canonical) fields + seen.set(canonicalId, { ...model, ...existing, id: canonicalId }); + } + } + + return [...seen.values()]; +} + +/** + * Reverse a provider alias to its canonical form for metadata lookups. + * Returns the original id if no alias mapping exists. + */ +export function resolveProviderAliasForMetadata(modelId: string): string { + const parts = modelId.split('/'); + if (parts.length !== 2) return modelId; + + const [providerPrefix, modelKey] = parts; + const canonicalPrefix = PROVIDER_ALIAS_TO_CANONICAL[providerPrefix]; + if (!canonicalPrefix) return modelId; + + return `${canonicalPrefix}/${modelKey}`; +} + +/** + * Check if a provider prefix is a known alias. + */ +export function isProviderAlias(providerPrefix: string): boolean { + return providerPrefix in PROVIDER_ALIAS_TO_CANONICAL; +} + /** * Fetch models from OmniRoute /v1/models endpoint * This is the CRITICAL FEATURE - dynamically fetches available models @@ -109,7 +228,12 @@ export async function fetchModels( // Runtime validation to ensure API returns expected structure if (!rawData || typeof rawData !== 'object' || !Array.isArray(rawData.data)) { - warn(`Invalid models response structure: ${JSON.stringify(rawData)}`); + const dataType = rawData && typeof rawData === 'object' + ? (rawData.data === null + ? 'null' + : Array.isArray(rawData.data) ? 'array' : typeof rawData.data) + : typeof rawData; + warn(`Invalid models response structure: expected { data: Array }, got { data: ${dataType} }`); throw new Error('Invalid models response structure: expected { data: Array }'); } @@ -121,25 +245,12 @@ export async function fetchModels( (model): model is OmniRouteModel => model !== null && model !== undefined && typeof model.id === 'string', ) - .map((model) => ({ - ...model, - // Ensure required fields - id: model.id, - name: model.name || model.id, - description: model.description || `OmniRoute model: ${model.id}`, - // Keep undefined for enrichment to work properly - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - supportsStreaming: model.supportsStreaming, - supportsVision: model.supportsVision, - supportsTools: model.supportsTools, - supportsTemperature: model.supportsTemperature, - supportsReasoning: model.supportsReasoning, - supportsAttachment: model.supportsAttachment, - })); + .map(normalizeModel); + + const dedupedModels = deduplicateModels(rawModels); // Enrich with models.dev and combo capabilities - const models = await enrichModelMetadata(rawModels, config); + const models = await enrichModelMetadata(dedupedModels, config); // Update cache modelCache.set(cacheKey, { @@ -299,11 +410,20 @@ function applyModelsDevMetadata( function getModelLookupCandidates(modelKey: string): string[] { const candidates = new Set(); + const addCandidate = (key: string): void => { - candidates.add(key.toLowerCase()); - candidates.add(resolveModelAlias(key).toLowerCase()); - candidates.add(normalizeModelKey(key)); - candidates.add(normalizeModelKey(resolveModelAlias(key))); + const lower = key.toLowerCase(); + const normalized = normalizeModelKey(key); + const aliasResolved = resolveModelAlias(key); + + candidates.add(lower); + candidates.add(normalized); + + // Only add alias variants if they differ from original + if (aliasResolved !== key) { + candidates.add(aliasResolved.toLowerCase()); + candidates.add(normalizeModelKey(aliasResolved)); + } }; addCandidate(modelKey); diff --git a/src/omniroute-combos.ts b/src/omniroute-combos.ts index ccc6b3d..95e5bab 100644 --- a/src/omniroute-combos.ts +++ b/src/omniroute-combos.ts @@ -9,6 +9,11 @@ import { import { REQUEST_TIMEOUT } from './constants.js'; import { warn, debug } from './logger.js'; +export function sanitizeForLog(value: string): string { + // Remove all control characters except tab (0x09) + return value.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); +} + /** * OmniRoute combo definition from /api/combos */ @@ -139,7 +144,7 @@ export async function resolveUnderlyingModels( // Check if this is a combo model const combo = combos.get(modelId); if (combo) { - debug(`Resolved combo "${modelId}" to ${combo.models.length} underlying models`); + debug(`Resolved combo "${sanitizeForLog(modelId)}" to ${combo.models.length} underlying models`); return combo.models .map((m) => { if (typeof m === 'string') return m; @@ -274,7 +279,7 @@ export async function calculateModelCapabilities( } // It's a combo - lookup all underlying models - debug(`Calculating capabilities for combo "${model.id}" from ${underlyingModels.length} models`); + debug(`Calculating capabilities for combo "${sanitizeForLog(model.id)}" from ${underlyingModels.length} models`); const resolvedModels: ModelsDevModel[] = []; const unresolvedModels: string[] = []; @@ -290,22 +295,22 @@ export async function calculateModelCapabilities( if (unresolvedModels.length > 0) { warn( - `Could not resolve ${unresolvedModels.length} underlying models for "${model.id}": ${unresolvedModels.join(', ')}`, + `Could not resolve ${unresolvedModels.length} underlying models for "${sanitizeForLog(model.id)}": ${unresolvedModels.map(sanitizeForLog).join(', ')}`, ); } if (resolvedModels.length === 0) { - warn(`No models.dev matches found for combo "${model.id}"`); + warn(`No models.dev matches found for combo "${sanitizeForLog(model.id)}"`); return {}; } - debug(`Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${model.id}"`); + debug(`Resolved ${resolvedModels.length}/${underlyingModels.length} underlying models for "${sanitizeForLog(model.id)}"`); // Calculate lowest common capabilities const capabilities = calculateLowestCommonCapabilities(resolvedModels); debug( - `Calculated capabilities for "${model.id}": context=${capabilities.contextWindow ?? 'N/A'}, maxTokens=${capabilities.maxTokens ?? 'N/A'}, vision=${capabilities.supportsVision ?? false}, tools=${capabilities.supportsTools ?? false}`, + `Calculated capabilities for "${sanitizeForLog(model.id)}": context=${capabilities.contextWindow ?? 'N/A'}, maxTokens=${capabilities.maxTokens ?? 'N/A'}, vision=${capabilities.supportsVision ?? false}, tools=${capabilities.supportsTools ?? false}`, ); return capabilities; @@ -353,7 +358,7 @@ export async function enrichComboModels( return model; } - debug(`Enriching combo model: ${model.id}`); + debug(`Enriching combo model: ${sanitizeForLog(model.id)}`); // Calculate capabilities for this combo const capabilities = await calculateModelCapabilities(model, config, modelsDevIndex); diff --git a/src/plugin.ts b/src/plugin.ts index c709fa3..142193f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,14 +10,18 @@ import type { OmniRouteModelMetadataConfig, OmniRouteModelsDevConfig, OmniRouteProviderModel, + OmniRouteModelVariant, } from './types.js'; import { OMNIROUTE_PROVIDER_ID, OMNIROUTE_DEFAULT_MODELS, OMNIROUTE_ENDPOINTS, + DEFAULT_CONTEXT_LIMIT, + DEFAULT_OUTPUT_LIMIT, } from './constants.js'; -import { fetchModels } from './models.js'; +import { fetchModels, resolveProviderAliasForMetadata, isProviderAlias } from './models.js'; import { warn, debug } from './logger.js'; +import { sanitizeForLog } from './omniroute-combos.js'; const OMNIROUTE_PROVIDER_NAME = 'OmniRoute'; const OMNIROUTE_PROVIDER_NPM = '@ai-sdk/openai-compatible'; @@ -53,7 +57,9 @@ export const OmniRouteAuthPlugin: Plugin = async (_input) => { const generatedModelMetadata: Record = {}; for (const model of models) { - generatedModelMetadata[model.id] = { + // Use canonical ID for metadata keys to match user config + const metadataKey = resolveProviderAliasForMetadata(model.id); + generatedModelMetadata[metadataKey] = { contextWindow: model.contextWindow, maxTokens: model.maxTokens, supportsTemperature: model.supportsTemperature, @@ -143,7 +149,7 @@ async function loadProviderOptions( try { const forceRefresh = config.refreshOnList !== false; models = await fetchModels(config, config.apiKey, forceRefresh); - debug(`Available models: ${models.map((model) => model.id).join(', ')}`); + debug(`Available models: ${models.map((model) => sanitizeForLog(model.id)).join(', ')}`); } catch (error) { warn(`Failed to fetch models, using defaults: ${error}`); models = OMNIROUTE_DEFAULT_MODELS; @@ -206,13 +212,13 @@ async function readAuthFromStore( function resolveProviderApi(api: unknown, apiMode: OmniRouteApiMode): OmniRouteApiMode { if (isApiMode(api)) { if (api !== apiMode) { - warn(`provider.api (${api}) and options.apiMode (${apiMode}) differ; using options.apiMode`); + warn(`provider.api (${sanitizeForLog(String(api))}) and options.apiMode (${sanitizeForLog(apiMode)}) differ; using options.apiMode`); } return apiMode; } if (typeof api === 'string') { - warn(`Unsupported provider.api value: ${api}. Using ${apiMode}.`); + warn(`Unsupported provider.api value: ${sanitizeForLog(String(api))}. Using ${sanitizeForLog(apiMode)}.`); } return apiMode; @@ -228,7 +234,7 @@ function getApiMode(options?: Record): OmniRouteApiMode { return value; } - warn(`Unsupported apiMode option: ${String(value)}. Using chat.`); + warn(`Unsupported apiMode option: ${sanitizeForLog(String(value))}. Using chat.`); return 'chat'; } @@ -250,13 +256,13 @@ function getBaseUrl(options?: Record): string { try { const parsed = new URL(trimmed); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { - warn(`Ignoring unsupported baseURL protocol: ${parsed.protocol}`); + warn(`Ignoring unsupported baseURL protocol: ${sanitizeForLog(parsed.protocol)}`); return OMNIROUTE_ENDPOINTS.BASE_URL; } return trimmed; } catch { - warn(`Ignoring invalid baseURL: ${trimmed}`); + warn(`Ignoring invalid baseURL: ${sanitizeForLog(trimmed)}`); return OMNIROUTE_ENDPOINTS.BASE_URL; } } @@ -355,12 +361,23 @@ function mergeModelMetadata( const userConfig = getModelMetadataConfig({ modelMetadata: rawUserConfig }); if (Array.isArray(userConfig)) { + // Validate user-provided metadata blocks to prevent issues in OpenCode framework + const validUserConfig = userConfig.filter((block) => { + const validation = isValidModelMetadata(block); + if (!validation.valid) { + warn(`Invalid metadata block for match "${sanitizeForLog(String(block.match))}" (field: ${sanitizeForLog(validation.field ?? '')}), skipping`); + return false; + } + return true; + }); + const generatedBlocks = Object.entries(generated).map(([id, metadata]) => ({ match: id, ...metadata, })); - return [...generatedBlocks, ...userConfig]; + // User config comes first so it takes precedence in first-match-wins systems + return [...validUserConfig, ...generatedBlocks]; } if (userConfig && isRecord(userConfig)) { @@ -368,11 +385,14 @@ function mergeModelMetadata( for (const [id, metadata] of Object.entries(userConfig)) { const validation = isValidModelMetadata(metadata); if (!validation.valid) { - warn(`Invalid metadata for model "${id}" (field: ${validation.field}), skipping`); + warn(`Invalid metadata for model "${sanitizeForLog(id)}" (field: ${sanitizeForLog(validation.field ?? '')}), skipping`); continue; } - merged[id] = { - ...(generated[id] ?? {}), + // If user uses an alias key (e.g., 'cx/gpt-5.5'), merge into canonical key + // so it matches the generated metadata and deduplicated model IDs + const canonicalId = resolveProviderAliasForMetadata(id); + merged[canonicalId] = { + ...(merged[canonicalId] ?? {}), ...metadata, }; } @@ -475,6 +495,8 @@ function toProviderModels( function toProviderModel(model: OmniRouteModel, baseUrl: string): OmniRouteProviderModel { const supportsVision = model.supportsVision === true; + // Default to true: if API doesn't explicitly say no tools, assume capability exists + // This aligns with OpenAI-compatible behavior where most models support tools const supportsTools = model.supportsTools !== false; const supportsTemperature = model.supportsTemperature !== false; const supportsReasoning = model.supportsReasoning === true; @@ -491,8 +513,8 @@ function toProviderModel(model: OmniRouteModel, baseUrl: string): OmniRouteProvi temperature: supportsTemperature, tool_call: supportsTools, modalities: { - input: supportsVision ? ['text', 'image'] as const : ['text'] as const, - output: ['text'] as const, + input: supportsVision ? ['text', 'image'] : ['text'], + output: ['text'], }, api: { id: model.id, @@ -529,8 +551,8 @@ function toProviderModel(model: OmniRouteModel, baseUrl: string): OmniRouteProvi }, }, limit: { - context: model.contextWindow ?? 4096, - output: model.maxTokens ?? 4096, + context: model.contextWindow ?? DEFAULT_CONTEXT_LIMIT, + output: model.maxTokens ?? DEFAULT_OUTPUT_LIMIT, }, options: {}, headers: {}, @@ -575,7 +597,7 @@ function createFetchInterceptor( return fetch(input, init); } - debug(`Intercepting request to ${url}`); + debug(`Intercepting request to ${sanitizeForLog(url)}`); // Merge headers from Request and init to avoid dropping existing headers const headers = new Headers(input instanceof Request ? input.headers : undefined); diff --git a/src/types.ts b/src/types.ts index 88022e9..903f95c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ export interface OmniRouteModel { id: string; name: string; description?: string; + + // OmniRoute native fields (camelCase from API) contextWindow?: number; maxTokens?: number; supportsStreaming?: boolean; @@ -13,6 +15,31 @@ export interface OmniRouteModel { supportsTemperature?: boolean; supportsReasoning?: boolean; supportsAttachment?: boolean; + + // OmniRoute native fields (snake_case from API) + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + vision?: boolean; + tool_calling?: boolean; + + // OmniRoute capabilities object + capabilities?: { + vision?: boolean; + tool_calling?: boolean; + reasoning?: boolean; + thinking?: boolean; + attachment?: boolean; + temperature?: boolean; + toolcall?: boolean; + }; + + // Enriched fields from models.dev + temperature?: boolean; + reasoning?: boolean; + attachment?: boolean; + tool_call?: boolean; + pricing?: { input?: number; output?: number; @@ -151,7 +178,15 @@ export interface OmniRouteProviderModel { options: Record; headers: Record; status: 'active'; - variants: Record; + variants: Record; +} + +/** + * Model variant configuration + */ +export interface OmniRouteModelVariant { + reasoningEffort?: 'low' | 'medium' | 'high'; + [key: string]: unknown; } /** diff --git a/test/logger.test.mjs b/test/logger.test.mjs index a7e123c..0ba0ee5 100644 --- a/test/logger.test.mjs +++ b/test/logger.test.mjs @@ -59,6 +59,9 @@ test('warn() writes to log file with correct format', async () => { warn('Test warning message'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + 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'); @@ -82,6 +85,9 @@ test('debug() writes when OMNIROUTE_DEBUG=1', async () => { debug('Test debug message'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + 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'); @@ -142,6 +148,9 @@ test('warn() always writes regardless of OMNIROUTE_DEBUG', async () => { warn('Test warning message'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + const content = readFileSync(testLogFile, 'utf-8'); assert.ok(content.includes('Test warning message'), 'warn should always write'); @@ -168,12 +177,18 @@ test('logger handles log file rotation', async () => { const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); warn('First message'); + // Wait for first async write to complete + await new Promise(resolve => setTimeout(resolve, 50)); + // Simulate log rotation: delete old file, create new one rmSync(oldLogFile); const newLogFile = createTestLogFile('test-new.log'); warn('Second message after rotation'); + // Wait for second async write to complete + await new Promise(resolve => setTimeout(resolve, 50)); + const content = readFileSync(newLogFile, 'utf-8'); assert.ok( content.includes('Second message after rotation'), @@ -195,6 +210,9 @@ test('logger re-scans when no log file exists at module load', async () => { warn('Message after log file created'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + const content = readFileSync(testLogFile, 'utf-8'); assert.ok(content.includes('Message after log file created'), 'should re-scan and write to new log file'); @@ -261,6 +279,9 @@ test('logger excludes directories with .log suffix', async () => { const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); warn('Test directory exclusion'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + const content = readFileSync(testLogFile, 'utf-8'); assert.ok(content.includes('Test directory exclusion'), 'should write to real log file, not directory'); @@ -282,6 +303,9 @@ test('logger uses alphabetical tie-breaker for identical mtime', async () => { const { warn } = await import(`../dist/src/logger.js#${Date.now()}-${Math.random()}`); warn('Test tie-breaker'); + // Wait for async appendFile to complete + await new Promise(resolve => setTimeout(resolve, 50)); + // Should write to test-alpha.log (alphabetically first) const contentA = readFileSync(fileA, 'utf-8'); const contentB = readFileSync(fileB, 'utf-8'); diff --git a/test/models.test.mjs b/test/models.test.mjs index 81b80aa..8036659 100644 --- a/test/models.test.mjs +++ b/test/models.test.mjs @@ -144,3 +144,238 @@ test('fetchModels uses different cache for different modelsDev configs', async ( assert.equal(calls, 2, 'Should fetch twice for different modelsDev configs'); }); + +// Task 15: Single Model Metadata Tracking +test('calculateLowestCommonCapabilities produces identical output for single model and combo-with-self', () => { + const single = calculateLowestCommonCapabilities([ + { id: 'test-model', temperature: true, reasoning: true, attachment: true }, + ]); + + const combo = calculateLowestCommonCapabilities([ + { id: 'test-model', temperature: true, reasoning: true, attachment: true }, + { id: 'test-model', temperature: true, reasoning: true, attachment: true }, + ]); + + // Core capability fields should match (supportsStreaming differs because + // single-model path uses modelsDevToMetadata which doesn't add streaming, + // while combo path always adds it) + assert.equal(single.supportsTemperature, combo.supportsTemperature); + assert.equal(single.supportsReasoning, combo.supportsReasoning); + assert.equal(single.supportsAttachment, combo.supportsAttachment); +}); + +// Task 17: Temperature/Reasoning Combo Tests +test('calculateLowestCommonCapabilities handles mixed defined and undefined temperature', () => { + const capabilities = calculateLowestCommonCapabilities([ + { id: 'with-temp', temperature: true }, + { id: 'without-temp' }, + ]); + + assert.equal(capabilities.supportsTemperature, true); +}); + +test('calculateLowestCommonCapabilities handles explicit temperature false overriding true', () => { + const capabilities = calculateLowestCommonCapabilities([ + { id: 'with-temp', temperature: true }, + { id: 'without-temp', temperature: false }, + ]); + + assert.equal(capabilities.supportsTemperature, false); +}); + +test('calculateLowestCommonCapabilities handles all three capabilities together', () => { + const capabilities = calculateLowestCommonCapabilities([ + { id: 'full-support', temperature: true, reasoning: true, attachment: true }, + { id: 'partial-support', temperature: true, reasoning: false, attachment: true }, + ]); + + assert.equal(capabilities.supportsTemperature, true); + assert.equal(capabilities.supportsReasoning, false); + assert.equal(capabilities.supportsAttachment, true); +}); + +test('calculateLowestCommonCapabilities handles single model with undefined temperature', () => { + const capabilities = calculateLowestCommonCapabilities([ + { id: 'no-temp-metadata', reasoning: true }, + ]); + + assert.equal(capabilities.supportsTemperature, undefined); + assert.equal(capabilities.supportsReasoning, true); +}); + +// Task 18: Variant+Alias Integration Test +test('variant suffix stripping works with alias resolution end-to-end', async () => { + const { stripVariantSuffix, resolveModelAlias, normalizeModelKey } = await import('../dist/src/models-dev.js'); + + // Test variant suffix stripping + const { base: base1, stripped: stripped1 } = stripVariantSuffix('gpt-4o-high'); + assert.equal(base1, 'gpt-4o'); + assert.equal(stripped1, true); + + const { base: base2, stripped: stripped2 } = stripVariantSuffix('claude-3-sonnet-low'); + assert.equal(base2, 'claude-3-sonnet'); + assert.equal(stripped2, true); + + const { base: base3, stripped: stripped3 } = stripVariantSuffix('gpt-4o'); + assert.equal(base3, 'gpt-4o'); + assert.equal(stripped3, false); + + // Test alias resolution on base name (variant suffix stripped first) + const alias1 = resolveModelAlias('kimi-k2.6-thinking'); + assert.equal(alias1, 'kimi-k2-thinking'); + + const alias2 = resolveModelAlias('kimi-k2.6-thinking-turbo'); + assert.equal(alias2, 'kimi-k2-thinking-turbo'); + + // Test normalization removes preview suffix (variant is stripped separately) + const normalized = normalizeModelKey('gpt-4o-preview'); + assert.equal(normalized, 'gpt-4o'); +}); + +// Task 19: Subscription Fallback Test +test('subscription provider fallback enriches from public provider', async () => { + const { getSubscriptionFallback } = await import('../dist/src/models-dev.js'); + + // Test known subscription fallbacks + assert.equal(getSubscriptionFallback('zai-coding-plan'), 'zai'); + assert.equal(getSubscriptionFallback('kimi-for-coding'), 'moonshotai'); + assert.equal(getSubscriptionFallback('github-models'), 'google'); + + // Test case insensitivity + assert.equal(getSubscriptionFallback('ZAI-CODING-PLAN'), 'zai'); + assert.equal(getSubscriptionFallback('GitHub-Models'), 'google'); + + // Test unknown provider returns null + assert.equal(getSubscriptionFallback('unknown-provider'), null); +}); + +// Task 21: Normalization of snake_case and capabilities fields +test('normalizeModel reads snake_case fields', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'test/model-1', + name: 'Test Model', + context_length: 128000, + max_output_tokens: 4096, + capabilities: { + vision: true, + tool_calling: true, + reasoning: true, + } + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + const model = models.find(m => m.id === 'test/model-1'); + + assert.ok(model, 'Model should be found'); + assert.equal(model.contextWindow, 128000, 'Should read context_length'); + assert.equal(model.maxTokens, 4096, 'Should read max_output_tokens'); + assert.equal(model.supportsVision, true, 'Should read capabilities.vision'); + assert.equal(model.supportsTools, true, 'Should read capabilities.tool_calling'); + assert.equal(model.supportsReasoning, true, 'Should read capabilities.reasoning'); +}); + +test('normalizeModel prefers camelCase over snake_case', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'test/model-2', + contextWindow: 64000, + context_length: 32000, + capabilities: { + vision: false, + } + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + const model = models.find(m => m.id === 'test/model-2'); + + assert.ok(model, 'Model should be found'); + assert.equal(model.contextWindow, 64000, 'Should prefer camelCase over snake_case'); +}); + +// Task 22: Deduplication of alias/canonical model entries +test('deduplication removes alias when canonical exists', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'ollamacloud/deepseek-v4', + name: 'DeepSeek V4 (alias)', + context_length: 64000, + }, + { + id: 'ollama-cloud/deepseek-v4', + name: 'DeepSeek V4 (canonical)', + context_length: 128000, + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + + assert.equal(models.length, 1, 'Should deduplicate to single model'); + assert.equal(models[0].id, 'ollama-cloud/deepseek-v4', 'Should prefer canonical ID'); + assert.equal(models[0].contextWindow, 128000, 'Should use canonical metadata'); +}); + +test('deduplication keeps alias when canonical is missing', async () => { + global.fetch = async (input) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.includes('/v1/models')) { + return new Response( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'ollamacloud/deepseek-v4', + name: 'DeepSeek V4', + context_length: 64000, + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + } + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + }; + + const models = await fetchModels(CONFIG, CONFIG.apiKey, false); + + assert.equal(models.length, 1, 'Should keep single model'); + assert.equal(models[0].id, 'ollama-cloud/deepseek-v4', 'Should normalize to canonical ID'); +}); diff --git a/test/plugin.test.mjs b/test/plugin.test.mjs index 35cd383..a996057 100644 --- a/test/plugin.test.mjs +++ b/test/plugin.test.mjs @@ -14,6 +14,10 @@ afterEach(() => { process.env.HOME = ORIGINAL_HOME; }); +function getDummyBaseUrl(port = 20128) { + return `http://localhost:${port}/v1`; +} + function createModelsResponse() { return { object: 'list', @@ -32,7 +36,7 @@ test('config hook applies defaults and normalized apiMode', async () => { provider: { omniroute: { options: { - baseURL: 'http://localhost:20128/v1', + baseURL: getDummyBaseUrl(), apiMode: 'invalid-mode', }, }, @@ -69,7 +73,7 @@ test('loader injects auth headers only for OmniRoute URLs', async () => { const provider = { options: { - baseURL: 'http://localhost:20128/v1', + baseURL: getDummyBaseUrl(), apiMode: 'chat', }, models: {}, @@ -78,7 +82,7 @@ test('loader injects auth headers only for OmniRoute URLs', async () => { const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); const interceptedFetch = options.fetch; - await interceptedFetch('http://localhost:20128/v1/chat/completions', { + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { method: 'POST', body: JSON.stringify({ model: 'gpt-4.1-mini', messages: [] }), }); @@ -123,14 +127,14 @@ test('gemini tool schema payload is sanitized before forwarding', async () => { }; const provider = { - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, models: {}, }; const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); const interceptedFetch = options.fetch; - await interceptedFetch('http://localhost:20128/v1/chat/completions', { + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { method: 'POST', body: JSON.stringify({ model: 'gemini-2.5-pro', @@ -188,14 +192,14 @@ test('non-gemini payload keeps original tool schema fields', async () => { }; const provider = { - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, models: {}, }; const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); const interceptedFetch = options.fetch; - await interceptedFetch('http://localhost:20128/v1/chat/completions', { + await interceptedFetch(`${getDummyBaseUrl()}/chat/completions`, { method: 'POST', body: JSON.stringify({ model: 'gpt-4.1-mini', @@ -244,14 +248,14 @@ test('gemini schema sanitization applies to responses endpoint request objects', }; const provider = { - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'responses' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'responses' }, models: {}, }; const options = await plugin.auth.loader(async () => ({ type: 'api', key: 'secret-key' }), provider); const interceptedFetch = options.fetch; - const request = new Request('http://localhost:20128/v1/responses', { + const request = new Request(`${getDummyBaseUrl()}/responses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -312,7 +316,7 @@ test('provider hook fetches models when auth is available via context', async () name: 'OmniRoute', source: 'config', env: [], - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, models: {}, }, { auth: { type: 'api', key: 'live-key' } }, @@ -336,7 +340,7 @@ test('provider hook ignores stale provider.models and returns defaults when no a name: 'OmniRoute', source: 'config', env: [], - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, models: { 'stale-model': { id: 'stale-model', @@ -370,7 +374,7 @@ test('provider hook returns defaults when fetch fails (fetchModels handles error name: 'OmniRoute', source: 'config', env: [], - options: { baseURL: 'http://localhost:20128/v1', apiMode: 'chat' }, + options: { baseURL: getDummyBaseUrl(), apiMode: 'chat' }, models: { 'existing-model': { id: 'existing-model', name: 'Existing', providerID: 'omniroute' } }, }, { auth: { type: 'api', key: 'bad-key' } }, @@ -418,7 +422,7 @@ test('config hook eagerly fetches models when auth is available', async () => { provider: { omniroute: { options: { - baseURL: 'http://localhost:20128/v1', + baseURL: getDummyBaseUrl(), apiMode: 'chat', }, }, @@ -473,7 +477,7 @@ test('config hook preserves user modelMetadata object overrides', async () => { provider: { omniroute: { options: { - baseURL: 'http://localhost:20129/v1', + baseURL: getDummyBaseUrl(20129), modelMetadata: { 'cx/gpt-5.5': { contextWindow: 258000, @@ -486,7 +490,8 @@ test('config hook preserves user modelMetadata object overrides', async () => { await plugin.config(config); - const metadata = config.provider.omniroute.options.modelMetadata['cx/gpt-5.5']; + // User metadata is merged into canonical key after deduplication + const metadata = config.provider.omniroute.options.modelMetadata['codex/gpt-5.5']; assert.equal(metadata.contextWindow, 258000); assert.equal(metadata.supportsReasoning, true); } finally { @@ -530,7 +535,7 @@ test('config hook preserves user modelMetadata match blocks', async () => { provider: { omniroute: { options: { - baseURL: 'http://localhost:20130/v1', + baseURL: getDummyBaseUrl(20130), modelMetadata: [userBlock], }, }, @@ -541,9 +546,11 @@ test('config hook preserves user modelMetadata match blocks', async () => { const metadata = config.provider.omniroute.options.modelMetadata; assert.ok(Array.isArray(metadata)); - assert.deepEqual(metadata.at(-1), userBlock); - assert.equal(metadata[0].match, 'cx/gpt-5.5'); - assert.equal(metadata[0].contextWindow, 1050000); + // User config comes first in first-match-wins systems + assert.deepEqual(metadata[0], userBlock); + // Generated metadata follows user config + assert.equal(metadata[1].match, 'codex/gpt-5.5'); + assert.equal(metadata[1].contextWindow, 1050000); } finally { await rm(tempHome, { recursive: true, force: true }); } @@ -588,7 +595,7 @@ test('config hook respects explicit attachment false for vision models', async ( provider: { omniroute: { options: { - baseURL: 'http://localhost:20131/v1', + baseURL: getDummyBaseUrl(20131), }, }, }, diff --git a/type-compat-test.ts b/type-compat-test.ts new file mode 100644 index 0000000..3609645 --- /dev/null +++ b/type-compat-test.ts @@ -0,0 +1,5 @@ +import type { Model } from '@opencode-ai/sdk'; +import type { OmniRouteProviderModel } from './src/types.js'; + +// Type compatibility test: OmniRouteProviderModel should be assignable to Model +const testCompat = (m: OmniRouteProviderModel): Model => m;