diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 44cea91c05..1198862b18 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -50,6 +50,7 @@ export interface RegistryModel { supportsReasoning?: boolean; supportedThinkingEfforts?: readonly string[]; supportsVision?: boolean; + supportsAudio?: boolean; supportsXHighEffort?: boolean; maxOutputTokens?: number; targetFormat?: string; diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 700d4adfd9..de36dc4d80 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -121,6 +121,7 @@ export interface ResolvedModelCapabilities { supportsThinking: boolean | null; supportsTools: boolean | null; supportsVision: boolean | null; + supportsAudio: boolean | null; supportsMaxTokens: boolean; attachment: boolean | null; structuredOutput: boolean | null; @@ -539,6 +540,25 @@ function resolveVisionCapability( return null; } +/** + * Resolve whether a chat model accepts audio input. + * + * Explicit catalog metadata wins. Synced input modalities are authoritative + * only when they contain at least one declared modality; an empty list means + * that no source knows the answer and remains `null` so Audio Bridge can act + * conservatively. + */ +export function resolveAudioCapability( + spec: Pick | undefined, + registryModel: { supportsAudio?: boolean } | null, + modalitiesInput: readonly string[] +): boolean | null { + if (typeof registryModel?.supportsAudio === "boolean") return registryModel.supportsAudio; + if (typeof spec?.supportsAudio === "boolean") return spec.supportsAudio; + if (modalitiesInput.length === 0) return null; + return modalitiesInput.some((entry) => String(entry).toLowerCase().includes("audio")); +} + /** * Issue #6524: an operator-set `max_output_tokens` capability override (see * `src/lib/db/modelCapabilityOverrides.ts`) is the manual escape hatch for a @@ -719,6 +739,7 @@ export function getResolvedModelCapabilities( lookupKey, customVisionOverride ); + const supportsAudio = resolveAudioCapability(spec, registryModel, modalitiesInput); // #8250: when resolve promoted vision over a contradictory attachment=false, // expose attachment=true so catalog / Vision Bridge / clients see one verdict. @@ -736,6 +757,7 @@ export function getResolvedModelCapabilities( supportsThinking, supportsTools, supportsVision, + supportsAudio, supportsMaxTokens: heuristicMaxTokens(lookupKey), attachment, structuredOutput: synced?.structured_output ?? null, diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index c9f46e67d4..d15fe50425 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -15,6 +15,7 @@ export interface ModelSpec { supportsThinking?: boolean; supportsTools?: boolean; supportsVision?: boolean; + supportsAudio?: boolean; // Model defaults to adaptive thinking and REJECTS an explicit `thinking.type:"disabled"` // (upstream returns 400). Used to normalize the request when a combo/route substitutes // this model after the client already chose `disabled`. See issue #3554. diff --git a/tests/unit/model-capabilities-audio.test.ts b/tests/unit/model-capabilities-audio.test.ts new file mode 100644 index 0000000000..8c3327e40a --- /dev/null +++ b/tests/unit/model-capabilities-audio.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAudioCapability } from "../../src/lib/modelCapabilities.ts"; + +test("explicit registry audio capability wins", () => { + assert.equal(resolveAudioCapability(undefined, { supportsAudio: true }, []), true); +}); + +test("explicit static audio capability is used when registry is silent", () => { + assert.equal(resolveAudioCapability({ supportsAudio: false }, null, []), false); +}); + +test("synced input modalities identify audio-capable and text-only models", () => { + assert.equal(resolveAudioCapability(undefined, null, ["text", "audio"]), true); + assert.equal(resolveAudioCapability(undefined, null, ["text"]), false); +}); + +test("missing audio capability evidence stays unknown", () => { + assert.equal(resolveAudioCapability(undefined, null, []), null); +});