diff --git a/CHANGELOG.md b/CHANGELOG.md index 6980c66..a0ed89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project are documented in this file. +## [Unreleased] + +### Added + +- **Auto-Router Capability Enrichment** — OmniRoute's zero-config `auto` model is no longer left without capability metadata. If a model with id `auto` (or `/auto`) is present in the fetched model list without an explicit `contextWindow`/`maxTokens`, the plugin now computes its capabilities as the lowest common denominator across all other known models (min context window/max tokens, boolean-AND for vision/tools/streaming/temperature/attachment, boolean-OR for reasoning). This removes the need to hardcode or manually maintain a context window override for `auto` as the OmniRoute catalog grows. (`src/omniroute-combos.ts`: `enrichAutoModel`, `isAutoModel`; wired into `src/models.ts`) (resolves #43) +- **5 New Test Cases** (`test/auto-model.test.mjs`) covering `isAutoModel` id matching and `enrichAutoModel` computation, no-op, and override-preservation behavior. + ## [1.2.2] - 2026-05-22 ### Added diff --git a/README.md b/README.md index d8447ae..fe92c2b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - ✅ **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 +- ✅ **Auto-Router Capability Enrichment** - Automatically computes context window/capabilities for OmniRoute's `auto` zero-config router from the rest of your catalog, no hardcoding needed - ✅ **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 @@ -206,6 +207,23 @@ Calculated capabilities: Note: Some underlying models may not be found in `models.dev` (e.g., custom models). In such cases, they are excluded from capability calculation, and a warning is logged. +### Auto-Router Model Capability Enrichment + +OmniRoute's zero-config `auto` router (added to `models` as `auto`) is not listed in `/api/combos` +like user-defined combos, so it can't be resolved to underlying models the same way. Instead, if a +model with id `auto` (or `/auto`) appears in your fetched model list without a +`contextWindow`/`maxTokens` already set, this plugin automatically computes its capabilities as the +lowest common denominator across every other known model in your catalog: + +- **Context Window / Max Tokens**: minimum across all other fetched models +- **Vision / Tools / Streaming / Temperature / Attachment**: `true` only if ALL other models support it +- **Reasoning**: `true` if ANY other model supports it + +This means you never need to hardcode or manually update `auto`'s context window as OmniRoute's +provider/model catalog grows or shrinks — it's recalculated automatically on every model fetch. If +OmniRoute itself ever starts returning explicit capabilities for `auto` (or you set them via +`modelMetadata` overrides), those take precedence and this calculation is skipped. + ### API Mode ### API Mode diff --git a/src/models.ts b/src/models.ts index 71f6fac..44043e5 100644 --- a/src/models.ts +++ b/src/models.ts @@ -15,7 +15,7 @@ import { resolveModelAlias, } from './models-dev.js'; import type { ModelsDevIndex, ModelsDevModel } from './models-dev.js'; -import { enrichComboModels, clearComboCache, splitModelId } from './omniroute-combos.js'; +import { enrichComboModels, enrichAutoModel, clearComboCache, splitModelId } from './omniroute-combos.js'; import { warn, debug } from './logger.js'; /** @@ -443,7 +443,10 @@ async function enrichModelMetadata( // Enrich combo models with lowest common capabilities const withComboCapabilities = await enrichComboModels(withModelsDev, config, modelsDevIndex); - return withComboCapabilities; + // Enrich the "auto" zero-config router model (if present) with capabilities + // computed from every other known model, so it never needs manual overrides + // as OmniRoute's catalog grows. + return enrichAutoModel(withComboCapabilities); } /** diff --git a/src/omniroute-combos.ts b/src/omniroute-combos.ts index 95e5bab..cb18480 100644 --- a/src/omniroute-combos.ts +++ b/src/omniroute-combos.ts @@ -335,6 +335,73 @@ export function isComboModel(model: OmniRouteModel): boolean { return false; } +/** + * Identify the OmniRoute "auto" zero-config router model. + * OmniRoute exposes this as a model id of literally `auto` (optionally + * prefixed, e.g. `omniroute/auto`). Unlike user-defined combos, it is not + * listed in `/api/combos`, so it needs its own capability calculation. + */ +export function isAutoModel(model: OmniRouteModel): boolean { + const { modelKey } = splitModelId(model.id); + return modelKey.toLowerCase() === 'auto'; +} + +/** + * Enrich the "auto" model (if present in the fetched model list) with + * capabilities computed as the lowest common denominator across every + * other known model. This mirrors how user-defined combo capabilities are + * calculated, and ensures the "auto" router's advertised context window / + * capabilities automatically track the OmniRoute catalog as it grows or + * shrinks, without requiring any hardcoded overrides. + * + * This should be called after models.dev + combo enrichment, so that the + * "other models" pool already has resolved capabilities where possible. + */ +export function enrichAutoModel(models: OmniRouteModel[]): OmniRouteModel[] { + const autoIndex = models.findIndex(isAutoModel); + if (autoIndex === -1) return models; + + const autoModel = models[autoIndex]; + + // If OmniRoute or models.dev already provided full capability data, don't override it. + if (autoModel.contextWindow !== undefined && autoModel.maxTokens !== undefined) { + return models; + } + + const others = models.filter((model, index) => index !== autoIndex && !isAutoModel(model)); + if (others.length === 0) { + return models; + } + + const withContext = others.filter((m): m is OmniRouteModel & { contextWindow: number } => m.contextWindow !== undefined); + const withMaxTokens = others.filter((m): m is OmniRouteModel & { maxTokens: number } => m.maxTokens !== undefined); + + const computedContextWindow = + autoModel.contextWindow ?? (withContext.length > 0 ? Math.min(...withContext.map((m) => m.contextWindow)) : undefined); + const computedMaxTokens = + autoModel.maxTokens ?? (withMaxTokens.length > 0 ? Math.min(...withMaxTokens.map((m) => m.maxTokens)) : undefined); + + debug( + `Calculated capabilities for auto-router model "${sanitizeForLog(autoModel.id)}" from ${others.length} known models: context=${computedContextWindow ?? 'N/A'}, maxTokens=${computedMaxTokens ?? 'N/A'}`, + ); + + const updated: OmniRouteModel = { + ...autoModel, + ...(computedContextWindow !== undefined ? { contextWindow: computedContextWindow } : {}), + ...(computedMaxTokens !== undefined ? { maxTokens: computedMaxTokens } : {}), + supportsVision: autoModel.supportsVision ?? others.every((m) => m.supportsVision === true), + supportsTools: autoModel.supportsTools ?? others.every((m) => m.supportsTools === true), + supportsStreaming: autoModel.supportsStreaming ?? others.every((m) => m.supportsStreaming === true), + supportsTemperature: autoModel.supportsTemperature ?? others.every((m) => m.supportsTemperature === true), + supportsReasoning: autoModel.supportsReasoning ?? others.some((m) => m.supportsReasoning === true), + supportsAttachment: autoModel.supportsAttachment ?? others.every((m) => m.supportsAttachment === true), + }; + + const result = [...models]; + result[autoIndex] = updated; + return result; +} + /** * Enrich models with combo-specific capabilities * This should be called after models.dev enrichment diff --git a/test/auto-model.test.mjs b/test/auto-model.test.mjs new file mode 100644 index 0000000..de09f49 --- /dev/null +++ b/test/auto-model.test.mjs @@ -0,0 +1,81 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { enrichAutoModel, isAutoModel } from '../dist/src/omniroute-combos.js'; + +test('isAutoModel matches bare "auto" and prefixed ids', () => { + assert.equal(isAutoModel({ id: 'auto' }), true); + assert.equal(isAutoModel({ id: 'omniroute/auto' }), true); + assert.equal(isAutoModel({ id: 'auto-pilot' }), false); + assert.equal(isAutoModel({ id: 'gpt-4o' }), false); +}); + +test('enrichAutoModel computes lowest-common capabilities from other models', () => { + const models = [ + { id: 'auto', name: 'Auto' }, + { + id: 'openai/gpt-4o', + name: 'GPT-4o', + contextWindow: 128000, + maxTokens: 16384, + supportsVision: true, + supportsTools: true, + supportsStreaming: true, + supportsTemperature: true, + supportsReasoning: false, + supportsAttachment: true, + }, + { + id: 'anthropic/claude-3-5-sonnet', + name: 'Claude 3.5 Sonnet', + contextWindow: 200000, + maxTokens: 8192, + supportsVision: true, + supportsTools: true, + supportsStreaming: true, + supportsTemperature: true, + supportsReasoning: true, + supportsAttachment: true, + }, + ]; + + const result = enrichAutoModel(models); + const auto = result.find((m) => m.id === 'auto'); + + assert.equal(auto.contextWindow, 128000, 'context window should be the minimum across models'); + assert.equal(auto.maxTokens, 8192, 'max tokens should be the minimum across models'); + assert.equal(auto.supportsVision, true); + assert.equal(auto.supportsTools, true); + assert.equal(auto.supportsReasoning, true, 'reasoning should be true if any model supports it'); +}); + +test('enrichAutoModel is a no-op when no auto model is present', () => { + const models = [ + { id: 'openai/gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, + ]; + const result = enrichAutoModel(models); + assert.deepEqual(result, models); +}); + +test('enrichAutoModel does not override explicit capabilities already provided', () => { + const models = [ + { id: 'auto', name: 'Auto', contextWindow: 999999, maxTokens: 999 }, + { id: 'openai/gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxTokens: 16384 }, + ]; + const result = enrichAutoModel(models); + const auto = result.find((m) => m.id === 'auto'); + assert.equal(auto.contextWindow, 999999); + assert.equal(auto.maxTokens, 999); +}); + +test('enrichAutoModel recalculates when only one capability field is missing', () => { + const models = [ + { id: 'auto', name: 'Auto', maxTokens: 4096 }, + { id: 'openai/gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxTokens: 16384 }, + { id: 'anthropic/claude-3-5-sonnet', name: 'Claude', contextWindow: 64000, maxTokens: 8192 }, + ]; + const result = enrichAutoModel(models); + const auto = result.find((m) => m.id === 'auto'); + assert.equal(auto.contextWindow, 64000, 'should fill in missing context window from minimum'); + assert.equal(auto.maxTokens, 4096, 'should keep pre-existing maxTokens untouched'); +});