mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* feat(providers): add OpenRouter speech-to-text (audio transcription) provider Adds OpenRouter as a speech-to-text provider for POST /v1/audio/transcriptions. Transcription requests route to OpenRouter's dedicated STT endpoint (https://openrouter.ai/api/v1/audio/transcriptions), converting the multipart audio upload into OpenRouter's JSON input_audio { data, format } shape and forwarding optional language, temperature, and response_format fields. OpenRouter STT reuses your existing OpenRouter connection, so no separate credential is required. Eleven transcription models are available (Deepgram Nova-3, Microsoft MAI-Transcribe 1.5, NVIDIA Parakeet, Mistral Voxtral, Qwen3 ASR, Google Chirp 3, and the OpenAI Whisper / GPT-4o transcribe family). The media-providers STT playground card now narrows its model picker to transcription-only models, so the OpenRouter chat catalog is not shown for speech-to-text, and the provider page shows an existing-connection note for OpenRouter STT. * fix(providers): address OpenRouter STT review feedback - Extract the OpenRouter transcription handler into open-sse/handlers/openrouterTranscription.ts to keep audioTranscription.ts under the file-size cap. - Qualify the STT card's submitted model id with the connection's provider prefix so OpenRouter models route to OpenRouter rather than the model's own vendor (the transcription route resolves the provider from the leading segment of the model id). - Coerce temperature to a number and forward timestamp_granularities on the JSON input_audio payload; match the base MIME type when resolving the audio format so codec parameters (e.g. audio/webm;codecs=opus) do not fall back to wav. - Split the OpenRouter cases into tests/unit/audio-transcription-openrouter.test.ts and add coverage for temperature, timestamp granularities, MIME codec params, and qualified-id routing.
640 lines
21 KiB
TypeScript
640 lines
21 KiB
TypeScript
// Split-guard for the provider-models discovery route decomposition
|
|
// (refactor: extract 4 pure leaves — helpers / normalizers / providerModelsConfig /
|
|
// providerSets — out of src/app/api/providers/[id]/models/route.ts). The leaves are
|
|
// DB-free and state-free; this guard pins their public surface and the host wiring
|
|
// so a future edit that silently breaks the split fails.
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import {
|
|
asRecord,
|
|
toNonEmptyString,
|
|
buildOptionalBearerHeaders,
|
|
buildNamedOpenAiStyleHeaders,
|
|
isLocalOpenAIStyleProvider,
|
|
mergeLocalCatalogModels,
|
|
getAzureOpenAIApiVersion,
|
|
} from "../../src/app/api/providers/[id]/models/discovery/helpers.ts";
|
|
import { normalizeOpenAiLikeModelsResponse } from "../../src/app/api/providers/[id]/models/discovery/normalizers.ts";
|
|
import {
|
|
NAMED_OPENAI_STYLE_PROVIDERS,
|
|
isNamedOpenAIStyleProvider,
|
|
} from "../../src/app/api/providers/[id]/models/discovery/providerSets.ts";
|
|
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
|
|
import { isCodexDiscoveryModelExcluded as isSharedCodexDiscoveryModelExcluded } from "../../src/shared/services/codexDiscoveryPolicy.ts";
|
|
import {
|
|
applyCodexDiscoveryFilters,
|
|
buildCodexDiscoveryCatalog,
|
|
buildCodexModelsUrl,
|
|
CODEX_GITHUB_MODELS_URL,
|
|
CODEX_MODELS_URL,
|
|
clearCodexGithubCatalogCacheForTests,
|
|
enrichCodexModelsFromGithubCatalog,
|
|
fetchCodexDiscoveryModels,
|
|
fetchCodexGithubCatalogModels,
|
|
isCodexDiscoveryModelExcluded,
|
|
mergeCodexLiveModelsWithLocalCatalog,
|
|
normalizeCodexGithubCatalogResponse,
|
|
normalizeCodexModelsResponse,
|
|
reconcileCuratedCodexCatalog,
|
|
} from "../../src/app/api/providers/[id]/models/discovery/codex.ts";
|
|
|
|
// ── helpers leaf ─────────────────────────────────────────────────────────────
|
|
|
|
test("helpers.asRecord returns plain objects untouched, non-objects as {}", () => {
|
|
assert.deepEqual(asRecord({ a: 1 }), { a: 1 });
|
|
assert.deepEqual(asRecord([1, 2]), {});
|
|
assert.deepEqual(asRecord(null), {});
|
|
assert.deepEqual(asRecord("x"), {});
|
|
});
|
|
|
|
test("helpers.toNonEmptyString trims and rejects blanks/non-strings", () => {
|
|
assert.equal(toNonEmptyString(" hi "), "hi");
|
|
assert.equal(toNonEmptyString(" "), null);
|
|
assert.equal(toNonEmptyString(7), null);
|
|
});
|
|
|
|
test("helpers.buildOptionalBearerHeaders only adds Authorization when a token is present", () => {
|
|
assert.deepEqual(buildOptionalBearerHeaders("tok"), {
|
|
"Content-Type": "application/json",
|
|
Authorization: "Bearer tok",
|
|
});
|
|
assert.deepEqual(buildOptionalBearerHeaders(null), { "Content-Type": "application/json" });
|
|
});
|
|
|
|
test("helpers.buildNamedOpenAiStyleHeaders adds the reka X-Api-Key only for reka", () => {
|
|
assert.equal(buildNamedOpenAiStyleHeaders("reka", "tok")["X-Api-Key"], "tok");
|
|
assert.equal(buildNamedOpenAiStyleHeaders("openai", "tok")["X-Api-Key"], undefined);
|
|
});
|
|
|
|
test("helpers.mergeLocalCatalogModels dedupes by id, registry wins", () => {
|
|
const merged = mergeLocalCatalogModels(
|
|
[{ id: "a", name: "A" }],
|
|
[
|
|
{ id: "a", name: "dup" },
|
|
{ id: "b", name: "B" },
|
|
]
|
|
);
|
|
assert.deepEqual(
|
|
merged.map((m) => m.id),
|
|
["a", "b"]
|
|
);
|
|
assert.equal(merged.find((m) => m.id === "a")?.name, "A");
|
|
});
|
|
|
|
test("helpers.getAzureOpenAIApiVersion falls back to the pinned default", () => {
|
|
assert.equal(getAzureOpenAIApiVersion({}), "2024-12-01-preview");
|
|
assert.equal(getAzureOpenAIApiVersion({ apiVersion: "2025-01-01" }), "2025-01-01");
|
|
});
|
|
|
|
test("helpers.isLocalOpenAIStyleProvider is false for a hosted provider", () => {
|
|
assert.equal(isLocalOpenAIStyleProvider("openai"), false);
|
|
});
|
|
|
|
// ── normalizers leaf ─────────────────────────────────────────────────────────
|
|
|
|
test("normalizers.normalizeOpenAiLikeModelsResponse maps ids and applies the fallback owner", () => {
|
|
const out = normalizeOpenAiLikeModelsResponse(
|
|
{ data: [{ id: "m1" }, { id: "m2", display_name: "M2", owned_by: "x" }] },
|
|
"acme"
|
|
);
|
|
assert.deepEqual(out, [
|
|
{ id: "m1", name: "m1", owned_by: "acme" },
|
|
{ id: "m2", name: "M2", owned_by: "x" },
|
|
]);
|
|
});
|
|
|
|
test("normalizers.normalizeOpenAiLikeModelsResponse drops entries without an id", () => {
|
|
const out = normalizeOpenAiLikeModelsResponse({ data: [{}, { id: "ok" }] }, "acme");
|
|
assert.deepEqual(
|
|
out.map((m) => m.id),
|
|
["ok"]
|
|
);
|
|
});
|
|
|
|
// ── providerSets leaf ────────────────────────────────────────────────────────
|
|
|
|
test("providerSets.NAMED_OPENAI_STYLE_PROVIDERS is a populated Set with known members", () => {
|
|
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS instanceof Set);
|
|
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.size >= 30);
|
|
for (const p of ["zenmux", "api-airforce", "together", "reka"]) {
|
|
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.has(p), `${p} must be a named OpenAI-style provider`);
|
|
}
|
|
});
|
|
|
|
test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => {
|
|
assert.equal(isNamedOpenAIStyleProvider("zenmux"), true);
|
|
assert.equal(isNamedOpenAIStyleProvider("definitely-not-a-provider"), false);
|
|
});
|
|
|
|
// ── providerModelsConfig leaf ────────────────────────────────────────────────
|
|
|
|
test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => {
|
|
assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models");
|
|
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models/");
|
|
});
|
|
|
|
test("providerModelsConfig keeps the aimlapi live catalog entry", () => {
|
|
assert.equal(PROVIDER_MODELS_CONFIG.aimlapi.url, "https://api.aimlapi.com/models");
|
|
});
|
|
|
|
test("providerModelsConfig aimlapi.parseResponse keeps only chat-completion models when present", () => {
|
|
const parsed = PROVIDER_MODELS_CONFIG.aimlapi.parseResponse([
|
|
{ id: "chat-1", type: "chat-completion", info: { name: "Chat 1" } },
|
|
{ id: "img-1", type: "image" },
|
|
]);
|
|
assert.deepEqual(parsed, [{ id: "chat-1", name: "Chat 1" }]);
|
|
});
|
|
|
|
test("providerModelsConfig openrouter.parseResponse keeps the full catalog (LLMs not filtered out)", () => {
|
|
// Generic OpenRouter discovery must stay unfiltered so sync/import/pickers
|
|
// and /v1/models keep every LLM. STT narrowing lives on the STT card, not here.
|
|
const data = {
|
|
data: [
|
|
{ id: "openai/gpt-4o", architecture: { modality: "text->text" } },
|
|
{
|
|
id: "openai/whisper-1",
|
|
architecture: { modality: "audio->transcription" },
|
|
},
|
|
],
|
|
};
|
|
const parsed = PROVIDER_MODELS_CONFIG.openrouter.parseResponse(data);
|
|
assert.deepEqual(
|
|
parsed.map((m: { id: string }) => m.id),
|
|
["openai/gpt-4o", "openai/whisper-1"]
|
|
);
|
|
});
|
|
|
|
// ── codex discovery leaf ────────────────────────────────────────────────────
|
|
|
|
test.beforeEach(() => {
|
|
clearCodexGithubCatalogCacheForTests();
|
|
});
|
|
|
|
test("codex.normalizeCodexModelsResponse maps data/models/map payloads to response models", () => {
|
|
assert.deepEqual(normalizeCodexModelsResponse({ data: [{ id: "gpt-5.5", name: "GPT 5.5" }] }), [
|
|
{
|
|
id: "gpt-5.5",
|
|
name: "GPT 5.5",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
},
|
|
]);
|
|
|
|
assert.deepEqual(
|
|
normalizeCodexModelsResponse({
|
|
"gpt-5.4": { title: "GPT 5.4" },
|
|
invalid: null,
|
|
}).map((m) => ({ id: m.id, name: m.name })),
|
|
[{ id: "gpt-5.4", name: "GPT 5.4" }]
|
|
);
|
|
});
|
|
|
|
test("codex.normalizeCodexModelsResponse parses the Codex live catalog shape", () => {
|
|
const parsed = normalizeCodexModelsResponse({
|
|
models: [
|
|
{
|
|
slug: "codex-auto-review",
|
|
display_name: "Codex Auto Review",
|
|
visibility: "hide",
|
|
supported_in_api: true,
|
|
},
|
|
{
|
|
slug: "gpt-5.4",
|
|
display_name: "GPT-5.4",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
context_length: 400000,
|
|
max_output_tokens: 128000,
|
|
service_tiers: [{ id: "priority", name: "Fast" }],
|
|
additional_speed_tiers: ["fast"],
|
|
},
|
|
{
|
|
slug: "gpt-5.5",
|
|
display_name: "GPT-5.5",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
max_input_tokens: 272000,
|
|
top_provider: { max_completion_tokens: 64000 },
|
|
},
|
|
{
|
|
slug: "internal-only",
|
|
display_name: "Internal Only",
|
|
visibility: "list",
|
|
supported_in_api: false,
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
parsed.map((m) => ({ id: m.id, name: m.name })),
|
|
[
|
|
{ id: "gpt-5.4", name: "GPT-5.4" },
|
|
{ id: "gpt-5.5", name: "GPT-5.5" },
|
|
]
|
|
);
|
|
assert.equal(parsed.find((model) => model.id === "gpt-5.4")?.inputTokenLimit, 400000);
|
|
assert.equal(parsed.find((model) => model.id === "gpt-5.4")?.outputTokenLimit, 128000);
|
|
assert.equal(parsed.find((model) => model.id === "gpt-5.5")?.inputTokenLimit, 272000);
|
|
assert.equal(parsed.find((model) => model.id === "gpt-5.5")?.outputTokenLimit, 64000);
|
|
});
|
|
|
|
test("codex.normalizeCodexGithubCatalogResponse parses current client catalog metadata", () => {
|
|
const parsed = normalizeCodexGithubCatalogResponse({
|
|
models: [
|
|
{
|
|
slug: "gpt-5.6-sol",
|
|
display_name: "GPT-5.6-Sol",
|
|
description: "Latest frontier agentic coding model.",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
minimal_client_version: "0.144.0",
|
|
context_window: 372000,
|
|
input_modalities: ["text", "image"],
|
|
supported_reasoning_levels: [{ effort: "low" }, { effort: "ultra" }],
|
|
},
|
|
{
|
|
slug: "future-model",
|
|
display_name: "Future Model",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
minimal_client_version: "999.0.0",
|
|
},
|
|
{
|
|
slug: "codex-auto-review",
|
|
display_name: "Codex Auto Review",
|
|
visibility: "hide",
|
|
supported_in_api: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
parsed.map((model) => model.id),
|
|
["gpt-5.6-sol"]
|
|
);
|
|
assert.equal(parsed[0]?.description, "Latest frontier agentic coding model.");
|
|
assert.equal(parsed[0]?.inputTokenLimit, 372000);
|
|
assert.equal(parsed[0]?.supportsThinking, true);
|
|
assert.equal(parsed[0]?.supportsVision, true);
|
|
});
|
|
|
|
test("codex.enrichCodexModelsFromGithubCatalog keeps live entitlement list authoritative", () => {
|
|
const enriched = enrichCodexModelsFromGithubCatalog(
|
|
[
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "Live Sol",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "GitHub Sol",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
inputTokenLimit: 372000,
|
|
supportsVision: true,
|
|
},
|
|
{
|
|
id: "gpt-5.6-luna",
|
|
name: "GitHub Luna",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
},
|
|
]
|
|
);
|
|
|
|
assert.deepEqual(
|
|
enriched.map((model) => model.id),
|
|
["gpt-5.6-sol"]
|
|
);
|
|
assert.equal(enriched[0]?.name, "Live Sol");
|
|
assert.equal(enriched[0]?.inputTokenLimit, 372000);
|
|
assert.equal(enriched[0]?.supportsVision, true);
|
|
});
|
|
|
|
test("codex.mergeCodexLiveModelsWithLocalCatalog merges capacity limits conservatively (smaller wins)", () => {
|
|
const merged = mergeCodexLiveModelsWithLocalCatalog(
|
|
[
|
|
{
|
|
id: "future-codex-model",
|
|
name: "Future Codex Model",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
},
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "Live Sol",
|
|
owned_by: "codex",
|
|
apiFormat: "responses",
|
|
supportedEndpoints: ["responses"],
|
|
inputTokenLimit: 272000,
|
|
supportsVision: true,
|
|
},
|
|
{
|
|
id: "gpt-5.5",
|
|
name: "Live GPT 5.5",
|
|
inputTokenLimit: 300000,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "GPT 5.6 Sol",
|
|
contextLength: 372000,
|
|
maxInputTokens: 372000,
|
|
maxOutputTokens: 128000,
|
|
},
|
|
{ id: "gpt-5.6-sol-low", name: "GPT 5.6 Sol (Low)", contextLength: 372000 },
|
|
{ id: "gpt-5.5", name: "GPT 5.5", maxInputTokens: 272000 },
|
|
]
|
|
);
|
|
|
|
const ids = merged.map((model) => model.id);
|
|
assert.ok(ids.includes("future-codex-model"));
|
|
assert.ok(ids.includes("gpt-5.6-sol"));
|
|
assert.ok(ids.includes("gpt-5.6-sol-low"));
|
|
const sol = merged.find((model) => model.id === "gpt-5.6-sol");
|
|
assert.equal(sol?.name, "Live Sol");
|
|
// Live (272000) is SMALLER than the pinned contract (372000) here — the
|
|
// smaller value wins so OmniRoute never promises more context than the
|
|
// live account can actually serve (#7012).
|
|
assert.equal(sol?.inputTokenLimit, 272000);
|
|
assert.equal(sol?.supportsVision, true);
|
|
// Output limit is pinned-only (live has none) — passes through unchanged.
|
|
assert.equal(sol?.outputTokenLimit, 128000);
|
|
assert.equal(
|
|
merged.find((model) => model.id === "gpt-5.5")?.inputTokenLimit,
|
|
272000,
|
|
"capacity limits merge conservatively for all Codex models, not only the pinned GPT-5.6 ids — the smaller of live (300000) vs. pinned (272000) wins"
|
|
);
|
|
});
|
|
|
|
test("codex discovery filters drop the GPT-5.4 family but keep other remote models", () => {
|
|
assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.4", name: "x" }), true);
|
|
assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.4-mini", name: "x" }), true);
|
|
assert.equal(isCodexDiscoveryModelExcluded({ id: "gpt-5.6-sol", name: "x" }), false);
|
|
|
|
const filtered = applyCodexDiscoveryFilters([
|
|
{ id: "gpt-5.4", name: "Retired" },
|
|
{ id: "gpt-5.4-pro", name: "Retired Pro" },
|
|
{ id: "future-codex-model", name: "Future" },
|
|
{ id: "gpt-5.6-sol", name: "Sol" },
|
|
]);
|
|
assert.deepEqual(
|
|
filtered.map((model) => model.id),
|
|
["future-codex-model", "gpt-5.6-sol"]
|
|
);
|
|
});
|
|
|
|
test("shared Codex discovery policy only matches explicit GPT-5.4 family boundaries", () => {
|
|
for (const id of ["GPT-5.4", "gpt-5.4-mini", "gpt-5.4_preview", "gpt-5.4.1"]) {
|
|
assert.equal(isSharedCodexDiscoveryModelExcluded({ id }), true, id);
|
|
}
|
|
for (const id of ["gpt-5.40", "gpt-5.4x", "future-codex-model"]) {
|
|
assert.equal(isSharedCodexDiscoveryModelExcluded({ id }), false, id);
|
|
}
|
|
});
|
|
|
|
test("codex.buildCodexDiscoveryCatalog merges then filters in one step", () => {
|
|
const catalog = buildCodexDiscoveryCatalog(
|
|
[
|
|
{ id: "gpt-5.4", name: "Retired Live" },
|
|
{ id: "brand-new-codex", name: "Brand New" },
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "Live Sol",
|
|
inputTokenLimit: 111,
|
|
supportsVision: true,
|
|
},
|
|
],
|
|
[
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "GPT 5.6 Sol",
|
|
maxInputTokens: 372000,
|
|
maxOutputTokens: 128000,
|
|
},
|
|
{ id: "gpt-5.6-sol-max", name: "GPT 5.6 Sol Max" },
|
|
]
|
|
);
|
|
const ids = catalog.map((model) => model.id);
|
|
assert.ok(ids.includes("brand-new-codex"));
|
|
assert.ok(ids.includes("gpt-5.6-sol"));
|
|
assert.ok(ids.includes("gpt-5.6-sol-max"));
|
|
assert.equal(
|
|
ids.some((id) => String(id).startsWith("gpt-5.4")),
|
|
false
|
|
);
|
|
|
|
// Optional curated helper still available for diagnostics only.
|
|
const curated = reconcileCuratedCodexCatalog(
|
|
[{ id: "brand-new-codex", name: "Brand New" }],
|
|
[{ id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }]
|
|
);
|
|
assert.deepEqual(
|
|
curated.models.map((model) => model.id),
|
|
["gpt-5.6-sol"]
|
|
);
|
|
assert.deepEqual(
|
|
curated.candidateModels.map((model) => model.id),
|
|
["brand-new-codex"]
|
|
);
|
|
});
|
|
|
|
test("codex.normalizeCodexModelsResponse drops entries without an id", () => {
|
|
const parsed = normalizeCodexModelsResponse({ models: [{ name: "" }, { model: "gpt-5.4" }] });
|
|
assert.deepEqual(
|
|
parsed.map((m) => m.id),
|
|
["gpt-5.4"]
|
|
);
|
|
});
|
|
|
|
test("codex.fetchCodexDiscoveryModels returns null for missing token, auth failure, empty, or network error", async () => {
|
|
assert.equal(
|
|
await fetchCodexDiscoveryModels({
|
|
accessToken: null,
|
|
fetchImpl: async () => Response.json({ data: [{ id: "never-called" }] }),
|
|
}),
|
|
null
|
|
);
|
|
|
|
assert.equal(
|
|
await fetchCodexDiscoveryModels({
|
|
accessToken: "tok",
|
|
fetchImpl: async () => new Response("unauthorized", { status: 401 }),
|
|
}),
|
|
null
|
|
);
|
|
|
|
assert.equal(
|
|
await fetchCodexDiscoveryModels({
|
|
accessToken: "tok",
|
|
fetchImpl: async () => Response.json({ data: [] }),
|
|
}),
|
|
null
|
|
);
|
|
|
|
assert.equal(
|
|
await fetchCodexDiscoveryModels({
|
|
accessToken: "tok",
|
|
fetchImpl: async () => {
|
|
throw new Error("network down");
|
|
},
|
|
}),
|
|
null
|
|
);
|
|
});
|
|
|
|
test("codex.fetchCodexDiscoveryModels calls the Codex models endpoint with Codex bearer headers", async () => {
|
|
let seenUrl = "";
|
|
let seenAuthorization = "";
|
|
let seenWorkspace = "";
|
|
let seenOriginator = "";
|
|
const models = await fetchCodexDiscoveryModels({
|
|
accessToken: "codex-access",
|
|
providerSpecificData: { chatgptAccountId: "account-123" },
|
|
fetchImpl: async (url, init) => {
|
|
seenUrl = url;
|
|
seenAuthorization = init.headers.Authorization;
|
|
seenWorkspace = init.headers["chatgpt-account-id"];
|
|
seenOriginator = init.headers.originator;
|
|
return Response.json({ models: [{ slug: "gpt-5.6", display_name: "GPT 5.6" }] });
|
|
},
|
|
});
|
|
|
|
assert.equal(CODEX_MODELS_URL, "https://chatgpt.com/backend-api/codex/models");
|
|
assert.equal(seenUrl, buildCodexModelsUrl());
|
|
assert.equal(seenAuthorization, "Bearer codex-access");
|
|
assert.equal(seenWorkspace, "account-123");
|
|
assert.equal(seenOriginator, "codex_cli_rs");
|
|
assert.deepEqual(
|
|
models?.map((m) => m.id),
|
|
["gpt-5.6"]
|
|
);
|
|
});
|
|
|
|
test("codex.fetchCodexGithubCatalogModels fetches the OpenAI Codex repo catalog", async () => {
|
|
let seenUrl = "";
|
|
const models = await fetchCodexGithubCatalogModels({
|
|
fetchImpl: async (url) => {
|
|
seenUrl = url;
|
|
return Response.json({
|
|
models: [
|
|
{
|
|
slug: "gpt-5.6-terra",
|
|
display_name: "GPT-5.6-Terra",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
minimal_client_version: "0.144.0",
|
|
},
|
|
],
|
|
});
|
|
},
|
|
});
|
|
|
|
assert.equal(
|
|
CODEX_GITHUB_MODELS_URL,
|
|
"https://raw.githubusercontent.com/openai/codex/refs/heads/main/codex-rs/models-manager/models.json"
|
|
);
|
|
assert.equal(seenUrl, CODEX_GITHUB_MODELS_URL);
|
|
assert.deepEqual(
|
|
models?.map((model) => model.id),
|
|
["gpt-5.6-terra"]
|
|
);
|
|
});
|
|
|
|
test("codex.fetchCodexGithubCatalogModels reuses cached catalog with ETags", async () => {
|
|
const calls: Array<{ url: string; ifNoneMatch?: string }> = [];
|
|
const first = await fetchCodexGithubCatalogModels({
|
|
now: 1000,
|
|
cacheTtlMs: 100,
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url, ifNoneMatch: init.headers["If-None-Match"] });
|
|
return Response.json(
|
|
{
|
|
models: [
|
|
{
|
|
slug: "gpt-5.6-luna",
|
|
display_name: "GPT-5.6-Luna",
|
|
visibility: "list",
|
|
supported_in_api: true,
|
|
minimal_client_version: "0.144.0",
|
|
},
|
|
],
|
|
},
|
|
{ headers: { etag: "catalog-v1" } }
|
|
);
|
|
},
|
|
});
|
|
const second = await fetchCodexGithubCatalogModels({
|
|
now: 1050,
|
|
cacheTtlMs: 100,
|
|
fetchImpl: async () => {
|
|
throw new Error("cache hit should not fetch");
|
|
},
|
|
});
|
|
const third = await fetchCodexGithubCatalogModels({
|
|
now: 1200,
|
|
cacheTtlMs: 100,
|
|
fetchImpl: async (url, init) => {
|
|
calls.push({ url, ifNoneMatch: init.headers["If-None-Match"] });
|
|
return new Response(null, { status: 304 });
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(
|
|
first?.map((model) => model.id),
|
|
["gpt-5.6-luna"]
|
|
);
|
|
assert.deepEqual(
|
|
second?.map((model) => model.id),
|
|
["gpt-5.6-luna"]
|
|
);
|
|
assert.deepEqual(
|
|
third?.map((model) => model.id),
|
|
["gpt-5.6-luna"]
|
|
);
|
|
assert.deepEqual(calls, [
|
|
{ url: CODEX_GITHUB_MODELS_URL, ifNoneMatch: undefined },
|
|
{ url: CODEX_GITHUB_MODELS_URL, ifNoneMatch: "catalog-v1" },
|
|
]);
|
|
});
|
|
|
|
// ── host wiring guard ────────────────────────────────────────────────────────
|
|
|
|
test("route.ts imports the discovery leaves and no longer declares the moved consts", () => {
|
|
const route = fs.readFileSync(
|
|
path.join("src", "app", "api", "providers", "[id]", "models", "route.ts"),
|
|
"utf-8"
|
|
);
|
|
for (const leaf of ["helpers", "normalizers", "providerSets", "providerModelsConfig", "codex"]) {
|
|
assert.match(
|
|
route,
|
|
new RegExp(`from "\\./discovery/${leaf}"`),
|
|
`route must import ./discovery/${leaf}`
|
|
);
|
|
}
|
|
assert.doesNotMatch(
|
|
route,
|
|
/const PROVIDER_MODELS_CONFIG\s*:/,
|
|
"PROVIDER_MODELS_CONFIG must live in the leaf, not route.ts"
|
|
);
|
|
assert.doesNotMatch(
|
|
route,
|
|
/const NAMED_OPENAI_STYLE_PROVIDERS\s*=/,
|
|
"NAMED_OPENAI_STYLE_PROVIDERS must live in the leaf, not route.ts"
|
|
);
|
|
});
|