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
26 changes: 26 additions & 0 deletions docs-site/src/content/docs/reference/configuration/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,29 @@ apply. `max` and `ultra` are accepted, while the dashboard offers `low` through

For a beginner-oriented explanation of v1, default, and v2 behavior, see
[Sub-agent surfaces](/guides/sub-agent-surface/).

## Global model effort pins

The optional root `modelPinnedEfforts` map fills or overrides incoming effort choices when
neither a provider model pin nor a provider-wide pin is configured. For example:

```json
{
"modelPinnedEfforts": {
"example-provider/example-model": "high"
}
}
```

Lookup checks the final selector before provider-prefix normalization, then the qualified
`provider/model` destination, then its bare upstream model ID. Original combo aliases and
synthetic effort-row selector IDs are not global pin keys; configure the concrete destination.
Synthetic-row effort and combo defaults are preserved as the effective input before pinning.
Each selected destination resolves its own pin, then applicable caps and wire normalization.
Compaction requests are exempt. `none` means effort omission and provider-default behavior,
not guaranteed reasoning disablement.

`GET /api/effort-caps` includes the map. `PUT /api/effort-caps` accepts `modelPinnedEfforts`
alongside the existing caps: omitted fields stay unchanged, `null` clears the map, and a map
entry set to `null` or `""` deletes only that key. Invalid combined updates leave both caps
and pins unchanged. Saving a pin does not alter the featured subagent roster.
29 changes: 29 additions & 0 deletions docs-site/src/content/docs/reference/configuration/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,35 @@ to the native default as a single choice. Defaults must belong to the final list
the catalog projection, not stored configuration or arbitrary gateway models sharing a GPT name.
See [custom native catalog examples](/guides/codex-app-models/).

### Operator-pinned reasoning effort

Set `pinnedReasoningEffort` on an existing provider to override incoming effort choices, or
use `modelPinnedReasoningEfforts` for individual upstream model IDs. Per-model provider pins
win over the provider-wide pin; the root `modelPinnedEfforts` map is the fallback. These are
operator settings, not provider-registry defaults. They do not change model discovery or the
advertised effort ladder.

```json
{
"pinnedReasoningEffort": "high",
"modelPinnedReasoningEfforts": {
"example-model": "max"
}
}
```

Merge these fields into the existing provider row. Accepted values are `none`, `minimal`,
`low`, `medium`, `high`, `xhigh`, `max`, and `ultra`. **`none` removes the explicit effort field**;
it uses the provider's default behavior and does not guarantee that reasoning is disabled.
Applicable effort caps still run after the pin, and provider wire mapping/normalization can
lower or omit an unsupported value. `ultra` is normalized before it reaches an upstream wire.
Compaction maintenance requests are exempt from pins.

`PATCH /api/providers?name=<provider>` accepts these fields. Omit a field to preserve it;
use `null` to clear a scalar or the whole map. A map entry set to `null` or `""` removes that
entry while preserving other entries. Malformed writes are rejected before saving. A malformed
optional pin in a hand-edited file is ignored on load without discarding the rest of the config.

### Discovered model display names

Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker
Expand Down
4 changes: 3 additions & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -1301,7 +1301,9 @@
"zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows",
"cli-models-price.test.ts": "cli",
"model-costs-management-api.test.ts": "server",
"usage-time-range.test.ts": "usage"
"usage-time-range.test.ts": "usage",
"model-pinned-effort.test.ts": "codex-integration",
"model-pinned-effort-config.test.ts": "config"
},
"migrated": [
"adapters",
Expand Down
72 changes: 71 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models";
import {
apiKeyTransportConfigError,
booleanRecordConfigError,
configReasoningPinsConfigError,
modelPinnedEffortsConfigError,
pinnedReasoningEffortConfigError,
modelAdapterRecordConfigError,
modelDisplayNamesConfigError,
nonBlankStringArrayConfigError,
Expand Down Expand Up @@ -515,11 +518,25 @@ const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => {
return labels;
});

const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => {
const error = pinnedReasoningEffortConfigError(value);
if (error) ctx.addIssue({ code: "custom", message: error });
}).transform(value => value as string);

const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => {
const error = modelPinnedEffortsConfigError(value);
if (error) ctx.addIssue({ code: "custom", message: error });
}).transform(value => Object.fromEntries(
Object.entries(value as Record<string, string>).map(([key, effort]) => [key.trim(), effort]),
));

/**
* Zod schema for one provider entry: known fields are validated strictly while unknown
* fields pass through (preserved for runtime extensions).
*/
const providerConfigSchema = z.object({
pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(),
modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(),
adapter: z.string().min(1),
baseUrl: z.string().min(1),
alias: z.string().optional(),
Expand Down Expand Up @@ -1122,6 +1139,7 @@ const configSchema = z.object({
z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }),
]).optional().catch(undefined),
providers: z.record(z.string(), providerConfigSchema),
modelPinnedEfforts: modelPinnedEffortsSchema.optional(),
defaultProvider: z.string().min(1).default("openai"),
defaultModelAliases: z.boolean().optional(),
// Malformed hand edits disable this opt-in projection without rejecting providers.
Expand Down Expand Up @@ -1612,6 +1630,49 @@ export function hardenExistingSecret(path: string): void {
}
}
}
/** Load only: discard invalid optional pins without rewriting the file or losing providers. */
function sanitizeReasoningPinsForLoad(parsed: unknown): void {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
const root = parsed as Record<string, unknown>;
let degraded = false;
const sanitizeMap = (owner: Record<string, unknown>, field: string) => {
const value = owner[field];
if (value === undefined) return;
if (!value || typeof value !== "object" || Array.isArray(value)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
delete owner[field];
degraded = true;
return;
}
const counts = new Map<string, number>();
for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1);
const valid: Record<string, string> = Object.create(null);
for (const [key, effort] of Object.entries(value)) {
if (counts.get(key.trim()) !== 1 || modelPinnedEffortsConfigError({ [key]: effort }) !== null) {
degraded = true;
continue;
}
valid[key.trim()] = effort as string;
}
if (Object.keys(valid).length) owner[field] = valid;
else delete owner[field];
};
sanitizeMap(root, "modelPinnedEfforts");
if (root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)) {
for (const value of Object.values(root.providers)) {
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
const provider = value as Record<string, unknown>;
if (pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)) {
delete provider.pinnedReasoningEffort;
degraded = true;
}
sanitizeMap(provider, "modelPinnedReasoningEfforts");
}
}
// Never include a provider/model name or value: malformed pins can contain secrets.
if (degraded) console.warn("config.json contains invalid optional reasoning pins — ignoring invalid fields or entries");
}

/**
* The schema's `.catch(undefined)` silently degrades an invalid persisted
* `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
Expand Down Expand Up @@ -2190,6 +2251,7 @@ export function loadConfig(): OcxConfig {
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
const parsed = JSON.parse(raw);
sanitizeAliasesForLoad(parsed);
sanitizeReasoningPinsForLoad(parsed);
sanitizeModelDisplayNamesForLoad(parsed);
sanitizeRetryOn429ForLoad(parsed);
sanitizeModelCostsForLoad(parsed);
Expand Down Expand Up @@ -2722,7 +2784,8 @@ function managementIngressConfigError(value: unknown): string | null {
}

export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
const boundaryError = blankHostnameError(value)
const boundaryError = configReasoningPinsConfigError(value)
?? blankHostnameError(value)
?? claudeSubagentEffortError(value)
?? appOwnedMemoryBudgetError(value)
?? upstreamHostCircuitThresholdError(value)
Expand Down Expand Up @@ -2752,6 +2815,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
try {
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
sanitizeReasoningPinsForLoad(parsed);
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
// schema and send the caller a default-config fallback (the config command could then
// persist that fallback over the user's providers/keys).
Expand Down Expand Up @@ -3101,6 +3165,8 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
* every save path.
*/
function persistConfigUnlocked(config: OcxConfig): boolean {
const pinError = configReasoningPinsConfigError(config);
if (pinError) throw new Error(pinError);
const configPath = getConfigPath();
const rawBeforeWrite = readRawConfigJson();
const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config);
Expand Down Expand Up @@ -3176,6 +3242,8 @@ export function initializePersistedConfigIfMissing(

/** Persist `config` to config.json under the config-mutation lock. */
export function saveConfig(config: OcxConfig): void {
const pinError = configReasoningPinsConfigError(config);
if (pinError) throw new Error(pinError);
// Keep the real-home assertion ahead of even lock-directory preparation.
assertNotRealHomeUnderTest(getConfigDir());
withConfigMutationLockSync(() => {
Expand Down Expand Up @@ -3633,6 +3701,8 @@ function readPersistedServerBinding(
* edits and deletions across stale whole-config saves.
*/
export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
const pinError = configReasoningPinsConfigError(config);
if (pinError) throw new Error(pinError);
withConfigMutationLockSync(() => {
const bindingBaseline = persistedLiveServerBinding.get(config);
// One authoritative pre-write read feeds both the live-config reconciliation and
Expand Down
71 changes: 70 additions & 1 deletion src/config/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
isValidModelDiscoveryModelId,
MODEL_DISCOVERY_MAX_MODELS,
} from "../providers/model-discovery-limits";
import { modelRecordValue } from "../reasoning-effort";
import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
import {
isWirePinnedModel,
MODEL_ADAPTER_OVERRIDE_ALLOWED,
Expand All @@ -27,6 +27,75 @@ const REASONING_SUMMARY_DELIVERY_SET = new Set<string>(REASONING_SUMMARY_DELIVER
const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
const MAX_MODEL_DISPLAY_NAME_LENGTH = 128;

/** Operator pins share one strict boundary across config and management writes. */
export function pinnedReasoningEffortConfigError(value: unknown, allowClear = false): string | null {
if (value === undefined || (allowClear && (value === null || value === ""))) return null;
return typeof value === "string" && isDeclaredReasoningEffort(value)
? null : "pinnedReasoningEffort must be a declared reasoning effort";
}

export function modelPinnedEffortsConfigError(
value: unknown,
field = "modelPinnedEfforts",
allowTombstones = false,
): string | null {
if (value === undefined || (allowTombstones && value === null)) return null;
if (!value || typeof value !== "object" || Array.isArray(value)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
return `${field} must be a plain object`;
}
const keys = new Set<string>();
for (const [key, effort] of Object.entries(value)) {
const normalized = key.trim();
if (!normalized || ["__proto__", "prototype", "constructor"].includes(normalized)) {
return `${field} keys must be nonblank model ids and must not be reserved object keys`;
}
if (keys.has(normalized)) return `${field} keys must be unique after trimming`;
keys.add(normalized);
if (allowTombstones && (effort === null || effort === "")) continue;
if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) {
return `${field} values must be declared reasoning efforts`;
}
}
return null;
}

/** Apply a validated map patch; null clears the field, entry tombstones remove one key. */
export function mergeModelPinnedEfforts(
current: Record<string, string> | undefined,
patch: unknown,
): Record<string, string> | undefined {
if (patch === undefined) return current === undefined ? undefined : { ...current };
if (patch === null) return undefined;
const next = Object.fromEntries(Object.entries(current ?? {}).map(([key, value]) => [key.trim(), value]));
for (const [key, effort] of Object.entries(patch as Record<string, string | null>)) {
if (effort === null || effort === "") delete next[key.trim()];
else next[key.trim()] = effort;
}
return Object.keys(next).length ? next : undefined;
}

export function providerReasoningPinsConfigError(provider: Record<string, unknown>): string | null {
return pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)
?? modelPinnedEffortsConfigError(provider.modelPinnedReasoningEfforts, "modelPinnedReasoningEfforts");
}

/** Validate only pin fields, including callers that bypass the whole-config schema. */
export function configReasoningPinsConfigError(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const raw = value as Record<string, unknown>;
const globalError = modelPinnedEffortsConfigError(raw.modelPinnedEfforts);
if (globalError) return globalError;
if (raw.providers && typeof raw.providers === "object") {
for (const provider of Object.values(raw.providers)) {
if (!provider || typeof provider !== "object") continue;
const error = providerReasoningPinsConfigError(provider as Record<string, unknown>);
if (error) return error;
}
}
return null;
}

/** Validate a provider destination without coupling DTO callers to config persistence. */
export function providerBaseUrlConfigError(baseUrl: string): string | null {
try {
Expand Down
8 changes: 8 additions & 0 deletions src/server/auth-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import {
apiKeyTransportConfigError,
booleanRecordConfigError,
providerReasoningPinsConfigError,
modelAdapterRecordConfigError,
nonBlankStringArrayConfigError,
positiveIntegerConfigError,
Expand Down Expand Up @@ -581,6 +582,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
return "provider must be a plain object";
}
const raw = provider as Record<string, unknown>;
const pinsError = providerReasoningPinsConfigError(raw);
if (pinsError) return pinsError;
for (const field of FORBIDDEN_PROVIDER_RUNTIME_FIELDS) {
if (Object.hasOwn(raw, field)) return `provider ${name} must not include runtime field "${field}"`;
}
Expand All @@ -594,6 +597,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
}
if (seed) seed.codexAccountMode = raw.codexAccountMode;
const canonicalCandidate = { ...raw };
// Validated operator overlays do not change the canonical auth/transport seed.
delete canonicalCandidate.pinnedReasoningEffort;
delete canonicalCandidate.modelPinnedReasoningEfforts;
delete canonicalCandidate.responsesSnapshotRepair;
// modelCosts is a user-owned display overlay, not part of the canonical
// forward seed; it is validated separately below (providerModelCostsConfigError).
Expand Down Expand Up @@ -829,6 +835,8 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
reasoningEfforts: "editor",
modelReasoningEfforts: "editor",
modelDefaultReasoningEfforts: "editor",
pinnedReasoningEffort: "editor",
modelPinnedReasoningEfforts: "editor",
modelSupportsReasoningSummaries: "editor",
modelSupportsVerbosity: "editor",
supportsVerbosity: "editor",
Expand Down
Loading
Loading