diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 7ad985eef..916babd25 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -71,6 +71,32 @@ export function isProviderModelsApiItems(value: unknown): value is ProviderModel ); } +/** + * Normalize OpenAI-compatible /models payloads for catalog discovery. + * Supports `{ data: [...] }` and top-level arrays (Together AI `#617`). + * Google's `{ models: [...] }` is handled by the connectivity probe only — catalog + * discovery must not treat a stray `models` key on openai-chat responses as valid. + */ +export function providerModelsListFromResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const data = (json as { data?: unknown }).data; + if (Array.isArray(data)) return data; + } + return undefined; +} + +/** Connectivity-probe shape: also accepts Google `{ models: [...] }`. */ +export function providerModelsListFromProbeResponse(json: unknown): unknown { + if (Array.isArray(json)) return json; + if (json !== null && typeof json === "object" && !Array.isArray(json)) { + const obj = json as { data?: unknown; models?: unknown }; + if (Array.isArray(obj.data)) return obj.data; + if (Array.isArray(obj.models)) return obj.models; + } + return undefined; +} + export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; @@ -366,9 +392,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig, } return models; } - const data = json !== null && typeof json === "object" && !Array.isArray(json) - ? (json as { data?: unknown }).data - : undefined; + const data = providerModelsListFromResponse(json); if (!isProviderModelsApiItems(data)) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); if (shouldLog) { diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 53d17ff10..b4aa2540d 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -339,12 +340,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise null) as { data?: unknown; models?: unknown } | null; - // OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape - // buildModelsRequest can produce) returns { models: [...] }. - const list = json && typeof json === "object" && !Array.isArray(json) - ? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined) - : undefined; + const json = await res.json().catch(() => null); + // OpenAI-style { data }, Google { models }, and Together-style top-level arrays (#617). + const list = providerModelsListFromProbeResponse(json); if (!Array.isArray(list)) { return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" }); } diff --git a/tests/provider-connection-test.test.ts b/tests/provider-connection-test.test.ts index f4bf64775..55cb7d9e4 100644 --- a/tests/provider-connection-test.test.ts +++ b/tests/provider-connection-test.test.ts @@ -135,6 +135,19 @@ describe("POST /api/providers/test (WP040 connectivity probe)", () => { expect(body.models).toBe(3); }); + test("Together-style top-level /models array is accepted (#617)", async () => { + globalThis.fetch = (async () => new Response(JSON.stringify([{ id: "meta/llama" }, { id: "Qwen/Qwen" }]), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + const config = baseConfig({ + together: { adapter: "openai-chat", baseUrl: "https://api.together.xyz/v1", apiKey: "tg-key" }, + }); + const { body } = await probe(config, "together"); + expect(body.ok).toBe(true); + expect(body.models).toBe(2); + }); + test("malformed 2xx data is an explicit failure, not a silent pass", async () => { globalThis.fetch = (async () => new Response(JSON.stringify({ nope: true }), { status: 200,