From 6ec9987ac4f219425234fbfdc303b895c4a5b27b Mon Sep 17 00:00:00 2001 From: Patrick Wozniak Date: Thu, 15 Oct 2026 22:47:56 +0200 Subject: [PATCH] fix(models): parse explicit CLI input modalities --- .../check-commandcode-model-metadata.ts | 54 ++++++++++--------- CHANGELOG.md | 4 ++ tests/test-model-metadata-check.ts | 46 ++++++++++++++-- 3 files changed, 75 insertions(+), 29 deletions(-) diff --git a/.github/scripts/check-commandcode-model-metadata.ts b/.github/scripts/check-commandcode-model-metadata.ts index d63fb00..be8d652 100644 --- a/.github/scripts/check-commandcode-model-metadata.ts +++ b/.github/scripts/check-commandcode-model-metadata.ts @@ -17,7 +17,6 @@ import { const execFileAsync = promisify(execFile) const MODELS_REFERENCE_PATH = "dist/bundled/command-code-knowledge/reference/models.md" const CLI_BUNDLE_PATH = "dist/cli.mjs" -const TEXT_ONLY_MARKER = ',__name(isKnownTextOnlyModel,"isKnownTextOnlyModel")' const VALID_EFFORTS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]) function quoteWindowsArgument(argument: string): string { @@ -136,26 +135,8 @@ export function parseModelsReference(markdown: string): { } } -export function parseKnownTextOnlyModelIds(bundle: string): readonly string[] { - const markerIndex = bundle.indexOf(TEXT_ONLY_MARKER) - if (markerIndex < 0) { - throw new Error("Could not find Command Code's isKnownTextOnlyModel catalog") - } - - const setStart = bundle.lastIndexOf("new Set([", markerIndex) - if (setStart < 0) throw new Error("Could not find the text-only model set") - - const arrayStart = setStart + "new Set(".length - const arrayEnd = markerIndex - 1 - const literal = bundle.slice(arrayStart, arrayEnd) - const parsed: unknown = JSON.parse(literal) - if (!isStringArray(parsed)) throw new Error("Expected the text-only model catalog to be strings") - - return sorted(new Set(parsed)) -} - function modelObject(bundle: string, modelId: string): string { - const start = bundle.indexOf(`{id:${JSON.stringify(modelId)},inputModalities:`) + const start = bundle.indexOf(`{id:${JSON.stringify(modelId)},`) if (start < 0) throw new Error(`Could not find model metadata for ${modelId}`) let depth = 0 @@ -184,14 +165,27 @@ export function parseBundleModelCapabilities( bundle: string, modelIds: readonly string[], ): { + imageModelIds: readonly string[] reasoningModelIds: readonly string[] maxOutputTokens: Readonly> } { + const imageModelIds: string[] = [] const reasoningModelIds: string[] = [] const maxOutputTokens: Record = {} for (const modelId of modelIds) { const entry = modelObject(bundle, modelId) + const literal = /\binputModalities:(\[[^\]]*\])/.exec(entry)?.[1] + if (!literal) throw new Error(`Missing input modalities for ${modelId}`) + const modalities: unknown = JSON.parse(literal) + if ( + !isStringArray(modalities) || + !modalities.includes("text") || + modalities.some((value) => !["text", "image", "file", "audio", "video"].includes(value)) + ) { + throw new Error(`Unexpected input modalities for ${modelId}`) + } + if (modalities.includes("image")) imageModelIds.push(modelId) if (entry.includes("reasoning:!0") || entry.includes("reasoningEfforts:[")) { reasoningModelIds.push(modelId) } @@ -206,6 +200,7 @@ export function parseBundleModelCapabilities( } return { + imageModelIds: sorted(imageModelIds), reasoningModelIds: sorted(reasoningModelIds), maxOutputTokens: Object.fromEntries( Object.entries(maxOutputTokens).sort(([left], [right]) => left.localeCompare(right)), @@ -218,11 +213,10 @@ export function commandCodeModelMetadataFromContents( cliBundle: string, ): CommandCodeModelMetadata { const reference = parseModelsReference(modelsReference) - const textOnlyModelIds = new Set(parseKnownTextOnlyModelIds(cliBundle)) const capabilities = parseBundleModelCapabilities(cliBundle, reference.modelIds) return { - imageModelIds: reference.modelIds.filter((modelId) => !textOnlyModelIds.has(modelId)), + imageModelIds: capabilities.imageModelIds, reasoningModelIds: capabilities.reasoningModelIds, reasoningEfforts: reference.reasoningEfforts, maxOutputTokens: capabilities.maxOutputTokens, @@ -512,7 +506,8 @@ async function resolvePackageSpec( encoding: "utf-8", }, ) - return `command-code@${parsePackageVersion(JSON.parse(stdout) as unknown)}` + const version: unknown = JSON.parse(stdout) + return `command-code@${parsePackageVersion(version)}` } async function inspectPackedPackage(packageSpec: string): Promise<{ @@ -525,14 +520,23 @@ async function inspectPackedPackage(packageSpec: string): Promise<{ try { const resolvedPackageSpec = await resolvePackageSpec(packageSpec, directory, npmCacheDirectory) const { stdout } = await execNpmFileAsync( - ["pack", resolvedPackageSpec, "--json", "--prefer-online", "--cache", npmCacheDirectory], + [ + "pack", + resolvedPackageSpec, + "--ignore-scripts", + "--json", + "--prefer-online", + "--cache", + npmCacheDirectory, + ], { cwd: directory, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024, }, ) - const packed = parsePackedPackage(JSON.parse(stdout) as unknown) + const packResult: unknown = JSON.parse(stdout) + const packed = parsePackedPackage(packResult) await execFileAsync("tar", ["-xzf", packed.filename], { cwd: directory }) const packageDirectory = join(directory, "package") diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ebcaee..bcd2677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Repair catalog synchronization for CLI 1.82.4: read each model's explicit input modalities instead of relying on the removed text-only set and property adjacency. Unexpected metadata still fails closed. Package inspection disables npm lifecycle scripts. + ## 0.7.7 - 2026-10-11 - Recognize the `off` reasoning effort now published for the DeepSeek V4 and V4.1 models, so the catalog parser no longer rejects it and the generate transport never forwards `off` as `reasoning_effort`. The Provider API and Oh My Pi adapters translate the level from the model's thinking metadata on their own wire. diff --git a/tests/test-model-metadata-check.ts b/tests/test-model-metadata-check.ts index 3c07513..0dcdb68 100644 --- a/tests/test-model-metadata-check.ts +++ b/tests/test-model-metadata-check.ts @@ -6,7 +6,6 @@ import { diffModelMetadata, hasModelMetadataDiff, parseBundleModelCapabilities, - parseKnownTextOnlyModelIds, parseModelsReference, parsePackageVersion, pruneObsoleteEffortOverrides, @@ -48,8 +47,43 @@ describe("Command Code model metadata checker", () => { }) }) - it("extracts the text-only set from the bundled CLI catalog", () => { - assert.deepEqual(parseKnownTextOnlyModelIds(CLI_BUNDLE), ["text-model"]) + it("reads per-model modalities when the CLI no longer has a text-only set", () => { + const bundle = CLI_BUNDLE.replaceAll( + "inputModalities:", + 'behaviorProfile:"standard-v1",inputModalities:', + ).replace( + 'catalog=new Set(["text-model"])', + 'catalog=new Set(["anthropic","openai"]),profile="v1"', + ) + assert.deepEqual(commandCodeModelMetadataFromContents(MODELS_REFERENCE, bundle).imageModelIds, [ + "vision-model", + ]) + }) + + it("keeps image support when upstream also advertises file input", () => { + const bundle = CLI_BUNDLE.replace('["text","image"]', '["text","image","file"]') + assert.deepEqual(commandCodeModelMetadataFromContents(MODELS_REFERENCE, bundle).imageModelIds, [ + "vision-model", + ]) + }) + + it("rejects missing or unsupported modalities instead of assuming vision", () => { + assert.throws( + () => + commandCodeModelMetadataFromContents( + MODELS_REFERENCE, + CLI_BUNDLE.replace('inputModalities:["text","image"]', 'inputModalities:["audio"]'), + ), + /modalities/, + ) + assert.throws( + () => + commandCodeModelMetadataFromContents( + MODELS_REFERENCE, + CLI_BUNDLE.replace('inputModalities:["text","image"],', ""), + ), + /modalities/, + ) }) it("accepts one exact npm registry version and rejects stale-looking output shapes", () => { @@ -61,6 +95,7 @@ describe("Command Code model metadata checker", () => { it("derives image, reasoning, effort, and output-limit metadata", () => { assert.deepEqual(parseBundleModelCapabilities(CLI_BUNDLE, ["text-model", "vision-model"]), { + imageModelIds: ["vision-model"], reasoningModelIds: ["vision-model"], maxOutputTokens: { "vision-model": 32_768 }, }) @@ -181,7 +216,10 @@ export const MODEL_MAX_OUTPUT_TOKENS: Readonly> = { () => parseModelsReference(MODELS_REFERENCE.replace("low, high", "low, turbo")), /Unexpected reasoning efforts/, ) - assert.throws(() => parseKnownTextOnlyModelIds("const unrelated = true"), /Could not find/) + assert.throws( + () => parseBundleModelCapabilities("const unrelated = true", ["vision-model"]), + /Could not find/, + ) }) it("prunes only the overrides that upstream now publishes", () => {