From b0a0bcc7279f46c334d3e1e76b35d47ec6ff98d7 Mon Sep 17 00:00:00 2001 From: ikelvingo Date: Sat, 25 Jul 2026 08:47:31 -0300 Subject: [PATCH] fix(api): expose responses-format models on all VS Code Ollama listing routes (#7587) isUsableChatModel() was copy-pasted into 5 vscode listing routes. PR #7012 widened only models/route.ts to accept api_format "responses"/"openai-responses" alongside "chat-completions"; the other 4 copies (token+raw api/tags and api/show) still rejected anything that wasn't literally "chat-completions", silently dropping Codex-discovery-synced GPT models (apiFormat "responses") from the Ollama-compatible /api/tags endpoint VS Code's "Ollama" provider import flow actually calls. Extracted the predicate into a single shared module (vscode/[token]/usableChatModel.ts) imported by all 5 routes so this one-fixed-four-left-behind drift cannot recur. --- .../7587-vscode-models-missing-openai.md | 1 + .../api/v1/vscode/[token]/api/show/route.ts | 30 +--- .../api/v1/vscode/[token]/api/tags/route.ts | 30 +--- src/app/api/v1/vscode/[token]/models/route.ts | 42 +---- .../api/v1/vscode/[token]/usableChatModel.ts | 63 ++++++++ .../v1/vscode/raw/[token]/api/show/route.ts | 30 +--- .../v1/vscode/raw/[token]/api/tags/route.ts | 30 +--- ...ode-token-routes-responses-listing.test.ts | 153 ++++++++++++++++++ 8 files changed, 222 insertions(+), 157 deletions(-) create mode 100644 changelog.d/fixes/7587-vscode-models-missing-openai.md create mode 100644 src/app/api/v1/vscode/[token]/usableChatModel.ts create mode 100644 tests/unit/vscode-token-routes-responses-listing.test.ts diff --git a/changelog.d/fixes/7587-vscode-models-missing-openai.md b/changelog.d/fixes/7587-vscode-models-missing-openai.md new file mode 100644 index 0000000000..c5fd715438 --- /dev/null +++ b/changelog.d/fixes/7587-vscode-models-missing-openai.md @@ -0,0 +1 @@ +- fix(api): expose Responses-API-format (OpenAI/Codex) chat models on every VS Code Ollama-compatible listing route, not just `/models` (#7587) diff --git a/src/app/api/v1/vscode/[token]/api/show/route.ts b/src/app/api/v1/vscode/[token]/api/show/route.ts index f48a500ea8..37f6a92098 100644 --- a/src/app/api/v1/vscode/[token]/api/show/route.ts +++ b/src/app/api/v1/vscode/[token]/api/show/route.ts @@ -16,6 +16,7 @@ import { } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstModelCandidates, getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -33,35 +34,6 @@ type OpenAiCatalogModel = { supported_endpoints?: string[]; }; -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getCatalogModelId(model: OpenAiCatalogModel) { return model.id || model.name || model.root || "unknown"; } diff --git a/src/app/api/v1/vscode/[token]/api/tags/route.ts b/src/app/api/v1/vscode/[token]/api/tags/route.ts index 81273c909b..c7a061132a 100644 --- a/src/app/api/v1/vscode/[token]/api/tags/route.ts +++ b/src/app/api/v1/vscode/[token]/api/tags/route.ts @@ -19,6 +19,7 @@ import { } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -64,35 +65,6 @@ async function selectPreferredModels(models: OpenAiCatalogModel[]) { return codexModels.length > 0 ? codexModels : models; } -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { const rawModelId = getModelName(model).trim(); const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); diff --git a/src/app/api/v1/vscode/[token]/models/route.ts b/src/app/api/v1/vscode/[token]/models/route.ts index 5d0e39fad5..450373e476 100644 --- a/src/app/api/v1/vscode/[token]/models/route.ts +++ b/src/app/api/v1/vscode/[token]/models/route.ts @@ -25,6 +25,7 @@ import { parseVscodeServiceTierVariantModelId, } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type CatalogModelEntry = { id?: string; @@ -72,12 +73,6 @@ type EnrichModelForVscodeOptions = { preserveNativeId?: boolean; }; -const TEXT_GENERATION_API_FORMATS = new Set([ - "chat-completions", - "responses", - "openai-responses", -]); - function usesResponsesApi(model: CatalogModelEntry) { return ( model.api_format === "responses" || @@ -86,41 +81,6 @@ function usesResponsesApi(model: CatalogModelEntry) { ); } -function excludesChatAndResponsesEndpoints(model: CatalogModelEntry) { - return ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") && - !model.supported_endpoints.includes("responses") - ); -} - -function excludesTextOutputModality(model: CatalogModelEntry) { - return ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ); -} - -function isUsableChatModel(model: CatalogModelEntry) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - if ( - typeof model.api_format === "string" && - !TEXT_GENERATION_API_FORMATS.has(model.api_format) - ) { - return false; - } - if (excludesChatAndResponsesEndpoints(model)) return false; - if (excludesTextOutputModality(model)) return false; - - return true; -} - function getModelImportReasoningEffortValues(model: VscodeCatalogModel, reasoningEffortValues: string[]) { const providerId = (model.owned_by || "").trim() || diff --git a/src/app/api/v1/vscode/[token]/usableChatModel.ts b/src/app/api/v1/vscode/[token]/usableChatModel.ts new file mode 100644 index 0000000000..744bf7e070 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/usableChatModel.ts @@ -0,0 +1,63 @@ +// Shared "is this catalog model usable as a VS Code chat model" filter. +// +// Historically this predicate was copy-pasted independently into every VS +// Code listing route (models, api/tags, api/show — both the token-prefixed +// and `raw` token variants). PR #7012 widened only the `models/route.ts` +// copy to accept Responses-API-format models (api_format "responses" / +// "openai-responses", or supported_endpoints containing "responses") +// alongside plain "chat-completions" models — the other 4 copies were left +// on the old, stricter filter, silently hiding OpenAI/Codex "responses" +// models from every Ollama-compatible listing endpoint (#7587). +// +// Centralizing the predicate here means a future widening only has to +// happen once. + +export type UsableChatModelCandidate = { + owned_by?: string; + parent?: string | null; + type?: string; + api_format?: string; + supported_endpoints?: string[]; + output_modalities?: string[]; +}; + +export const TEXT_GENERATION_API_FORMATS = new Set([ + "chat-completions", + "responses", + "openai-responses", +]); + +function excludesChatAndResponsesEndpoints(model: UsableChatModelCandidate) { + return ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") && + !model.supported_endpoints.includes("responses") + ); +} + +function excludesTextOutputModality(model: UsableChatModelCandidate) { + return ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ); +} + +export function isUsableChatModel(model: UsableChatModelCandidate) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + if ( + typeof model.api_format === "string" && + !TEXT_GENERATION_API_FORMATS.has(model.api_format) + ) { + return false; + } + if (excludesChatAndResponsesEndpoints(model)) return false; + if (excludesTextOutputModality(model)) return false; + + return true; +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/show/route.ts b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts index 5c43ab18a4..c6fe788f9c 100644 --- a/src/app/api/v1/vscode/raw/[token]/api/show/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts @@ -14,6 +14,7 @@ import { import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; import { getFamilyFirstModelCandidates } from "@/app/api/v1/vscode/raw/[token]/familyFirstModelIds"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -31,35 +32,6 @@ type OpenAiCatalogModel = { supported_endpoints?: string[]; }; -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getCatalogModelId(model: OpenAiCatalogModel) { return model.id || model.name || model.root || "unknown"; } diff --git a/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts index 830cab45da..08cadb8636 100644 --- a/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts +++ b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts @@ -14,6 +14,7 @@ import { } from "@/app/api/v1/vscode/raw/[token]/reasoningMetadata"; import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; +import { isUsableChatModel } from "@/app/api/v1/vscode/[token]/usableChatModel"; type OpenAiCatalogModel = { id?: string; @@ -59,35 +60,6 @@ async function selectPreferredModels(models: OpenAiCatalogModel[]) { return codexModels.length > 0 ? codexModels : models; } -function isUsableChatModel(model: OpenAiCatalogModel) { - if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { - return false; - } - if (typeof model.parent === "string" && model.parent.length > 0) return false; - if (typeof model.type === "string" && model.type !== "chat") return false; - - const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; - if (apiFormat !== "chat-completions") return false; - - if ( - Array.isArray(model.supported_endpoints) && - model.supported_endpoints.length > 0 && - !model.supported_endpoints.includes("chat") - ) { - return false; - } - - if ( - Array.isArray(model.output_modalities) && - model.output_modalities.length > 0 && - !model.output_modalities.includes("text") - ) { - return false; - } - - return true; -} - function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { const rawModelId = getModelName(model).trim(); const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); diff --git a/tests/unit/vscode-token-routes-responses-listing.test.ts b/tests/unit/vscode-token-routes-responses-listing.test.ts new file mode 100644 index 0000000000..db08e1f77a --- /dev/null +++ b/tests/unit/vscode-token-routes-responses-listing.test.ts @@ -0,0 +1,153 @@ +// #7587: PR #7012 widened only the `/models` route's isUsableChatModel copy to +// accept Responses-API-format models (e.g. Codex-discovery-synced GPT models with +// apiFormat "responses"); the other 4 duplicated copies (tags/show, token + raw) +// still rejected anything that wasn't literally "chat-completions", silently +// dropping every OpenAI/Codex "responses" model from the Ollama-compatible +// listing endpoints VS Code's "Ollama" provider import flow actually uses. +// +// Split into its own file (rather than appended to vscode-token-routes.test.ts) +// because that file is frozen by check-file-size.mjs's testFrozen baseline. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync( + path.join(os.tmpdir(), "omniroute-vscode-responses-listing-") +); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "vscode-responses-listing-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const vscodeModelsRoute = await import("../../src/app/api/v1/vscode/[token]/models/route.ts"); +const vscodeTagsRoute = await import("../../src/app/api/v1/vscode/[token]/api/tags/route.ts"); +const vscodeShowRoute = await import("../../src/app/api/v1/vscode/[token]/api/show/route.ts"); +const vscodeRawTagsRoute = + await import("../../src/app/api/v1/vscode/raw/[token]/api/tags/route.ts"); +const vscodeRawShowRoute = + await import("../../src/app/api/v1/vscode/raw/[token]/api/show/route.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("vscode Ollama-compatible tags/show routes (token + raw) expose Codex-discovered responses-format GPT models", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + // Note: the id deliberately avoids the "gpt-5.4" family — it collides with + // CODEX_DISCOVERY_EXCLUDED_ID_PREFIXES (src/shared/services/codexDiscoveryPolicy.ts) + // and would be dropped from the catalog before ever reaching the listing filter + // this test targets. + const connection = await providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: "codex-vscode-responses-listing", + apiKey: "sk-test", + isActive: true, + testStatus: "active", + providerSpecificData: {}, + }); + await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [ + { + id: "gpt-6.9-responses-probe", + name: "GPT 6.9 Responses Probe", + apiFormat: "responses", + supportedEndpoints: ["responses"], + inputTokenLimit: 400000, + outputTokenLimit: 128000, + }, + ]); + const key = await apiKeysDb.createApiKey( + "vscode-responses-listing", + "machine-vscode-responses-listing" + ); + + const modelsResponse = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const modelsBody = (await modelsResponse.json()) as { + data?: Array<{ id?: string; owned_by?: string; api_format?: string }>; + }; + const responsesModel = (modelsBody.data || []).find( + (model) => model.owned_by === "codex" && model.api_format === "responses" + ); + assert.ok( + responsesModel, + "precondition failed: expected the responses-format GPT model on /models (PR #7012 fix)" + ); + + const tagsResponse = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const tagsBody = (await tagsResponse.json()) as { models?: Array<{ name?: string }> }; + const tagNames = new Set((tagsBody.models || []).map((model) => model.name)); + assert.ok( + tagNames.has(responsesModel!.id), + `expected /api/tags (Ollama flow) to also expose the responses-format GPT model, got: ${JSON.stringify( + Array.from(tagNames) + )}` + ); + + const rawTagsResponse = await vscodeRawTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/tags`) + ); + const rawTagsBody = (await rawTagsResponse.json()) as { models?: Array<{ name?: string }> }; + const rawTagNames: string[] = (rawTagsBody.models || []) + .map((model) => model.name) + .filter((name): name is string => typeof name === "string"); + const rawResponsesModelName = rawTagNames.find((name) => + name.includes("gpt-6.9-responses-probe") + ); + assert.ok( + rawResponsesModelName, + `expected raw /api/tags to also expose the responses-format GPT model, got: ${JSON.stringify( + rawTagNames + )}` + ); + + const showResponse = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + body: JSON.stringify({ name: responsesModel!.id }), + }) + ); + assert.equal( + showResponse.status, + 200, + "expected /api/show to find the responses-format GPT model by its tag name" + ); + + const rawShowResponse = await vscodeRawShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + body: JSON.stringify({ name: rawResponsesModelName }), + }) + ); + assert.equal( + rawShowResponse.status, + 200, + "expected raw /api/show to find the responses-format GPT model by its tag name" + ); +});