Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact model IDs before constructing validation errors

When a malformed persisted map uses a secret-shaped model key, such as {"sk-...": null}, this message embeds that key verbatim; loadConfig() then passes the Zod issue to warnAndBackupInvalidConfig(), which prints the full message to stderr. The management validation path likewise returns the unredacted key. Redact and JSON-escape the key before including it in the diagnostic, as the nearby retryOn429 validation already does.

AGENTS.md reference: AGENTS.md:L233-L234

Useful? React with 👍 / 👎.

}
}
return null;
}

const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVERY_VALUES);

export function reasoningSummaryDeliveryRecordConfigError(
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
providerHeadersConfigError,
reasoningSummaryDeliveryRecordConfigError,
retryOn429PolicyConfigError,
stringArrayRecordConfigError,
} from "../config";
import { providerDestinationConfigError } from "../lib/destination-policy";
import { redactSecretString } from "../lib/redact";
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions tests/management-provider-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading