Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/cli/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* show <name> Show provider config details (secrets masked)
* set-default <name> 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";
Expand Down
216 changes: 28 additions & 188 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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.<name>.modelCosts`: a plain object keyed by exact model
* id, each value a 4-tuple of non-negative finite USD-per-1M-token rates.
Expand Down Expand Up @@ -862,119 +850,6 @@ export function sanitizeModelCostsForDisplay(costs: unknown): Record<string, Pro
return Object.keys(out).length > 0 ? out : undefined;
}

/** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
export function apiKeyTransportConfigError(
provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
): 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<string>(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<string, boolean>
: 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(
Expand Down Expand Up @@ -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 =
Expand Down
Loading
Loading