Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@ All notable changes to this project are documented in this file.
### Added

- **`modelNameDisplay` option** — New `OmniRouteConfig` option that controls how model names appear in the model picker. Set to `"id"` to show provider-qualified model IDs (e.g. `gh/gpt-5.5`) instead of human-readable names, which disambiguates entries when multiple providers serve the same base model. (`src/plugin.ts`, `src/types.ts`) (@Rahulsharma0810)
- **`modelNameDisplay: "prefixed"` and `hideModelAliases`** — Extended the `modelNameDisplay` option with a `"prefixed"` mode that renders model names as `${provider} / ${name}` (e.g. `"OpenCode / Big Pickle"`) using a provider label map, and added `hideModelAliases` to filter out alias models that have a `parent` field in `/v1/models`. (`src/plugin.ts`, `src/models.ts`, `src/types.ts`, `src/constants.ts`)

### Fixed

- **Preserve provider/origin prefix in model picker** — OmniRoute models from different providers no longer share the same display name when `modelNameDisplay` is set to `"prefixed"`; aliases can also be hidden with `hideModelAliases`. (#29)
- **Exclude cached tokens from chat usage** — `prompt_tokens` and `total_tokens` in `/chat/completions` responses now exclude `prompt_tokens_details.cached_tokens`, matching OpenCode's separate cached-input accounting. Applies to both JSON and streaming responses. (`src/plugin.ts`) (@makcimbx)
- **Use `@ai-sdk/openai` for responses mode** — When `apiMode` is `responses`, the plugin now selects `@ai-sdk/openai` as the provider package instead of `@ai-sdk/openai-compatible`, and reconciles explicit model `api.npm` values when the provider package changes. (`src/plugin.ts`) (@makcimbx)
- **Strip Claude title reasoning effort** — The fetch interceptor now removes `reasoning_effort`/`reasoningEffort` from identified Claude title-generation requests, avoiding Anthropic OAuth temperature/thinking validation errors while preserving reasoning options for normal Claude chat requests. (`src/plugin.ts`) (@thomasmaerz)
- **Fix SSE newline boundary corruption** — Corrected `normalizeSseChatUsageResponse` so `\r\n` sequences split across stream chunks no longer inject spurious empty lines into the event stream. (`src/plugin.ts`)
- **Fix Claude title prompt detection short-circuit** — `isOpenCodeTitlePrompt` now checks `payload.input` even when `payload.messages` is present but does not contain the title prompt. (`src/plugin.ts`)

## [1.2.2] - 2026-05-22

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ Use `/connect omniroute` to store your API key in `~/.local/share/opencode/auth.
| `provider.omniroute.options.refreshOnList` | boolean | No | Whether to refresh models when provider options load (default: true) |
| `provider.omniroute.options.modelsDev` | object | No | Enrich model metadata from models.dev on refresh (default: enabled) |
| `provider.omniroute.options.modelMetadata` | object \| array | No | Override/add metadata for custom/virtual models (works well in `opencode.js`) |
| `provider.omniroute.options.modelNameDisplay` | `'name' \| 'id'` | No | Use model `name` (default) or `id` in the model picker; `id` disambiguates duplicate display names |
| `provider.omniroute.options.modelNameDisplay` | `'name' \| 'id' \| 'prefixed'` | No | Use model `name` (default), `id`, or provider-prefixed name (e.g. `"OpenCode / GPT-4o"`) in the model picker |
| `provider.omniroute.options.hideModelAliases` | `boolean` | No | Hide alias models that have a `parent` field in `/v1/models` (default: `false`) |

### Model Metadata Enrichment (models.dev)

Expand Down Expand Up @@ -274,8 +275,10 @@ interface OmniRouteConfig {
refreshOnList?: boolean;
modelsDev?: OmniRouteModelsDevConfig;
modelMetadata?: OmniRouteModelMetadataConfig;
/** Controls how model names appear in the picker: `"name"` (default) or `"id"`. */
modelNameDisplay?: 'name' | 'id';
/** Controls how model names appear in the picker: `"name"` (default), `"id"`, or `"prefixed"`. */
modelNameDisplay?: 'name' | 'id' | 'prefixed';
/** Hide alias models that have a `parent` field in `/v1/models`. */
hideModelAliases?: boolean;
}

type OmniRouteApiMode = 'chat' | 'responses';
Expand Down
20 changes: 20 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,23 @@ export const PROVIDER_ALIAS_TO_CANONICAL: Record<string, string> = {
kr: 'kiro',
if: 'qoder',
};

/**
* Friendly display labels for provider origins.
* Used when modelNameDisplay is "prefixed".
*/
export const PROVIDER_DISPLAY_LABELS: Record<string, string> = {
oc: 'OpenCode Free',
opencode: 'OpenCode',
openrouter: 'OpenRouter',
anthropic: 'Anthropic',
claude: 'Anthropic',
openai: 'OpenAI',
google: 'Google',
gemini: 'Google',
cx: 'Codex',
codex: 'Codex',
gh: 'GitHub',
github: 'GitHub',
antigravity: 'Antigravity',
};
10 changes: 9 additions & 1 deletion src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ function normalizeModel(model: OmniRouteModel): OmniRouteModel {
name: model.name || model.id,
description: model.description || `OmniRoute model: ${model.id}`,

// Preserve OpenAI-compatible origin metadata for display/filtering
owned_by: model.owned_by,
root: model.root,
parent: model.parent,

// Context limits: prefer explicit camelCase, fallback to snake_case
contextWindow:
model.contextWindow ?? model.context_length ?? model.max_input_tokens,
Expand Down Expand Up @@ -337,7 +342,10 @@ export async function fetchModels(
)
.map(normalizeModel);

const dedupedModels = deduplicateModels(rawModels);
const visibleModels = config.hideModelAliases
? rawModels.filter((model) => !model.parent)
: rawModels;
const dedupedModels = deduplicateModels(visibleModels);
const groupedModels = groupVariantModels(dedupedModels);
const models = await enrichModelMetadata(groupedModels, config);

Expand Down
104 changes: 91 additions & 13 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
OMNIROUTE_ENDPOINTS,
DEFAULT_CONTEXT_LIMIT,
DEFAULT_OUTPUT_LIMIT,
PROVIDER_ALIAS_TO_CANONICAL,
PROVIDER_DISPLAY_LABELS,
} from './constants.js';
import { fetchModels, resolveProviderAliasForMetadata } from './models.js';
import { warn, debug } from './logger.js';
Expand Down Expand Up @@ -235,6 +237,7 @@ function createRuntimeConfig(
const modelsDev = getModelsDevConfig(options);
const modelMetadata = getModelMetadataConfig(options);
const modelNameDisplay = getModelNameDisplay(options);
const hideModelAliases = getHideModelAliases(options);

return {
baseUrl,
Expand All @@ -245,6 +248,7 @@ function createRuntimeConfig(
modelsDev,
modelMetadata,
modelNameDisplay,
hideModelAliases,
};
}

Expand Down Expand Up @@ -381,9 +385,9 @@ function getBoolean(

function getModelNameDisplay(
options: Record<string, unknown> | undefined,
): 'name' | 'id' | undefined {
): 'name' | 'id' | 'prefixed' | undefined {
const value = options?.modelNameDisplay;
if (value === 'name' || value === 'id') {
if (value === 'name' || value === 'id' || value === 'prefixed') {
return value;
}
if (value !== undefined) {
Expand All @@ -392,6 +396,14 @@ function getModelNameDisplay(
return undefined;
}

function getHideModelAliases(options: Record<string, unknown> | undefined): boolean | undefined {
const value = options?.hideModelAliases;
if (typeof value === 'boolean') {
return value;
}
return undefined;
}

function getModelsDevConfig(options: Record<string, unknown> | undefined): OmniRouteModelsDevConfig | undefined {
const raw = options?.modelsDev;
if (!isRecord(raw)) return undefined;
Expand Down Expand Up @@ -536,7 +548,7 @@ function isGeneratedOmniRouteProviderModel(value: unknown): boolean {
function reconcileExplicitModels(
models: Record<string, unknown> | undefined,
providerNpm: string,
modelNameDisplay?: 'name' | 'id',
modelNameDisplay?: 'name' | 'id' | 'prefixed',
): Record<string, unknown> | undefined {
if (!isRecord(models)) return models;
let changed = false;
Expand All @@ -558,8 +570,15 @@ function reconcileExplicitModels(
};
}

const expectedName = modelNameDisplay === 'id' ? id : (model.name ?? id);
if (typeof model.name === 'string' ? model.name !== expectedName : modelNameDisplay === 'id') {
const expectedName = formatModelDisplayName(
id,
typeof model.name === 'string' ? model.name : id,
modelNameDisplay,
);
const hasName = typeof model.name === 'string';
const nameMismatch = hasName && model.name !== expectedName;
const needsSyntheticName = !hasName && (modelNameDisplay === 'id' || modelNameDisplay === 'prefixed');
if (nameMismatch || needsSyntheticName) {
updatedModel = {
...updatedModel,
name: expectedName,
Expand Down Expand Up @@ -876,11 +895,63 @@ function isValidModelMetadata(value: unknown): { valid: boolean; field?: string
return { valid: true };
}

const PROVIDER_DISPLAY_LABEL_VALUES = new Set(
Object.values(PROVIDER_DISPLAY_LABELS).map((label) => label.toLowerCase()),
);

function formatModelDisplayName(
id: string,
baseName: string,
modelNameDisplay?: 'name' | 'id' | 'prefixed',
): string {
if (modelNameDisplay === 'id') {
return id;
}
if (modelNameDisplay === 'prefixed') {
const origin = getModelOrigin(id);
if (!origin) {
return baseName;
}
const cleanBaseName = stripProviderPrefix(baseName);
return `${getPrettyOrigin(origin)} / ${cleanBaseName}`;
}
return baseName;
}

function getModelOrigin(modelId: string): string | undefined {
const slashIndex = modelId.indexOf('/');
if (slashIndex > 0) {
return modelId.slice(0, slashIndex);
}
return undefined;
}

function getPrettyOrigin(origin: string): string {
return PROVIDER_DISPLAY_LABELS[origin] ?? origin;
}

function stripProviderPrefix(name: string): string {
const slashIndex = name.indexOf(' / ');
if (slashIndex <= 0) {
return name;
}
const prefix = name.slice(0, slashIndex);
const lowerPrefix = prefix.toLowerCase();
if (
PROVIDER_DISPLAY_LABELS[lowerPrefix] ||
PROVIDER_ALIAS_TO_CANONICAL[lowerPrefix] ||
PROVIDER_DISPLAY_LABEL_VALUES.has(lowerPrefix)
) {
return name.slice(slashIndex + 3);
}
return name;
}

function toProviderModels(
models: OmniRouteModel[],
baseUrl: string,
providerNpm: string,
modelNameDisplay?: 'name' | 'id',
modelNameDisplay?: 'name' | 'id' | 'prefixed',
): Record<string, OmniRouteProviderModel> {
const entries: Array<[string, OmniRouteProviderModel]> = models.map((model) => [
model.id,
Expand All @@ -893,7 +964,7 @@ function toProviderModel(
model: OmniRouteModel,
baseUrl: string,
providerNpm: string,
modelNameDisplay?: 'name' | 'id',
modelNameDisplay?: 'name' | 'id' | 'prefixed',
): OmniRouteProviderModel {
const supportsVision = model.supportsVision === true;
// Default to true: if API doesn't explicitly say no tools, assume capability exists
Expand All @@ -907,7 +978,7 @@ function toProviderModel(

return {
id: model.id,
name: modelNameDisplay === 'id' ? model.id : (model.name || model.id),
name: formatModelDisplayName(model.id, model.name || model.id, modelNameDisplay),
providerID: OMNIROUTE_PROVIDER_ID,
family: getModelFamily(model.id),
release_date: '',
Expand Down Expand Up @@ -1089,17 +1160,22 @@ function normalizeSseChatUsageResponse(response: Response): Response {
const stream = response.body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
pending += decoder.decode(chunk, { stream: true });
pending = pending.replace(/\r\n?/g, '\n');
const lines = pending.split('\n');
pending = lines.pop() ?? '';

for (const line of lines) {
for (let line of lines) {
if (line.endsWith('\r')) {
line = line.slice(0, -1);
}
controller.enqueue(encoder.encode(`${normalizeSseChatUsageLine(line)}\n`));
}
},
flush(controller) {
const tail = (pending + decoder.decode()).replace(/\r\n?/g, '\n');
let tail = pending + decoder.decode();
if (tail) {
if (tail.endsWith('\r')) {
tail = tail.slice(0, -1);
}
controller.enqueue(encoder.encode(`${normalizeSseChatUsageLine(tail)}\n`));
}
},
Expand Down Expand Up @@ -1268,18 +1344,20 @@ function isOpenCodeTitlePrompt(payload: Record<string, unknown>): boolean {

const messages = payload.messages;
if (Array.isArray(messages)) {
return messages.some((message) => {
const hasTitlePrompt = messages.some((message) => {
if (!isRecord(message) || message.role !== 'system') return false;
return contentContainsTitlePrompt(message.content);
});
if (hasTitlePrompt) return true;
}

const input = payload.input;
if (Array.isArray(input)) {
return input.some((item) => {
const hasTitlePrompt = input.some((item) => {
if (!isRecord(item) || item.role !== 'system') return false;
return contentContainsTitlePrompt(item.content);
});
if (hasTitlePrompt) return true;
}

return false;
Expand Down
12 changes: 11 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface OmniRouteModel {
vision?: boolean;
tool_calling?: boolean;

// OpenAI-compatible model metadata fields used by OmniRoute
owned_by?: string;
root?: string;
parent?: string | null;

// OmniRoute capabilities object
capabilities?: {
vision?: boolean;
Expand Down Expand Up @@ -139,8 +144,13 @@ export interface OmniRouteConfig {
* `"cx/gpt-5.5"`). Always unique per entry — useful when multiple
* providers serve the same model or when OmniRoute name disambiguation
* is not enabled.
* - `"prefixed"`: prefix the human-readable `name` with the provider
* origin (e.g. `"OpenCode Free / Big Pickle"`). Combines readability
* with disambiguation.
*/
modelNameDisplay?: 'name' | 'id';
modelNameDisplay?: 'name' | 'id' | 'prefixed';
/** Hide alias models that have a `parent` field in `/v1/models`. */
hideModelAliases?: boolean;
}

export interface OmniRouteProviderModelModalities {
Expand Down
Loading