mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Compare commits
2 Commits
647afe327b
...
fix/7587-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b87fd70572 | ||
|
|
b0a0bcc727 |
1
changelog.d/fixes/7587-vscode-models-missing-openai.md
Normal file
1
changelog.d/fixes/7587-vscode-models-missing-openai.md
Normal file
@@ -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)
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
63
src/app/api/v1/vscode/[token]/usableChatModel.ts
Normal file
63
src/app/api/v1/vscode/[token]/usableChatModel.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
153
tests/unit/vscode-token-routes-responses-listing.test.ts
Normal file
153
tests/unit/vscode-token-routes-responses-listing.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user