From 1cd9f4cdad994dd1df4d8cdb9118bc15eeefe6d6 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Sun, 9 Aug 2026 15:48:29 +0900 Subject: [PATCH] fix(config): validate model input modality maps --- src/config.ts | 25 ++++++++++++++++++++ src/server/auth-cors.ts | 3 +++ tests/config.test.ts | 12 ++++++++++ tests/management-provider-validation.test.ts | 12 ++++++++++ 4 files changed, 52 insertions(+) diff --git a/src/config.ts b/src/config.ts index 3d0d1a239..39b55e6af 100644 --- a/src/config.ts +++ b/src/config.ts @@ -746,6 +746,20 @@ export function booleanRecordConfigError(value: unknown, field: string): string return null; } +export function stringArrayRecordConfigError(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; + for (const [key, entry] of Object.entries(value)) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (!Array.isArray(entry) || entry.some(item => typeof item !== "string")) { + return `${field}.${key} must be an array of strings`; + } + } + return null; +} + const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); export function reasoningSummaryDeliveryRecordConfigError( @@ -1274,6 +1288,17 @@ const configSchema = z.object({ message: reasoningSummariesError, }); } + const inputModalitiesError = stringArrayRecordConfigError( + (provider as { modelInputModalities?: unknown }).modelInputModalities, + "modelInputModalities", + ); + if (inputModalitiesError) { + ctx.addIssue({ + code: "custom", + path: ["providers", name, "modelInputModalities"], + message: inputModalitiesError, + }); + } const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery, (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 536526f3e..4a260e9e1 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -12,6 +12,7 @@ import { providerHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, retryOn429PolicyConfigError, + stringArrayRecordConfigError, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; @@ -477,6 +478,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (maxInputError) return `provider ${name} ${maxInputError}`; const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; + const inputModalitiesError = stringArrayRecordConfigError(raw.modelInputModalities, "modelInputModalities"); + if (inputModalitiesError) return `provider ${name} ${inputModalitiesError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( raw.modelReasoningSummaryDelivery, raw.modelSupportsReasoningSummaries, diff --git a/tests/config.test.ts b/tests/config.test.ts index cca41a4d6..55c5ee922 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1405,6 +1405,18 @@ describe("opencodex config defaults", () => { expect(readConfigDiagnostics().error).toContain("providers.custom.modelMaxInputTokens"); }); + test("disk config rejects malformed modelInputModalities", () => { + writeConfig({ + port: 10100, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", modelInputModalities: { model: null } }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("providers.custom.modelInputModalities"); + }); + test("disk config preserves valid OpenRouter routing and rejects invalid destinations", () => { writeConfig({ port: 10100, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 884a955bc..2bbc52103 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -411,6 +411,18 @@ describe("provider management validation", () => { } expect(loadConfig().providers["custom-max-input"].modelMaxInputTokens).toEqual({ model: 1000 }); + for (const invalid of [null, [], { model: null }, { model: "text" }, { model: ["text", null] }]) { + const rejected = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "xai", + provider: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", modelInputModalities: invalid }, + }), + }); + expect(rejected.status).toBe(400); + } + const acceptedSummaryCapability = await fetch(new URL("/api/providers", server.url), { method: "POST", headers: { "content-type": "application/json" },