Skip to content
9 changes: 8 additions & 1 deletion scripts/quality/validate-release-green.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,14 @@ export function parseEslintJson(out) {

/** Pull the cognitive-complexity violation count from the gate's output. */
export function parseCognitiveCount(out) {
const m = String(out || "").match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i);
const s = String(out || "");
// `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets, with the
// cyclomatic "N violações" summary emitted FIRST — so a bare `\d+ violações` regex would grab
// the cyclomatic count. Prefer the unambiguous machine-readable `cognitiveComplexity=N` line
// (mirrors the cyclomatic `complexity=N` parse used for cycCurrent below).
const machine = s.match(/(?:^|\n)cognitiveComplexity=(\d+)/);
if (machine) return Number(machine[1]);
const m = s.match(/(\d+)\s+(?:function\(s\) exceed|violações|violations)/i);
return m ? Number(m[1]) : null;
}

Expand Down
15 changes: 10 additions & 5 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,12 +860,17 @@ async function buildUnifiedModelsResponseCore(
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
// `/v1/models`) return image/diffusion models with no modality info,
// so `endpoints` below would default to ["chat"] and misrepresent
// them as chat-capable. Skip any synced model that is already a
// registered image model for this provider — getAllImageModels()
// below adds the correctly-typed `type: "image"` entry instead.
// them as chat-capable. Skip a registered image model only when its
// synced metadata does not explicitly advertise a chat endpoint.
// Multi-capability models may intentionally share an id between the
// chat and image catalogs; getAllImageModels() adds the image entry.
const explicitlySupportsChat = sm.supportedEndpoints?.some(
(endpoint) => endpoint === "chat" || endpoint === "responses"
);
if (
isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id)
!explicitlySupportsChat &&
(isRegisteredImageModel(canonicalProviderId, sm.id) ||
isRegisteredImageModel(providerId, sm.id))
) {
continue;
}
Expand Down
52 changes: 43 additions & 9 deletions tests/unit/image-model-not-in-chat-catalog-6457.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
// `type: "image"` by the imageRegistry loop — and catalogDedupe.ts keys on
// (id, type, subtype), so the two distinct-`type` entries both survived.
//
// Fix: skip a synced model in the chat-catalog loop when it is already a registered
// image model for that exact provider (open-sse/config/imageRegistry.ts
// isRegisteredImageModel()) — the imageRegistry loop still adds the correctly-typed
// `type: "image"` entry.
// Fix: skip an exact-provider registered image model from the chat-catalog loop only
// when synced metadata does not explicitly advertise `chat` or `responses`. The image
// registry loop still adds the correctly typed image entry, while multi-capability
// models keep both entries.

import test from "node:test";
import assert from "node:assert/strict";
Expand All @@ -38,6 +38,7 @@ async function resetStorage() {
}

test.beforeEach(async () => {
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
await resetStorage();
});

Expand All @@ -46,19 +47,19 @@ test.after(async () => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

async function seedHuggingFaceConnection() {
async function seedProviderConnection(provider: string) {
return providersDb.createProviderConnection({
provider: "huggingface",
provider,
authType: "apikey",
name: `huggingface-${Math.random().toString(16).slice(2, 8)}`,
apiKey: "hf-key",
name: `${provider}-${Math.random().toString(16).slice(2, 8)}`,
apiKey: `${provider}-key`,
isActive: true,
testStatus: "active",
});
}

test("#6457 image/diffusion model discovered via live sync is NOT listed as a chat model", async () => {
const connection = await seedHuggingFaceConnection();
const connection = await seedProviderConnection("huggingface");

// Simulate what HuggingFace's live `/v1/models` discovery persists for an
// image/diffusion model: no supportedEndpoints/modality info at all — the exact
Expand Down Expand Up @@ -100,3 +101,36 @@ test("#6457 image/diffusion model discovered via live sync is NOT listed as a ch
assert.equal(entry.type, undefined, "the real chat model must not carry a non-chat type");
}
});

test("registered image model with explicit chat endpoints keeps both catalog entries", async () => {
const connection = await seedProviderConnection("codex");

await modelsDb.replaceSyncedAvailableModelsForConnection("codex", connection.id, [
{
id: "gpt-5.6-sol",
name: "GPT 5.6 Sol",
supportedEndpoints: ["responses"],
},
]);

const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models?prefix=alias")
);
assert.equal(response.status, 200);

const body = (await response.json()) as {
data: Array<{ id: string; type?: string; supported_endpoints?: string[] }>;
};
const entries = body.data.filter((model) => model.id.endsWith("/gpt-5.6-sol"));

assert.ok(
entries.some(
(model) => model.type !== "image" && model.supported_endpoints?.includes("responses")
),
"explicit responses support must keep the synced chat entry"
);
assert.ok(
entries.some((model) => model.type === "image"),
"the registered image entry must remain available under the same model id"
);
});
14 changes: 14 additions & 0 deletions tests/unit/validate-release-green.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,20 @@ test("parseCognitiveCount reads the gate's count (en + pt)", () => {
assert.equal(parseCognitiveCount("no number"), null);
});

test("parseCognitiveCount ignores the cyclomatic count in the combined ratchets output (#7009)", () => {
// `check:complexity-ratchets` runs ONE shared ESLint walk and prints BOTH ratchets.
// The cyclomatic "N violações" summary is emitted FIRST, so a bare `\\d+ violações`
// regex captured 2056 (cyclomatic) instead of 890 (cognitive) — a phantom drift in
// every pre-flight report. Prefer the unambiguous machine-readable `cognitiveComplexity=N`.
const combined = [
"complexity=2056",
"cognitiveComplexity=890",
"[complexity] OK — 2056 violações (baseline 2056)",
"[cognitive-complexity] OK — 890 violações (baseline 890)",
].join("\n");
assert.equal(parseCognitiveCount(combined), 890);
});

test("isDrift flags only growth past the committed baseline (down-direction ratchets)", () => {
assert.equal(isDrift(3900, 3867), true); // grew → drift
assert.equal(isDrift(3867, 3867), false); // equal → ok
Expand Down
Loading