diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 725db2963a..ee4c92b8b7 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -8,7 +8,8 @@ * show Show provider config details (secrets masked) * set-default Change the default provider */ -import { apiKeyTransportConfigError, hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; +import { hasOwnProvider, isValidProviderName, loadConfig, sanitizeModelCostsForDisplay, saveConfig } from "../config"; +import { apiKeyTransportConfigError } from "../config/provider-validation"; import { hasHelpFlag } from "./help"; import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; diff --git a/src/config.ts b/src/config.ts index dcf34313a4..d70bb5beb4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,19 @@ import { dirname, join, resolve } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; +import { + apiKeyTransportConfigError, + booleanRecordConfigError, + modelAdapterRecordConfigError, + nonBlankStringArrayConfigError, + normalizeNonBlankStringArray, + positiveIntegerConfigError, + positiveIntegerRecordConfigError, + providerBaseUrlConfigError, + providerHeadersConfigError, + reasoningSummaryDeliveryRecordConfigError, + upstreamHttpVersionConfigError, +} from "./config/provider-validation"; import { bumpConfigGenerationAtPath, bumpCurrentConfigGeneration, @@ -56,11 +69,9 @@ import { } from "./lib/windows-elevation"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { - isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, OPENAI_PROVIDER_TIER_VERSION, pinnedWireAdapter, - REASONING_SUMMARY_DELIVERY_VALUES, UPSTREAM_HTTP_VERSION_VALUES, type OcxClaudeCodeConfig, type OcxConfig, @@ -69,7 +80,7 @@ import { type FastWire, type ProviderCostOverlay, } from "./types"; -import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire"; import { getProviderRegistryEntry, @@ -79,7 +90,7 @@ import { } from "./providers/registry"; import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; -import { isCodexReasoningEffort, modelRecordValue } from "./reasoning-effort"; +import { isCodexReasoningEffort } from "./reasoning-effort"; import { COST4_RATE_KEYS, isValidCost4Rate, @@ -743,30 +754,20 @@ const providerConfigSchema = z.object({ responsesSnapshotRepair: z.boolean().optional(), }).passthrough(); -const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; -const SENSITIVE_PROVIDER_HEADERS = new Set([ - "authorization", - "cookie", - "set-cookie", - "proxy-authorization", - "x-api-key", - "x-goog-api-key", - "x-amz-security-token", -]); - export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; - -export function providerBaseUrlConfigError(baseUrl: string): string | null { - try { - const parsed = new URL(baseUrl.trim()); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL"; - if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials"; - if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments"; - } catch { - return "baseUrl must be a valid URL"; - } - return null; -} +export { + apiKeyTransportConfigError, + booleanRecordConfigError, + modelAdapterRecordConfigError, + nonBlankStringArrayConfigError, + normalizeNonBlankStringArray, + positiveIntegerConfigError, + positiveIntegerRecordConfigError, + providerBaseUrlConfigError, + providerHeadersConfigError, + reasoningSummaryDeliveryRecordConfigError, + upstreamHttpVersionConfigError, +} from "./config/provider-validation"; function providerResponsesPathConfigError(responsesPath: string | undefined): string | null { if (responsesPath === undefined) return null; @@ -780,19 +781,6 @@ function providerResponsesPathConfigError(responsesPath: string | undefined): st return null; } -export function providerHeadersConfigError(headers: unknown): string | null { - if (headers === undefined) return null; - if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object"; - for (const [name, value] of Object.entries(headers)) { - const normalized = name.trim().toLowerCase(); - if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names"; - if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`; - if (typeof value !== "string") return `header "${name}" value must be a string`; - if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`; - } - return null; -} - /** * Validate `providers..modelCosts`: a plain object keyed by exact model * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. @@ -862,119 +850,6 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record 0 ? out : undefined; } -/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ -export function apiKeyTransportConfigError( - provider: Pick, -): string | null { - if (provider.apiKeyTransport === undefined) return null; - if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") { - return 'apiKeyTransport must be "x-api-key" or "bearer"'; - } - if (provider.adapter !== "anthropic") { - return "apiKeyTransport is supported only by the anthropic adapter"; - } - if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") { - return "apiKeyTransport requires Anthropic API-key authentication"; - } - return null; -} - -/** - * Shared runtime boundary for the per-provider upstream HTTP-version pin (#1668). Used by - * the management write path (providerManagementConfigError / PATCH) so it can never disagree - * with the strict zod load schema: a value that survives POST/PATCH is always loadable, and - * a value the loader rejects is rejected at write time too. - */ -export function upstreamHttpVersionConfigError(value: unknown): string | null { - if (value === undefined || value === null) return null; - if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) { - return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear'; - } - return null; -} - -export function positiveIntegerRecordConfigError(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 (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) { - return `${field}.${key} must be a positive finite integer`; - } - } - return null; -} - -export function positiveIntegerConfigError(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { - return `${field} must be a positive finite integer`; - } - return null; -} - -export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null { - if (value === undefined) return null; - if (!Array.isArray(value)) return `${field} must be an array`; - for (const [index, entry] of value.entries()) { - if (typeof entry !== "string" || !entry.trim()) { - return `${field}.${index} must be a nonblank model id`; - } - } - return null; -} - -/** - * Keep hand-edited config and management writes on one canonical model-id list. - * Validation happens separately so an all-whitespace value is rejected rather than - * normalized into a model id that can never match at runtime. - */ -export function normalizeNonBlankStringArray(value: readonly string[]): string[] { - return [...new Set(value.map(entry => entry.trim()))]; -} - -export function booleanRecordConfigError(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 (typeof entry !== "boolean") return `${field}.${key} must be a boolean`; - } - return null; -} - -const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); - -export function reasoningSummaryDeliveryRecordConfigError( - value: unknown, - supportsReasoningSummaries: unknown, - field = "modelReasoningSummaryDelivery", -): 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`; - - const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null - && supportsReasoningSummaries && typeof supportsReasoningSummaries === "object" - ? supportsReasoningSummaries as Record - : undefined; - for (const [key, entry] of Object.entries(value)) { - if (!key.trim()) return `${field} keys must be nonblank model ids`; - if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) { - return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`; - } - if (modelRecordValue(supports, key) === false) { - return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`; - } - } - return null; -} - const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]); export function modelPreferHostedToolsConfigError( @@ -1069,41 +944,6 @@ export function modelPreferHostedToolsConfigError( return null; } -/** - * Validate a provider's per-model wire override map (#404). - * - * Rejects, rather than silently ignoring, configurations the resolver would refuse: - * a value outside the allowed wires, a model the upstream pins to one wire, and any - * override on a canonical forward provider (where switching wires would drop the - * caller's forwarded credential). Silently dropping them would leave the user - * believing an override is in effect. - */ -export function modelAdapterRecordConfigError( - value: unknown, - field: string, - providerName: string, - provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown }, -): 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`; - const entries = Object.entries(value); - if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { - return `${field} is not supported on the canonical ChatGPT forward provider`; - } - for (const [key, entry] of entries) { - if (!key.trim()) return `${field} keys must be nonblank model ids`; - if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) { - return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`; - } - if (isWirePinnedModel(providerName, key.trim())) { - return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`; - } - } - return null; -} - const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts new file mode 100644 index 0000000000..8a068d271b --- /dev/null +++ b/src/config/provider-validation.ts @@ -0,0 +1,177 @@ +import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; +import { modelRecordValue } from "../reasoning-effort"; +import { + isWirePinnedModel, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + REASONING_SUMMARY_DELIVERY_VALUES, + UPSTREAM_HTTP_VERSION_VALUES, + type OcxProviderConfig, +} from "../types"; + +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; +const SENSITIVE_PROVIDER_HEADERS = new Set([ + "authorization", + "cookie", + "set-cookie", + "proxy-authorization", + "x-api-key", + "x-goog-api-key", + "x-amz-security-token", +]); +const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); + +/** Validate a provider destination without coupling DTO callers to config persistence. */ +export function providerBaseUrlConfigError(baseUrl: string): string | null { + try { + const parsed = new URL(baseUrl.trim()); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL"; + if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials"; + if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments"; + } catch { + return "baseUrl must be a valid URL"; + } + return null; +} + +/** Validate user-configured provider headers while keeping auth headers on owned fields. */ +export function providerHeadersConfigError(headers: unknown): string | null { + if (headers === undefined) return null; + if (!headers || typeof headers !== "object" || Array.isArray(headers)) return "headers must be an object"; + for (const [name, value] of Object.entries(headers)) { + const normalized = name.trim().toLowerCase(); + if (!normalized || !HEADER_NAME_PATTERN.test(name)) return "headers must use valid HTTP header names"; + if (SENSITIVE_PROVIDER_HEADERS.has(normalized)) return `headers must not include sensitive header "${name}"; use apiKey/authMode instead`; + if (typeof value !== "string") return `header "${name}" value must be a string`; + if (/[\r\n]/.test(value)) return `header "${name}" value must not include line breaks`; + } + return null; +} + +/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */ +export function apiKeyTransportConfigError( + provider: Pick, +): string | null { + if (provider.apiKeyTransport === undefined) return null; + if (provider.apiKeyTransport !== "x-api-key" && provider.apiKeyTransport !== "bearer") { + return 'apiKeyTransport must be "x-api-key" or "bearer"'; + } + if (provider.adapter !== "anthropic") { + return "apiKeyTransport is supported only by the anthropic adapter"; + } + if (provider.authMode === "oauth" || provider.authMode === "forward" || provider.authMode === "local") { + return "apiKeyTransport requires Anthropic API-key authentication"; + } + return null; +} + +/** Shared strict boundary for the per-provider upstream HTTP-version pin. */ +export function upstreamHttpVersionConfigError(value: unknown): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== "string" || !(UPSTREAM_HTTP_VERSION_VALUES as readonly string[]).includes(value)) { + return 'upstreamHttpVersion must be one of "auto", "http1.1", "h1", "http2", "h2", or null to clear'; + } + return null; +} + +export function positiveIntegerRecordConfigError(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 (typeof entry !== "number" || !Number.isFinite(entry) || !Number.isInteger(entry) || entry <= 0) { + return `${field}.${key} must be a positive finite integer`; + } + } + return null; +} + +export function positiveIntegerConfigError(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value <= 0) { + return `${field} must be a positive finite integer`; + } + return null; +} + +export function nonBlankStringArrayConfigError(value: unknown, field: string): string | null { + if (value === undefined) return null; + if (!Array.isArray(value)) return `${field} must be an array`; + for (const [index, entry] of value.entries()) { + if (typeof entry !== "string" || !entry.trim()) { + return `${field}.${index} must be a nonblank model id`; + } + } + return null; +} + +/** Normalize only after validation so whitespace-only entries cannot silently disappear. */ +export function normalizeNonBlankStringArray(value: readonly string[]): string[] { + return [...new Set(value.map(entry => entry.trim()))]; +} + +export function booleanRecordConfigError(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 (typeof entry !== "boolean") return `${field}.${key} must be a boolean`; + } + return null; +} + +export function reasoningSummaryDeliveryRecordConfigError( + value: unknown, + supportsReasoningSummaries: unknown, + field = "modelReasoningSummaryDelivery", +): 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`; + + const supports = booleanRecordConfigError(supportsReasoningSummaries, "modelSupportsReasoningSummaries") === null + && supportsReasoningSummaries && typeof supportsReasoningSummaries === "object" + ? supportsReasoningSummaries as Record + : undefined; + for (const [key, entry] of Object.entries(value)) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (typeof entry !== "string" || !REASONING_SUMMARY_DELIVERY_SET.has(entry)) { + return `${field}.${key} must be one of: ${REASONING_SUMMARY_DELIVERY_VALUES.join(", ")}`; + } + if (modelRecordValue(supports, key) === false) { + return `${field}.${key} conflicts with modelSupportsReasoningSummaries=false`; + } + } + return null; +} + +/** Validate a provider's per-model wire override map against runtime routing rules. */ +export function modelAdapterRecordConfigError( + value: unknown, + field: string, + providerName: string, + provider: { adapter?: unknown; authMode?: unknown; baseUrl?: unknown }, +): 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`; + const entries = Object.entries(value); + if (entries.length > 0 && isCanonicalOpenAiForwardProvider(provider as OcxProviderConfig)) { + return `${field} is not supported on the canonical ChatGPT forward provider`; + } + for (const [key, entry] of entries) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (typeof entry !== "string" || !MODEL_ADAPTER_OVERRIDE_ALLOWED.has(entry)) { + return `${field}.${key} must be one of: ${[...MODEL_ADAPTER_OVERRIDE_ALLOWED].join(", ")}`; + } + if (isWirePinnedModel(providerName, key.trim())) { + return `${field}.${key} cannot be overridden: the upstream only speaks one wire for this model`; + } + } + return null; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 77ffa085c2..2257f78923 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -1,23 +1,25 @@ import { timingSafeEqual } from "node:crypto"; import { formatErrorResponse } from "../bridge"; +import { + codexAutoStartEnabled, + modelPreferHostedToolsConfigError, + providerModelCostsConfigError, + requestPacingConfigError, + retryOn429PolicyConfigError, + sanitizeModelCostsForDisplay, +} from "../config"; import { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, - modelPreferHostedToolsConfigError, - codexAutoStartEnabled, nonBlankStringArrayConfigError, positiveIntegerConfigError, positiveIntegerRecordConfigError, providerBaseUrlConfigError, providerHeadersConfigError, - providerModelCostsConfigError, reasoningSummaryDeliveryRecordConfigError, - retryOn429PolicyConfigError, - requestPacingConfigError, - sanitizeModelCostsForDisplay, upstreamHttpVersionConfigError, -} from "../config"; +} from "../config/provider-validation"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; diff --git a/src/server/management/provider-capability-config.ts b/src/server/management/provider-capability-config.ts index ce58966df3..a3b27a1602 100644 --- a/src/server/management/provider-capability-config.ts +++ b/src/server/management/provider-capability-config.ts @@ -1,4 +1,4 @@ -import { booleanRecordConfigError } from "../../config"; +import { booleanRecordConfigError } from "../../config/provider-validation"; import type { OcxConfig } from "../../types"; /** diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 26a279f592..d29abd17a3 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -282,6 +282,22 @@ Both fields must stay positive finite integers at disk-config and management val Registry entries may seed them through `providerConfigSeed`, key-login derivation, OAuth reconcile, and `routeModel`, but user config overrides registry defaults per field/key. +## Provider validation ownership + +`src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, +CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement +and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, +ordering, and cross-field rules are part of the write/load contract because management requests and +hand-edited `config.json` must accept and reject the same provider shapes. + +[Decision Log] +- 목적과 의도: Separate reusable provider payload validation from config file persistence without changing accepted configuration or error behavior. +- 기존 구현 및 제약 조건: The Zod schema, CLI, and management API shared helpers defined inside `src/config.ts`, so callers needing one pure check depended on the full persistence module. +- 검토한 주요 대안: Keep validation in the persistence module; duplicate checks per caller; extract one leaf and retain compatibility re-exports. +- 선택한 방식: Use one pure validation leaf, consume it from config refinement and direct DTO callers, and keep `src/config.ts` re-exports during migration. +- 다른 대안 대신 이 방식을 선택한 이유: One implementation preserves load/write parity while reducing dependency breadth and avoiding a flag-day import rewrite. +- 장점, 단점 및 영향: Validation can be characterized independently and config persistence becomes smaller; a temporary facade remains until all internal callers migrate. + ## Restore `ocx stop`, `ocx restore` / `ocx eject`, `ocx service stop`, and `ocx service uninstall` must strip diff --git a/tests/provider-config-validation.test.ts b/tests/provider-config-validation.test.ts new file mode 100644 index 0000000000..21f9b53cb1 --- /dev/null +++ b/tests/provider-config-validation.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { + apiKeyTransportConfigError, + booleanRecordConfigError, + modelAdapterRecordConfigError, + nonBlankStringArrayConfigError, + normalizeNonBlankStringArray, + positiveIntegerConfigError, + positiveIntegerRecordConfigError, + providerBaseUrlConfigError, + providerHeadersConfigError, + reasoningSummaryDeliveryRecordConfigError, + upstreamHttpVersionConfigError, +} from "../src/config/provider-validation"; + +describe("provider config validation leaf", () => { + test("accepts only credential-free HTTP(S) base URLs", () => { + expect(providerBaseUrlConfigError("https://example.test/v1")).toBeNull(); + expect(providerBaseUrlConfigError("file:///tmp/provider")).toBe("baseUrl must be an http(s) URL"); + expect(providerBaseUrlConfigError("https://user:pass@example.test/v1")).toContain("embedded credentials"); + expect(providerBaseUrlConfigError("https://example.test/v1?token=x")).toContain("query strings"); + expect(providerBaseUrlConfigError("not a url")).toBe("baseUrl must be a valid URL"); + }); + + test("rejects sensitive, malformed, non-string, and multiline headers", () => { + expect(providerHeadersConfigError({ "X-Custom": "ok" })).toBeNull(); + expect(providerHeadersConfigError({ Authorization: "Bearer secret" })).toContain("sensitive header"); + expect(providerHeadersConfigError({ "Bad Header": "x" })).toContain("valid HTTP header names"); + expect(providerHeadersConfigError({ "X-Count": 1 })).toContain("must be a string"); + expect(providerHeadersConfigError({ "X-Custom": "ok\r\nInjected: yes" })).toContain("line breaks"); + }); + + test("keeps apiKeyTransport on Anthropic API-key providers only", () => { + expect(apiKeyTransportConfigError({ adapter: "anthropic", authMode: "key", apiKeyTransport: "bearer" })).toBeNull(); + expect(apiKeyTransportConfigError({ adapter: "openai-chat", authMode: "key", apiKeyTransport: "bearer" })).toContain("anthropic adapter"); + expect(apiKeyTransportConfigError({ adapter: "anthropic", authMode: "oauth", apiKeyTransport: "bearer" })).toContain("API-key authentication"); + expect(apiKeyTransportConfigError({ adapter: "anthropic", authMode: "key", apiKeyTransport: "invalid" as "bearer" })).toContain("x-api-key"); + }); + + test("shares the upstream HTTP-version enum across write and load boundaries", () => { + for (const value of [undefined, null, "auto", "http1.1", "h1", "http2", "h2"]) { + expect(upstreamHttpVersionConfigError(value)).toBeNull(); + } + expect(upstreamHttpVersionConfigError("h3")).toContain("must be one of"); + }); + + test("requires own-property positive integer maps", () => { + expect(positiveIntegerRecordConfigError({ model: 1 }, "limits")).toBeNull(); + expect(positiveIntegerRecordConfigError(Object.create({ inherited: 1 }), "limits")).toContain("own properties"); + expect(positiveIntegerRecordConfigError({ " ": 1 }, "limits")).toContain("nonblank model ids"); + for (const value of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, "1"]) { + expect(positiveIntegerConfigError(value, "limit")).toContain("positive finite integer"); + } + }); + + test("validates and normalizes model-id lists in separate phases", () => { + expect(nonBlankStringArrayConfigError([" model-a ", "model-b"], "models")).toBeNull(); + expect(nonBlankStringArrayConfigError(["model-a", " "], "models")).toContain("models.1"); + expect(normalizeNonBlankStringArray([" model-a ", "model-a", "model-b"])).toEqual(["model-a", "model-b"]); + }); + + test("validates boolean capability maps before cross-field delivery checks", () => { + expect(booleanRecordConfigError({ model: true }, "supports")).toBeNull(); + expect(booleanRecordConfigError(Object.create({ model: true }), "supports")).toContain("own properties"); + expect(reasoningSummaryDeliveryRecordConfigError({ model: "sequential" }, { model: true })).toBeNull(); + expect(reasoningSummaryDeliveryRecordConfigError({ model: "unknown" }, { model: true })).toContain("must be one of"); + expect(reasoningSummaryDeliveryRecordConfigError({ model: "sequential" }, { model: false })).toContain("conflicts"); + }); + + test("rejects invalid, wire-pinned, and canonical-forward model adapter overrides", () => { + const keyed = { adapter: "openai-responses", authMode: "key", baseUrl: "https://example.test/v1" }; + expect(modelAdapterRecordConfigError({ model: "openai-chat" }, "modelAdapters", "custom", keyed)).toBeNull(); + expect(modelAdapterRecordConfigError({ model: "anthropic" }, "modelAdapters", "custom", keyed)).toContain("must be one of"); + expect(modelAdapterRecordConfigError( + { "minimax-m3": "openai-chat" }, + "modelAdapters", + "opencode-go", + keyed, + )).toContain("only speaks one wire"); + expect(modelAdapterRecordConfigError( + { "gpt-5.5": "openai-chat" }, + "modelAdapters", + "openai", + { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, + )).toContain("canonical ChatGPT forward provider"); + }); +});