diff --git a/changelog.d/fixes/13168-opencode-catalog-display-names.md b/changelog.d/fixes/13168-opencode-catalog-display-names.md new file mode 100644 index 0000000000..200dbcdb0e --- /dev/null +++ b/changelog.d/fixes/13168-opencode-catalog-display-names.md @@ -0,0 +1 @@ +- **fix(cli):** OpenCode config generator preserves catalog display names — custom names win, then `display_name`/native `name` (with the `owned_by/` prefix stripped once), then a readable label for `auto/*` ids, instead of always showing the raw model id ([#13168](https://github.com/diegosouzapw/OmniRoute/pull/13168)) — thanks @domenicomassafra diff --git a/src/lib/cli-helper/config-generator/opencode.ts b/src/lib/cli-helper/config-generator/opencode.ts index e65206dc55..363c413f1b 100644 --- a/src/lib/cli-helper/config-generator/opencode.ts +++ b/src/lib/cli-helper/config-generator/opencode.ts @@ -40,6 +40,8 @@ export function assertSafeCatalogUrl(rawUrl: string): URL { interface CatalogModelEntry { id: string; owned_by?: string; + name?: string; + display_name?: string; /** OpenAI-compatible field name; some upstreams return this. */ context_length?: number; max_context_window_tokens?: number; @@ -254,8 +256,22 @@ function buildModelEntry( catalog: CatalogModelEntry | undefined, existing: ExistingModelEntry | undefined ): ExistingModelEntry { - // Carry over user-set "name" first; fall back to id when absent. - const name = (typeof existing?.name === "string" && existing.name.trim()) || id; + // Carry over user-set names first, then native catalog display metadata. + // Technical ids remain map keys; names are presentation only. + const catalogName = catalog?.display_name ?? catalog?.name; + const nativeName = typeof catalogName === "string" ? catalogName.trim() : ""; + const providerPrefix = catalog?.owned_by ? `${catalog.owned_by}/` : ""; + const modelName = nativeName.startsWith(providerPrefix) + ? nativeName.slice(providerPrefix.length) + : nativeName; + const autoName = id.startsWith("auto/") + ? `Auto ${id.slice("auto/".length).replace(/(^|[-_])([a-z])/g, (_, separator, letter) => `${separator === "" ? "" : " "}${letter.toUpperCase()}`)}` + : ""; + const name = + (typeof existing?.name === "string" && existing.name.trim() !== id && existing.name.trim()) || + autoName || + modelName || + id; const entry: ExistingModelEntry = { name }; diff --git a/tests/unit/opencode-catalog-display-names-13168.test.ts b/tests/unit/opencode-catalog-display-names-13168.test.ts new file mode 100644 index 0000000000..e64bb94462 --- /dev/null +++ b/tests/unit/opencode-catalog-display-names-13168.test.ts @@ -0,0 +1,172 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +/** + * Regression guard for #13168: the OpenCode config generator used to emit + * the raw catalog model id as the display name, so `auto/chat` stayed + * `auto/chat` and provider-prefixed catalog labels duplicated the provider + * name. `buildModelEntry()`'s name precedence (highest to lowest) is: + * 1. an existing custom name already set by the user in opencode.json + * 2. a readable label for `auto/*` ids (OmniRoute's own virtual routing ids) + * 3. the catalog's `display_name` (falling back to `name`), with a leading + * `owned_by/` prefix stripped once + * 4. the raw catalog/model id, when nothing else is known + */ +describe("opencode config generator — catalog display-name precedence (#13168)", () => { + function makeCatalogResponse(models: unknown[]): unknown { + return { object: "list", data: models }; + } + + function stubFetchOnce(body: unknown, status = 200) { + const original = globalThis.fetch; + // @ts-ignore — globalThis.fetch signature is compatible for our purposes + globalThis.fetch = (async () => { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + return { + restore: () => { + globalThis.fetch = original; + }, + }; + } + + function writeTempConfig(config: unknown): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-config-13168-")); + const file = path.join(dir, "opencode.json"); + fs.writeFileSync(file, JSON.stringify(config), "utf8"); + return file; + } + + it("1. an existing custom name wins over any catalog name", async () => { + const configPath = writeTempConfig({ + provider: { + omniroute: { + models: { + "vendor/model-a": { name: "My Custom Label" }, + }, + }, + }, + }); + const stub = stubFetchOnce( + makeCatalogResponse([ + { id: "vendor/model-a", owned_by: "vendor", display_name: "Ignored Catalog Name" }, + ]) + ); + try { + const { generateOpencodeConfig } = + await import("../../src/lib/cli-helper/config-generator/opencode.ts"); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models["vendor/model-a"].name, "My Custom Label"); + } finally { + stub.restore(); + fs.rmSync(path.dirname(configPath), { recursive: true, force: true }); + } + }); + + it("2. catalog display_name is preferred over the raw id and native name, stripping the owned_by prefix", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-config-13168-")); + const configPath = path.join(tmpDir, "missing.json"); + const stub = stubFetchOnce( + makeCatalogResponse([ + { + id: "vendor/model-b", + owned_by: "vendor", + display_name: "vendor/Pretty Name", + name: "raw-native-name", + }, + ]) + ); + try { + const { generateOpencodeConfig } = + await import("../../src/lib/cli-helper/config-generator/opencode.ts"); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models["vendor/model-b"].name, "Pretty Name"); + } finally { + stub.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("3. catalog native `name` is used when display_name is absent", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-config-13168-")); + const configPath = path.join(tmpDir, "missing.json"); + const stub = stubFetchOnce( + makeCatalogResponse([ + { id: "vendor/model-c", owned_by: "vendor", name: "vendor/Native Label" }, + ]) + ); + try { + const { generateOpencodeConfig } = + await import("../../src/lib/cli-helper/config-generator/opencode.ts"); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models["vendor/model-c"].name, "Native Label"); + } finally { + stub.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("4. `auto/*` ids fall back to a readable label when no catalog metadata names them", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-config-13168-")); + const configPath = path.join(tmpDir, "missing.json"); + const stub = stubFetchOnce(makeCatalogResponse([{ id: "auto/chat-turbo" }])); + try { + const { generateOpencodeConfig } = + await import("../../src/lib/cli-helper/config-generator/opencode.ts"); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }); + const cfg = JSON.parse(out); + assert.strictEqual(cfg.provider.omniroute.models["auto/chat-turbo"].name, "Auto Chat Turbo"); + } finally { + stub.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("5. raw catalog model id is used when neither custom name, display_name, name, nor auto/* apply", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-config-13168-")); + const configPath = path.join(tmpDir, "missing.json"); + const stub = stubFetchOnce(makeCatalogResponse([{ id: "custom-vendor/raw-model-id" }])); + try { + const { generateOpencodeConfig } = + await import("../../src/lib/cli-helper/config-generator/opencode.ts"); + const out = await generateOpencodeConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }); + const cfg = JSON.parse(out); + assert.strictEqual( + cfg.provider.omniroute.models["custom-vendor/raw-model-id"].name, + "custom-vendor/raw-model-id" + ); + } finally { + stub.restore(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +});