fix(config): read server input modalities in openai-models-list discovery
discoverOpenAIModelsList never consulted the /v1/models row's input field, so custom virtual tier ids absent from the bundled catalog fell through to the ["text"] fallback and showed images: no even when the server advertised input: ["text","image"]. Parse the direct input array and OpenRouter-style architecture.input_modalities from the response row, preferring server-reported modalities over native metadata and the bundled reference. Fixes #7583
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `openai-models-list` discovery ignoring server-advertised input modalities, so custom virtual tier IDs absent from the bundled catalog showed `images: no` even when the `/v1/models` response reported `input: ["text","image"]` ([#7583](https://github.com/can1357/oh-my-pi/issues/7583)).
|
||||
|
||||
## [17.2.7] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -724,6 +724,30 @@ export async function discoverLlamaCppModelRuntimeMetadata(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read image-input support from an OpenAI-compatible `/v1/models` row. Handles
|
||||
* the direct `input: ["text","image"]` array thin proxies emit and the
|
||||
* OpenRouter-style `architecture.input_modalities` array; returns undefined when
|
||||
* neither is present so the bundled reference (or the `["text"]` default) can
|
||||
* take over.
|
||||
*/
|
||||
function extractOpenAIModelsListInputCapabilities(item: {
|
||||
input?: unknown;
|
||||
architecture?: unknown;
|
||||
}): ("text" | "image")[] | undefined {
|
||||
const modalities = new Set<string>();
|
||||
const collect = (value: unknown): void => {
|
||||
if (!Array.isArray(value)) return;
|
||||
for (const entry of value) {
|
||||
if (typeof entry === "string") modalities.add(entry.toLowerCase());
|
||||
}
|
||||
};
|
||||
collect(item.input);
|
||||
if (isRecord(item.architecture)) collect(item.architecture.input_modalities);
|
||||
if (modalities.size === 0) return undefined;
|
||||
return modalities.has("image") ? ["text", "image"] : ["text"];
|
||||
}
|
||||
|
||||
export async function discoverOpenAIModelsList(
|
||||
providerConfig: DiscoveryProviderConfig,
|
||||
ctx: DiscoveryContext,
|
||||
@@ -752,7 +776,13 @@ export async function discoverOpenAIModelsList(
|
||||
}
|
||||
headers = h;
|
||||
return (await res.json()) as {
|
||||
data?: Array<{ id?: string; max_model_len?: unknown; context_length?: unknown }>;
|
||||
data?: Array<{
|
||||
id?: string;
|
||||
max_model_len?: unknown;
|
||||
context_length?: unknown;
|
||||
input?: unknown;
|
||||
architecture?: unknown;
|
||||
}>;
|
||||
};
|
||||
}),
|
||||
nativeMetadataPromise,
|
||||
@@ -796,7 +826,9 @@ export async function discoverOpenAIModelsList(
|
||||
baseUrl,
|
||||
reasoning: reference?.reasoning ?? false,
|
||||
thinking: inheritReferenceThinking(undefined, reference, providerConfig.provider),
|
||||
input: nativeMetadataForModel?.input ?? reference?.input ?? ["text"],
|
||||
input: extractOpenAIModelsListInputCapabilities(item) ??
|
||||
nativeMetadataForModel?.input ??
|
||||
reference?.input ?? ["text"],
|
||||
...(providerConfig.discovery.type === "lm-studio" ? { imageInputDecoder: "stb" as const } : {}),
|
||||
// Proxy/gateway pricing is provider-specific and rarely matches
|
||||
// upstream bundled catalogs, so keep costs local-unknown even
|
||||
|
||||
@@ -2172,6 +2172,46 @@ providers:
|
||||
expect(unknown?.reasoning).toBe(false);
|
||||
});
|
||||
|
||||
test("openai-models-list discovery reads server-advertised input modalities for ids absent from the catalog", async () => {
|
||||
writeRawModelsJson({
|
||||
"openai-test": {
|
||||
baseUrl: "http://127.0.0.1:9996",
|
||||
api: "openai-completions",
|
||||
auth: "none",
|
||||
discovery: { type: "openai-models-list" },
|
||||
},
|
||||
});
|
||||
const fetchMock: FetchImpl = async input => {
|
||||
const url = String(input);
|
||||
if (url === "http://127.0.0.1:9996/v1/models") {
|
||||
// Custom virtual tier ids that are absent from the bundled
|
||||
// catalog: their vision support can only come from the server row.
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ id: "high", object: "model", input: ["text", "image"] },
|
||||
{ id: "leftover", object: "model", architecture: { input_modalities: ["text", "image"] } },
|
||||
{ id: "low", object: "model", input: ["text"] },
|
||||
{ id: "medium", object: "model" },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath, { fetch: fetchMock });
|
||||
await registry.refresh();
|
||||
// Direct `input` array and OpenRouter-style `architecture.input_modalities`
|
||||
// both surface vision support.
|
||||
expect(registry.find("openai-test", "high")?.input).toEqual(["text", "image"]);
|
||||
expect(registry.find("openai-test", "leftover")?.input).toEqual(["text", "image"]);
|
||||
// Server explicitly reports text-only; no image support invented.
|
||||
expect(registry.find("openai-test", "low")?.input).toEqual(["text"]);
|
||||
// Silent server → default text-only fallback.
|
||||
expect(registry.find("openai-test", "medium")?.input).toEqual(["text"]);
|
||||
});
|
||||
|
||||
test("proxy discovery honors API-reported context_length and endpoint routing", async () => {
|
||||
writeRawModelsJson({
|
||||
"proxy-test": {
|
||||
|
||||
Reference in New Issue
Block a user