diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 54affc94ad..f3caea02f2 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -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. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 0deb12616f..3c72c4b302 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -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=` 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 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d46f547867..a1b6e1d46e 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/config.ts b/src/config.ts index d25aa07ad2..8da89cbfdf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,9 @@ export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; import { apiKeyTransportConfigError, booleanRecordConfigError, + configReasoningPinsConfigError, + modelPinnedEffortsConfigError, + pinnedReasoningEffortConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, nonBlankStringArrayConfigError, @@ -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).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(), @@ -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. @@ -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; + let degraded = false; + const sanitizeMap = (owner: Record, 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(); + for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1); + const valid: Record = 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; + 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. @@ -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); @@ -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) @@ -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). @@ -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); @@ -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(() => { @@ -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 diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 326914a758..c1e60033e8 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -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, @@ -27,6 +27,75 @@ const REASONING_SUMMARY_DELIVERY_SET = new Set(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(); + 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 | undefined, + patch: unknown, +): Record | 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)) { + 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 | 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; + 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); + 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 { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0dd49910fb..476fd3a4ae 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,6 +13,7 @@ import { import { apiKeyTransportConfigError, booleanRecordConfigError, + providerReasoningPinsConfigError, modelAdapterRecordConfigError, nonBlankStringArrayConfigError, positiveIntegerConfigError, @@ -581,6 +582,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): return "provider must be a plain object"; } const raw = provider as Record; + 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}"`; } @@ -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). @@ -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", diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 49e4beb61a..9abb99683e 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -6,6 +6,8 @@ import { collectChatCompletion, isChatCompletionsStreamError, } from "../chat/outbound"; +import { applyChatEffortCap, chatCollabSurface, effortCapAppliesTo, resolvePinnedEffort, supportedLadderFor } from "./effort-policy"; +import { mapReasoningEffort } from "../reasoning-effort"; import { classifyError, cyberPolicyErrorType, @@ -60,6 +62,68 @@ type Rec = Record; const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024; const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024; +const chatEffortSnapshots = new WeakMap(); + +function normalizePinnedChatEffort(options: HandleNativeChatOptions): void { + const { chatBody, route, config, req, logCtx, requestedModel } = options; + let snapshot = chatEffortSnapshots.get(chatBody); + const inputModel = typeof chatBody.model === "string" ? chatBody.model : requestedModel; + let selector = inputModel; + if (snapshot) { + if (snapshot.providerName === route.providerName && snapshot.modelId === route.modelId) { + logCtx.requestedEffort = snapshot.annotation; + return; + } + if (snapshot.present) chatBody.reasoning_effort = snapshot.value; + else delete chatBody.reasoning_effort; + if (selector === snapshot.inputModel || selector === snapshot.modelId) { + selector = `${route.providerName}/${route.modelId}`; + } + } else { + snapshot = { + inputModel, + providerName: route.providerName, + modelId: route.modelId, + present: Object.hasOwn(chatBody, "reasoning_effort"), + value: chatBody.reasoning_effort, + annotation: undefined, + }; + chatEffortSnapshots.set(chatBody, snapshot); + } + snapshot.inputModel = inputModel; + snapshot.providerName = route.providerName; + snapshot.modelId = route.modelId; + const from = typeof chatBody.reasoning_effort === "string" ? chatBody.reasoning_effort : undefined; + logCtx.requestedEffort = from; + // Compaction is normally excluded by native-route eligibility; preserve that boundary here too. + const pinned = chatBody.compaction_trigger === undefined + ? resolvePinnedEffort(route, selector, config) + : undefined; + if (pinned !== undefined) { + logCtx.requestedEffort = from ? `${from}->${pinned}` : pinned; + if (pinned === "none") delete chatBody.reasoning_effort; + else chatBody.reasoning_effort = pinned; + // The native lane historically passes caller effort through, including with caps set. + // Only a newly operator-pinned value enters the cap and provider-mapping pipeline. + if (effortCapAppliesTo(chatCollabSurface(chatBody), req.headers, config)) { + const capped = applyChatEffortCap(chatBody, req.headers, config, supportedLadderFor(route)); + if (capped) logCtx.requestedEffort = `${logCtx.requestedEffort}->${capped.to}`; + } + const effort = typeof chatBody.reasoning_effort === "string" ? chatBody.reasoning_effort : undefined; + const wireEffort = mapReasoningEffort(route.provider, route.modelId, effort); + if (wireEffort === undefined) delete chatBody.reasoning_effort; + else chatBody.reasoning_effort = wireEffort; + } + snapshot.annotation = logCtx.requestedEffort; +} + function isRec(value: unknown): value is Rec { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -147,9 +211,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio return chatCompletionsErrorResponse(status, safeMessage, type, code); }; - logCtx.requestedEffort = typeof options.chatBody.reasoning_effort === "string" - ? options.chatBody.reasoning_effort - : undefined; + normalizePinnedChatEffort(options); logCtx.requestedServiceTier = typeof options.chatBody.service_tier === "string" ? options.chatBody.service_tier : undefined; diff --git a/src/server/effort-policy.ts b/src/server/effort-policy.ts index 2686b73460..5a8b63af7a 100644 --- a/src/server/effort-policy.ts +++ b/src/server/effort-policy.ts @@ -14,7 +14,7 @@ */ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { modelInList } from "../types"; -import { codexEffortRank, configuredReasoningEfforts, isCodexReasoningEffort, modelRecordValue } from "../reasoning-effort"; +import { codexEffortRank, configuredReasoningEfforts, isCodexReasoningEffort, isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort"; import { catalogModelEfforts } from "../codex/catalog"; /** @@ -188,3 +188,185 @@ export function applyEffortCap( if (raw?.reasoning && typeof raw.reasoning === "object") raw.reasoning.effort = resolved; return { from: requested, to: resolved, subagent }; } + +/** + * Resolve any pinned reasoning effort configured for this model or provider. + * Priority order: + * 1. Provider model-specific pinned effort (`provider.modelPinnedReasoningEfforts[modelId]`) + * 2. Provider-wide pinned effort (`provider.pinnedReasoningEffort`) + * 3. Global config model-specific pinned effort (`config.modelPinnedEfforts[modelId]`) + * Global keys try the final pre-namespace selector, provider-qualified destination, + * then bare destination, using modelRecordValue's exact/family/case-fold semantics. + * The caller removes synthetic effort rows and combo selectors before this boundary. + * + * Returns undefined when no valid pinned effort tier is configured. + */ +export function resolvePinnedEffort( + route: { provider: OcxProviderConfig; modelId: string; providerName?: string }, + parsedModelId?: string, + config?: OcxConfig, +): string | undefined { + const prov = route.provider; + const rawProvModel = modelRecordValue(prov.modelPinnedReasoningEfforts, route.modelId) + ?? (parsedModelId ? modelRecordValue(prov.modelPinnedReasoningEfforts, parsedModelId) : undefined); + if (rawProvModel && isDeclaredReasoningEffort(rawProvModel)) { + return rawProvModel; + } + if (prov.pinnedReasoningEffort && isDeclaredReasoningEffort(prov.pinnedReasoningEffort)) { + return prov.pinnedReasoningEffort; + } + if (config?.modelPinnedEfforts) { + const rawGlobal = (parsedModelId ? modelRecordValue(config.modelPinnedEfforts, parsedModelId) : undefined) + ?? (route.providerName ? modelRecordValue(config.modelPinnedEfforts, `${route.providerName}/${route.modelId}`) : undefined) + ?? modelRecordValue(config.modelPinnedEfforts, route.modelId); + if (rawGlobal && isDeclaredReasoningEffort(rawGlobal)) { + return rawGlobal; + } + } + return undefined; +} + +interface EffortSnapshot { + selector: string; + providerName: string; + modelId: string; + reasoningPresent: boolean; + reasoning: OcxParsedRequest["options"]["reasoning"]; + rawEffortPresent: boolean; + rawEffort: unknown; +} + +const effortSnapshots = new WeakMap(); + +/** Capture effective synthetic/combo defaults before final model namespace rewriting. + * A different destination restores effort alone; intervening summary/options edits survive. + * Credential retries do not change the destination and retain their existing decision. + */ +export function prepareEffortNormalization( + parsed: OcxParsedRequest, + route: { providerName: string; modelId: string }, +): string { + const raw = parsed._rawBody as { reasoning?: Record } | undefined; + const previous = effortSnapshots.get(parsed); + if (!previous) { + effortSnapshots.set(parsed, { + selector: parsed.modelId, + providerName: route.providerName, + modelId: route.modelId, + reasoningPresent: Object.hasOwn(parsed.options, "reasoning"), + reasoning: parsed.options.reasoning, + rawEffortPresent: !!raw?.reasoning && Object.hasOwn(raw.reasoning, "effort"), + rawEffort: raw?.reasoning?.effort, + }); + return parsed.modelId; + } + if (previous.providerName === route.providerName && previous.modelId === route.modelId) { + return previous.selector; + } + if (previous.reasoningPresent) parsed.options.reasoning = previous.reasoning; + else delete parsed.options.reasoning; + if (raw && previous.rawEffortPresent) { + if (!raw.reasoning || typeof raw.reasoning !== "object") raw.reasoning = {}; + raw.reasoning.effort = previous.rawEffort; + } else if (raw?.reasoning && typeof raw.reasoning === "object") { + delete raw.reasoning.effort; + } + // An unchanged wire model is the previous destination, not a new requested alias. + previous.selector = parsed.modelId === previous.modelId || parsed.modelId === previous.selector + ? `${route.providerName}/${route.modelId}` + : parsed.modelId; + previous.providerName = route.providerName; + previous.modelId = route.modelId; + return previous.selector; +} + +/** + * Detect collaboration surface for a native chat request body. + * Mirrors Responses collabSurface behavior across function and custom tool representations. + */ +export function chatCollabSurface(chatBody: Record): "v1" | "v2" | null { + if (!Array.isArray(chatBody.tools)) return null; + let namespacedSpawn = false; + let flatSpawn = false; + let v1Only = false; + let v2Only = false; + for (const raw of chatBody.tools) { + if (!raw || typeof raw !== "object") continue; + const tool = raw as Record; + let name = ""; + let namespace: string | undefined = undefined; + if (tool.type === "function" && tool.function && typeof tool.function === "object") { + const fn = tool.function as Record; + name = typeof fn.name === "string" ? fn.name : ""; + } else if (tool.type === "custom" && tool.custom && typeof tool.custom === "object") { + const cust = tool.custom as Record; + name = typeof cust.name === "string" ? cust.name : ""; + } else if (typeof tool.name === "string") { + name = tool.name; + } + if (typeof tool.namespace === "string") namespace = tool.namespace; + if (name === "spawn_agent") { + if (namespace) namespacedSpawn = true; + else flatSpawn = true; + } else if (name === "send_input" || name === "resume_agent" || name === "close_agent") { + v1Only = true; + } else if (name === "send_message" || name === "followup_task" || name === "interrupt_agent" || name === "list_agents") { + v2Only = true; + } + } + if (!namespacedSpawn && !flatSpawn) return null; + if (namespacedSpawn && flatSpawn) return null; + if (v1Only && v2Only) return null; + if (v1Only) return "v1"; + if (v2Only) return "v2"; + return namespacedSpawn ? "v1" : "v2"; +} + +/** + * Apply effortCap to a native chat completions body when admitted by the collaboration gate. + */ +export function applyChatEffortCap( + chatBody: Record, + headers: Headers, + config: OcxConfig, + supported?: readonly string[] | undefined, +): { from: string; to: string; subagent: boolean } | null { + const subagent = isThreadSpawnRequest(headers); + const cap = effortCapFor(config, subagent); + if (!cap) return null; + const resolved = resolveCappedEffort(cap, supported); + const requested = typeof chatBody.reasoning_effort === "string" ? chatBody.reasoning_effort : undefined; + if (resolved === null) { + if (!requested) return null; + delete chatBody.reasoning_effort; + return { from: requested, to: "none", subagent }; + } + if (!requested || !isCodexReasoningEffort(requested)) return null; + if (codexEffortRank(requested) <= codexEffortRank(resolved)) return null; + chatBody.reasoning_effort = resolved; + return { from: requested, to: resolved, subagent }; +} + +export function applyPinnedEffort( + parsed: OcxParsedRequest, + route: { provider: OcxProviderConfig; modelId: string; providerName?: string }, + config?: OcxConfig, + selector = effortSnapshots.get(parsed)?.selector ?? parsed.modelId, +): { from: string | undefined; to: string } | null { + if (parsed._compactionRequest === true) return null; + const pinned = resolvePinnedEffort(route, selector, config); + if (!pinned) return null; + const requested = parsed.options.reasoning; + const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined; + const targetEffort = pinned === "none" ? undefined : pinned; + parsed.options.reasoning = targetEffort; + if (targetEffort) { + if (raw && typeof raw === "object") { + if (!raw.reasoning || typeof raw.reasoning !== "object") raw.reasoning = {}; + raw.reasoning.effort = targetEffort; + } + } else if (raw?.reasoning && typeof raw.reasoning === "object") { + delete raw.reasoning.effort; + } + return { from: requested, to: pinned }; +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index a7cf017f3a..561d00a080 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation"; import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance"; import { DEFAULT_SUBAGENT_MODELS, @@ -16,6 +17,7 @@ import { providerHeadersConfigError, saveConfigPreservingClaudeCode, subagentDefaultSyncEffective, + validateConfigCandidate, } from "../../config"; import { clearLoginState, @@ -603,24 +605,63 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise return jsonResponse({ effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null, + modelPinnedEfforts: config.modelPinnedEfforts ?? {}, efforts: CODEX_REASONING_LEVELS.map(l => l.effort), }); } if (url.pathname === "/api/effort-caps" && req.method === "PUT") { - let body: { effortCap?: unknown; subagentEffortCap?: unknown }; + let body: unknown; try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return jsonResponse({ error: "effort caps body must be a plain object" }, 400); + } + const patch = body as Record; const { isCodexReasoningEffort } = await import("../../reasoning-effort"); + const draft = { ...projectConfigRebaseProvenance(config) }; + const touched: (keyof OcxConfig)[] = []; for (const key of ["effortCap", "subagentEffortCap"] as const) { - if (!(key in body)) continue; - const value = body[key]; - if (value === null || value === "") { deleteConfigTopLevelKey(config, key); continue; } - if (typeof value !== "string" || !isCodexReasoningEffort(value)) { - return jsonResponse({ error: `unknown reasoning effort "${String(value)}"` }, 400); + if (!Object.hasOwn(patch, key)) continue; + const value = patch[key]; + if (value === null || value === "") deleteConfigTopLevelKey(draft, key); + else if (typeof value === "string" && isCodexReasoningEffort(value)) draft[key] = value; + else return jsonResponse({ error: "caps must be valid reasoning efforts or null" }, 400); + touched.push(key); + } + if (Object.hasOwn(patch, "modelPinnedEfforts")) { + const error = modelPinnedEffortsConfigError(patch.modelPinnedEfforts, "modelPinnedEfforts", true); + if (error) return jsonResponse({ error }, 400); + const pins = mergeModelPinnedEfforts(config.modelPinnedEfforts, patch.modelPinnedEfforts); + if (pins) draft.modelPinnedEfforts = pins; + else deleteConfigTopLevelKey(draft, "modelPinnedEfforts"); + touched.push("modelPinnedEfforts"); + } + const validation = validateConfigCandidate(draft); + if (!validation.ok) return jsonResponse({ error: validation.error }, 400); + if (touched.some(key => !Object.hasOwn(draft, key)) && config.configRebaseProvenance !== undefined + && parsedConfigRebaseDeletionKeys(config) === null) { + return jsonResponse({ error: "unsupported config deletion provenance" }, 409); + } + const projected = projectConfigRebaseProvenance(draft); + touched.push("configRebaseProvenance"); + const rollback = captureConfigTopLevelRollback(config, touched); + try { + for (const key of touched) { + if (Object.hasOwn(projected, key)) Object.defineProperty(config, key, { + value: projected[key], writable: true, enumerable: true, configurable: true, + }); + else deleteConfigTopLevelKey(config, key); } - config[key] = value; + (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config); + } catch (error) { + rollback(); + throw error; } - saveConfigPreservingClaudeCode(config); - return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null }); + return jsonResponse({ + ok: true, + effortCap: config.effortCap ?? null, + subagentEffortCap: config.subagentEffortCap ?? null, + ...(config.modelPinnedEfforts ? { modelPinnedEfforts: config.modelPinnedEfforts } : {}), + }); } // Featured roster and saved picker order are separate settings. Native Codex advertises diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index cc7a732a88..b5fca15abb 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -379,8 +379,8 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise, + current: OcxProviderConfig | undefined, +): string | null { + const scalarError = pinnedReasoningEffortConfigError(patch.pinnedReasoningEffort, true); + const mapError = modelPinnedEffortsConfigError(patch.modelPinnedReasoningEfforts, "modelPinnedReasoningEfforts", true); + if (scalarError || mapError) return scalarError ?? mapError; + const scalar = Object.hasOwn(patch, "pinnedReasoningEffort") + ? patch.pinnedReasoningEffort : current?.pinnedReasoningEffort; + const map = Object.hasOwn(patch, "modelPinnedReasoningEfforts") + ? mergeModelPinnedEfforts(current?.modelPinnedReasoningEfforts, patch.modelPinnedReasoningEfforts) + : current?.modelPinnedReasoningEfforts; + if (scalar === undefined || scalar === null || scalar === "") delete next.pinnedReasoningEffort; + else next.pinnedReasoningEffort = scalar as string; + if (map === undefined) delete next.modelPinnedReasoningEfforts; + else next.modelPinnedReasoningEfforts = { ...map }; + return null; +} + /** * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once * for validation and again inside the config mutation lock against the newest provider, @@ -478,6 +506,11 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "pinnedReasoningEffort") || Object.hasOwn(rawBody, "modelPinnedReasoningEfforts")) { + const error = applyProviderPinFields(next, rawBody, provider); + if (error) return { error }; + touched = true; + } if (Object.hasOwn(rawBody, "modelAutoCompactTokenLimits")) { const value = rawBody.modelAutoCompactTokenLimits; const error = modelAutoCompactTokenLimitsConfigError(value, { @@ -689,6 +722,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; + const effortSelector = prepareEffortNormalization(parsed, route); // Only Anthropic message routes retain the Codex-facing selector. Other providers must keep // their existing response.model contract even when their public and wire model ids differ. @@ -2403,6 +2404,17 @@ async function applyFinalRouteRequestNormalization(args: { } } + { + const { applyPinnedEffort } = await import("../effort-policy"); + const pinned = applyPinnedEffort(parsed, route, config, effortSelector); + if (pinned) { + logCtx.requestedEffort = pinned.from ? `${pinned.from}->${pinned.to}` : pinned.to; + if (isInjectionDebugEnabled()) { + injectionDebugLog(`[opencodex] ${route.modelId}: pinned reasoning effort applied (${pinned.from ?? "none"} -> ${pinned.to})`); + } + } + } + { const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy"); const surface = collabSurface(parsed); diff --git a/src/types/config.ts b/src/types/config.ts index 31b5a52ceb..017d01a94a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -544,6 +544,8 @@ export interface OcxConfig { * set, the lower one wins for sub-agents. See src/server/effort-policy.ts. */ subagentEffortCap?: string; + /** Global model effort overrides, after provider model/wide pins; none means omission. */ + modelPinnedEfforts?: Record; /** * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only diff --git a/src/types/provider.ts b/src/types/provider.ts index 97a359506a..b51230d6d1 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -493,6 +493,10 @@ export interface OcxProviderConfig { modelReasoningEfforts?: Record; /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */ modelDefaultReasoningEfforts?: Record; + /** Operator-owned effort override; none omits effort and uses the provider default. */ + pinnedReasoningEffort?: string; + /** Per-model operator override, ahead of provider-wide and global pins; caps still apply. */ + modelPinnedReasoningEfforts?: Record; /** * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible * Responses backend rejects Codex summary-delivery fields for that model. diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 86aa4a81a7..b64cb4bce1 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -346,6 +346,15 @@ wire-clamps ultra/max to each model's real top rung (e.g. gpt-5.5 ultra → xhig (`src/server/effort-policy.ts`): they lower or preserve the requested effort rather than rejecting the request, and they never raise it. +Operator-owned `pinnedReasoningEffort`, `modelPinnedReasoningEfforts`, and root +`modelPinnedEfforts` resolve before applicable effort caps at the final destination. +Provider model pins precede provider-wide pins, then global selector/destination pins. +A pin can raise the effective caller effort; the later cap can still lower or omit it. +`none` means explicit-effort omission (provider default), not guaranteed reasoning disablement. +Compaction maintenance is exempt. Pins are user overlays and do not alter registry seeds, +model discovery or advertised ladders. Native Chat normalizes newly pinned values through +provider wire mapping; unpinned native requests retain their existing pass-through contract. + [Decision Log] - 목적과 의도: Xiaomi MiMo의 공식 OpenAI Chat endpoint가 실제로 받지 않는 `max`/ `ultra` reasoning tier를 catalog에 노출하지 않도록 한다. diff --git a/tests/codex-integration/effort-policy.test.ts b/tests/codex-integration/effort-policy.test.ts index 3f2262ade6..2f1e65c10c 100644 --- a/tests/codex-integration/effort-policy.test.ts +++ b/tests/codex-integration/effort-policy.test.ts @@ -441,6 +441,18 @@ describe("cap composition with downstream clamps", () => { }); describe("/api/effort-caps", () => { + // Management writes validate the entire config, unlike the pure policy helpers above. + function makeApiConfig(overrides: Partial = {}): OcxConfig { + return makeConfig({ + defaultProvider: "effort-fixture", + providers: { "effort-fixture": { + adapter: "openai-chat", + baseUrl: "https://effort.example.invalid/v1", + } }, + ...overrides, + }); + } + function isolatedHome(): void { tempHome = mkdtempSync(join(tmpdir(), "ocx-effort-caps-")); process.env.OPENCODEX_HOME = tempHome; @@ -459,7 +471,7 @@ describe("/api/effort-caps", () => { test("PUT sets both caps; GET surfaces them with the ladder", async () => { isolatedHome(); - const config = makeConfig(); + const config = makeApiConfig(); const putRes = await put(config, { effortCap: "high", subagentEffortCap: "medium" }); expect(await putRes.json()).toEqual({ ok: true, effortCap: "high", subagentEffortCap: "medium" }); expect(config.effortCap).toBe("high"); @@ -476,7 +488,7 @@ describe("/api/effort-caps", () => { test("absent key unchanged; null clears; invalid ladder value -> 400", async () => { isolatedHome(); - const config = makeConfig({ effortCap: "high", subagentEffortCap: "medium" }); + const config = makeApiConfig({ effortCap: "high", subagentEffortCap: "medium" }); const keep = await put(config, { subagentEffortCap: "low" }); expect(keep.status).toBe(200); expect(config.effortCap).toBe("high"); diff --git a/tests/codex-integration/model-pinned-effort.test.ts b/tests/codex-integration/model-pinned-effort.test.ts new file mode 100644 index 0000000000..7c7b15401c --- /dev/null +++ b/tests/codex-integration/model-pinned-effort.test.ts @@ -0,0 +1,568 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolvePinnedEffort, applyPinnedEffort, prepareEffortNormalization, chatCollabSurface, applyChatEffortCap } from "../../src/server/effort-policy"; +import { handleManagementAPI } from "../../src/server/management-api"; +import { handleResponses } from "../../src/server/responses/core"; +import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleNativeChatCompletions } from "../../src/server/chat-native"; +import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; +import { parseRequest } from "../../src/responses/parser"; +import { routeModel } from "../../src/router"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../helpers/translator-budget"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; + +describe("model pinned reasoning effort policy", () => { + const providerWithPinned: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + pinnedReasoningEffort: "high", + modelPinnedReasoningEfforts: { + "special-model": "max", + "disabled-effort-model": "none", + }, + }; + + test("resolves model-specific pinned effort over provider-wide pinned effort", () => { + const route = { provider: providerWithPinned, modelId: "special-model" }; + expect(resolvePinnedEffort(route)).toBe("max"); + }); + + test("resolves provider-wide pinned effort when model is not specifically pinned", () => { + const route = { provider: providerWithPinned, modelId: "other-model" }; + expect(resolvePinnedEffort(route)).toBe("high"); + }); + + test("resolves global config modelPinnedEfforts fallback when provider has none", () => { + const emptyProvider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + }; + const config = { + modelPinnedEfforts: { "global-pinned": "max" }, + } as unknown as OcxConfig; + const route = { provider: emptyProvider, modelId: "global-pinned" }; + expect(resolvePinnedEffort(route, undefined, config)).toBe("max"); + }); + + test("applyPinnedEffort overrides caller effort in both parsed options and raw body", () => { + const route = { provider: providerWithPinned, modelId: "special-model" }; + const parsed: OcxParsedRequest = { + modelId: "special-model", + context: { messages: [] }, + stream: true, + options: { reasoning: "low" }, + _rawBody: { reasoning: { effort: "low" } }, + }; + + const rewrite = applyPinnedEffort(parsed, route); + expect(rewrite).toEqual({ from: "low", to: "max" }); + expect(parsed.options.reasoning).toBe("max"); + expect((parsed._rawBody as any).reasoning.effort).toBe("max"); + }); + + test("applyPinnedEffort applies pinned effort when caller sent none", () => { + const route = { provider: providerWithPinned, modelId: "other-model" }; + const parsed: OcxParsedRequest = { + modelId: "other-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: {}, + }; + + const rewrite = applyPinnedEffort(parsed, route); + expect(rewrite).toEqual({ from: undefined, to: "high" }); + expect(parsed.options.reasoning).toBe("high"); + expect((parsed._rawBody as any).reasoning.effort).toBe("high"); + }); + + test("applyPinnedEffort with none strips effort from both shapes", () => { + const route = { provider: providerWithPinned, modelId: "disabled-effort-model" }; + const parsed: OcxParsedRequest = { + modelId: "disabled-effort-model", + context: { messages: [] }, + stream: true, + options: { reasoning: "high" }, + _rawBody: { reasoning: { effort: "high", summary: "auto" } }, + }; + + const rewrite = applyPinnedEffort(parsed, route); + expect(rewrite).toEqual({ from: "high", to: "none" }); + expect(parsed.options.reasoning).toBeUndefined(); + expect((parsed._rawBody as any).reasoning.effort).toBeUndefined(); + expect((parsed._rawBody as any).reasoning.summary).toBe("auto"); + }); +}); + +describe("management API pinned reasoning effort configuration", () => { + let tempHome: string | undefined; + const savedHome = process.env.OPENCODEX_HOME; + afterEach(() => { + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + if (tempHome) removeTreeWithRetry(tempHome); + tempHome = undefined; + }); + function isolatedHome(): void { + tempHome = mkdtempSync(join(tmpdir(), "ocx-pinned-effort-")); + process.env.OPENCODEX_HOME = tempHome; + } + + function makeConfig(overrides: Partial = {}): OcxConfig { + return { + version: 1, + defaultProvider: "custom", + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://api.custom.com", + allowPrivateNetwork: true, + }, + }, + ...overrides, + } as unknown as OcxConfig; + } + + test("PATCH /api/providers sets and updates pinned reasoning efforts", async () => { + isolatedHome(); + const config = makeConfig(); + const patchReq = new Request("http://localhost/api/providers?name=custom", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pinnedReasoningEffort: "high", + modelPinnedReasoningEfforts: { "model-a": "max", "model-b": "low" }, + }), + }); + const patchRes = await handleManagementAPI(patchReq, new URL(patchReq.url), config); + expect(patchRes?.status).toBe(200); + const provider = config.providers.custom; + expect(provider.pinnedReasoningEffort).toBe("high"); + expect(provider.modelPinnedReasoningEfforts).toEqual({ "model-a": "max", "model-b": "low" }); + + // Updating with whitespace key normalizes to trimmed model id + const wsReq = new Request("http://localhost/api/providers?name=custom", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelPinnedReasoningEfforts: { " model-c ": "medium" }, + }), + }); + const wsRes = await handleManagementAPI(wsReq, new URL(wsReq.url), config); + expect(wsRes?.status).toBe(200); + expect(config.providers.custom.modelPinnedReasoningEfforts).toEqual({ "model-a": "max", "model-b": "low", "model-c": "medium" }); + + // Clearing a model pinned effort with whitespace key + const wsClearReq = new Request("http://localhost/api/providers?name=custom", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelPinnedReasoningEfforts: { " model-c ": null }, + }), + }); + const wsClearRes = await handleManagementAPI(wsClearReq, new URL(wsClearReq.url), config); + expect(wsClearRes?.status).toBe(200); + expect(config.providers.custom.modelPinnedReasoningEfforts).toEqual({ "model-a": "max", "model-b": "low" }); + + // Clearing a model pinned effort + const clearReq = new Request("http://localhost/api/providers?name=custom", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelPinnedReasoningEfforts: { "model-a": null }, + }), + }); + const clearRes = await handleManagementAPI(clearReq, new URL(clearReq.url), config); + expect(clearRes?.status).toBe(200); + expect(config.providers.custom.modelPinnedReasoningEfforts).toEqual({ "model-b": "low" }); + }); + + test("PATCH /api/providers rejects invalid reasoning effort values", async () => { + isolatedHome(); + const config = makeConfig(); + const badReq = new Request("http://localhost/api/providers?name=custom", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pinnedReasoningEffort: "invalid-tier", + }), + }); + const badRes = await handleManagementAPI(badReq, new URL(badReq.url), config); + expect(badRes?.status).toBe(400); + }); + + test("PUT /api/effort-caps supports modelPinnedEfforts roundtrip", async () => { + isolatedHome(); + const config = makeConfig(); + const putReq = new Request("http://localhost/api/effort-caps", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelPinnedEfforts: { "gpt-5.5": "max", "claude-sonnet-4-6": "high" }, + }), + }); + const putRes = await handleManagementAPI(putReq, new URL(putReq.url), config); + expect(putRes?.status).toBe(200); + expect(config.modelPinnedEfforts).toEqual({ "gpt-5.5": "max", "claude-sonnet-4-6": "high" }); + + const getReq = new Request("http://localhost/api/effort-caps"); + const getRes = await handleManagementAPI(getReq, new URL(getReq.url), config); + const data = await getRes?.json() as { modelPinnedEfforts: Record }; + expect(data.modelPinnedEfforts).toEqual({ "gpt-5.5": "max", "claude-sonnet-4-6": "high" }); + + // Partial merge: add one model, clear another + const updateReq = new Request("http://localhost/api/effort-caps", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + modelPinnedEfforts: { "gemini-3.7-flash": "high", "gpt-5.5": null }, + }), + }); + const updateRes = await handleManagementAPI(updateReq, new URL(updateReq.url), config); + expect(updateRes?.status).toBe(200); + expect(config.modelPinnedEfforts).toEqual({ "claude-sonnet-4-6": "high", "gemini-3.7-flash": "high" }); + }); +}); +import { ManagementRequest as Request } from "../helpers/management-auth"; + +describe("native chat completions effort policy", () => { + + test("detects v2 collab surface in native chat tools", () => { + const chatBody = { + tools: [ + { type: "function", function: { name: "spawn_agent" } }, + { type: "function", function: { name: "send_message" } }, + ], + }; + expect(chatCollabSurface(chatBody)).toBe("v2"); + }); + + test("applyChatEffortCap respects effortCap ceiling over pinned effort", () => { + const config = { + effortCap: "low", + }; + const chatBody = { + reasoning_effort: "max", + }; + const rewrite = applyChatEffortCap(chatBody, new Headers(), config, ["low", "medium", "high", "max"]); + expect(rewrite).toEqual({ from: "max", to: "low", subagent: false }); + expect(chatBody.reasoning_effort).toBe("low"); + }); +}); + +// Exercise the real ingress/adapter serializers. Only the upstream fetch is replaced; +// unexpected destinations fail closed instead of reaching a live provider. +describe("operator pins on the actual request wire", () => { + const originalFetch = globalThis.fetch; + let savedHome: string | undefined; + let home: string; + let codexHome: IsolatedCodexHome; + let captured: Array<{ url: string; body: Record }>; + let failFirst: boolean; + let failureStatus: number; + let onFirstSend: (() => void) | undefined; + + beforeEach(() => { + savedHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-pin-wire-")); + process.env.OPENCODEX_HOME = home; + codexHome = installIsolatedCodexHome("ocx-pin-wire-codex-"); + captured = []; + failFirst = false; + failureStatus = 503; + onFirstSend = undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof globalThis.Request ? input.url : String(input); + if (!url.startsWith("http://127.0.0.1:65534/")) throw new Error("unexpected pin-test destination"); + const body = JSON.parse(String(init?.body)) as Record; + captured.push({ url, body }); + if (captured.length === 1) onFirstSend?.(); + if (failFirst && captured.length === 1) { + return Response.json({ error: { message: "fixture unavailable", type: "server_error" } }, + { status: failureStatus, headers: { "retry-after": "0" } }); + } + if (url.endsWith("/chat/completions")) { + if (body.stream === true) { + return new Response([ + 'data: {"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\n', + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}\n\n', + 'data: [DONE]\n\n', + ].join(""), { headers: { "content-type": "text/event-stream" } }); + } + return Response.json({ + id: "chatcmpl_pin", object: "chat.completion", model: body.model, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + } + return Response.json({ + id: "resp_pin", object: "response", model: body.model, status: "completed", + output: [{ type: "message", id: "msg_pin", role: "assistant", status: "completed", + content: [{ type: "output_text", text: "ok", annotations: [] }] }], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + if (savedHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = savedHome; + codexHome.restore(); + removeTreeWithRetry(home); + }); + + function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", authMode: "key", apiKey: "fixture-pin-key", + baseUrl: "http://127.0.0.1:65534/v1", allowPrivateNetwork: true, + liveModels: false, models: ["pin-model"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + ...overrides, + }; + } + + function config(p: Partial = {}, overrides: Partial = {}): OcxConfig { + return { port: 0, defaultProvider: "fixture", providers: { fixture: provider(p) }, + multiAgentGuidanceEnabled: false, ...overrides }; + } + + async function request(c: OcxConfig, inbound: "chat" | "responses", extra: Record = {}, headers: HeadersInit = {}) { + const body = inbound === "chat" + ? { model: "fixture/pin-model", messages: [{ role: "user", content: "hello" }], stream: false, reasoning_effort: "low", ...extra } + : { model: "fixture/pin-model", input: "hello", stream: false, reasoning: { effort: "low", summary: "auto" }, ...extra }; + const req = new Request(`http://localhost/v1/${inbound === "chat" ? "chat/completions" : "responses"}`, { + method: "POST", headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) }, + body: JSON.stringify(body), + }); + const response = inbound === "chat" + ? await handleChatCompletions(req, c, { model: "", provider: "" }) + : await handleResponses(req, c, { model: "", provider: "" }, { abortSignal: AbortSignal.timeout(5_000) }); + const text = await response.text(); + expect(response.status, text).toBe(200); + expect(captured.length).toBeGreaterThan(0); + return captured.at(-1)!.body; + } + + for (const inbound of ["chat", "responses"] as const) { + test(`${inbound}: ultra pin maps to max on the Chat wire`, async () => { + const wire = await request(config({ pinnedReasoningEffort: "ultra" }), inbound); + expect(wire.reasoning_effort).toBe("max"); + }); + + test(`${inbound}: none omits effort instead of sending none`, async () => { + const wire = await request(config({ pinnedReasoningEffort: "none" }), inbound); + expect(Object.hasOwn(wire, "reasoning_effort")).toBe(false); + }); + + test(`${inbound}: minimal pin uses the existing low wire mapping`, async () => { + expect((await request(config({ pinnedReasoningEffort: "minimal" }), inbound)).reasoning_effort).toBe("low"); + }); + + test(`${inbound}: provider-model > provider-wide > global`, async () => { + const c = config({ pinnedReasoningEffort: "high", modelPinnedReasoningEfforts: { "pin-model": "xhigh" } }, + { modelPinnedEfforts: { "fixture/pin-model": "medium" } }); + expect((await request(c, inbound)).reasoning_effort).toBe("xhigh"); + delete c.providers.fixture!.modelPinnedReasoningEfforts; + expect((await request(c, inbound)).reasoning_effort).toBe("high"); + delete c.providers.fixture!.pinnedReasoningEffort; + expect((await request(c, inbound)).reasoning_effort).toBe("medium"); + }); + + test(`${inbound}: exact selector > qualified destination > bare destination`, async () => { + const c = config({ modelAliases: { "pin-model": "friendly" } }, { + modelPinnedEfforts: { "fixture/friendly": "xhigh", "fixture/pin-model": "high", "pin-model": "medium" }, + }); + expect((await request(c, inbound, { model: "fixture/friendly" })).reasoning_effort).toBe("xhigh"); + delete c.modelPinnedEfforts!["fixture/friendly"]; + expect((await request(c, inbound, { model: "fixture/friendly" })).reasoning_effort).toBe("high"); + delete c.modelPinnedEfforts!["fixture/pin-model"]; + expect((await request(c, inbound, { model: "fixture/friendly" })).reasoning_effort).toBe("medium"); + }); + + test(`${inbound}: qualified global lookup retains case-fold semantics`, async () => { + const c = config({}, { modelPinnedEfforts: { "FIXTURE/PIN-MODEL": "high", "pin-model": "medium" } }); + expect((await request(c, inbound)).reasoning_effort).toBe("high"); + }); + + test(`${inbound}: provider model selector fallback precedes provider-wide pin`, async () => { + const c = config({ modelAliases: { "pin-model": "friendly" }, pinnedReasoningEffort: "high", + modelPinnedReasoningEfforts: { "fixture/friendly": "medium" } }); + expect((await request(c, inbound, { model: "fixture/friendly" })).reasoning_effort).toBe("medium"); + }); + + test(`${inbound}: applicable child cap follows pin, before wire alias`, async () => { + const c = config({ pinnedReasoningEffort: "ultra", reasoningEffortMap: { medium: "enabled" } }, + { effortCap: "high", subagentEffortCap: "medium" }); + const wire = await request(c, inbound, {}, { "x-openai-subagent": "collab_spawn" }); + expect(wire.reasoning_effort).toBe("enabled"); + }); + + test(`${inbound}: v2 main cap follows pin; v1 main leaves it alone`, async () => { + const c = config({ pinnedReasoningEffort: "max" }, { effortCap: "medium" }); + const tools = inbound === "chat" + ? [{ type: "function", function: { name: "spawn_agent", parameters: { type: "object", properties: {} } } }] + : [{ type: "function", name: "spawn_agent", parameters: { type: "object", properties: {} } }]; + expect((await request(c, inbound, { tools })).reasoning_effort).toBe("medium"); + c.multiAgentMode = "v1"; + expect((await request(c, inbound, { tools })).reasoning_effort).toBe("max"); + }); + + test(`${inbound}: cap below all supported rungs omits pinned effort`, async () => { + const c = config({ pinnedReasoningEffort: "max", reasoningEfforts: ["high", "max"] }, { subagentEffortCap: "low" }); + expect(Object.hasOwn(await request(c, inbound, {}, { "x-openai-subagent": "collab_spawn" }), "reasoning_effort")).toBe(false); + }); + } + + test("Responses passthrough none preserves reasoning.summary", async () => { + const wire = await request(config({ adapter: "openai-responses", pinnedReasoningEffort: "none" }), "responses"); + expect(wire.reasoning).toEqual({ summary: "auto" }); + }); + + test("Responses passthrough maps a pinned ultra through its declared ladder", async () => { + const wire = await request(config({ adapter: "openai-responses", pinnedReasoningEffort: "ultra" }), "responses"); + expect(wire.reasoning).toEqual({ effort: "max", summary: "auto" }); + }); + + test("native Chat without pins preserves caller wire spelling and existing cap behavior", async () => { + const c = config({ reasoningEfforts: ["low"], reasoningEffortMap: { max: "enabled" } }, { effortCap: "low", subagentEffortCap: "low" }); + expect((await request(c, "chat", { reasoning_effort: "ultra" }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("ultra"); + expect(Object.hasOwn(await request(c, "chat", { reasoning_effort: undefined }), "reasoning_effort")).toBe(false); + }); + + test("unpinned Responses keeps its existing applicable cap", async () => { + expect((await request(config({}, { subagentEffortCap: "medium" }), "responses", + { reasoning: { effort: "max", summary: "auto" } }, { "x-openai-subagent": "collab_spawn" })).reasoning_effort).toBe("medium"); + }); + + test("routed compaction skips pins and caps", async () => { + const wire = await request(config({ pinnedReasoningEffort: "max" }, { subagentEffortCap: "low" }), "responses", { + input: [{ role: "user", content: "summarize this" }, { type: "compaction_trigger" }], + reasoning: { effort: "medium", summary: "auto" }, + }, { "x-openai-subagent": "collab_spawn" }); + expect(wire.reasoning_effort).toBe("medium"); + }); + + test("synthetic rows retain effective effort and exclude synthetic global pin keys", async () => { + const c = config({}, { cursorEffortRows: true, modelPinnedEfforts: { "fixture/pin-model--high": "max" } }); + expect((await request(c, "responses", { model: "fixture/pin-model--high" })).reasoning_effort).toBe("high"); + c.modelPinnedEfforts!["fixture/pin-model"] = "medium"; + expect((await request(c, "responses", { model: "fixture/pin-model--high" })).reasoning_effort).toBe("medium"); + }); + + test("combo failover recomputes each destination's default without leaking the first pin", async () => { + failFirst = true; + const c = config({}, { + providers: { + first: provider({ pinnedReasoningEffort: "max", reasoningEfforts: ["low", "high", "max"] }), + second: provider({ reasoningEfforts: ["low", "medium"] }), + }, + defaultProvider: "first", + modelPinnedEfforts: { "combo/pin-default": "low" }, + combos: { "pin-default": { strategy: "failover", defaultEffort: "high", targets: [ + { provider: "first", model: "pin-model" }, { provider: "second", model: "pin-model" }, + ] } }, + }); + const wire = await request(c, "responses", { model: "combo/pin-default", reasoning: { summary: "auto" } }); + expect(captured.map(({ body }) => body.reasoning_effort)).toEqual(["max", "medium"]); + expect(wire.reasoning_effort).toBe("medium"); + }); + + test("native repeated destinations restore only original effort and keep credential-retry decisions", async () => { + const c = config({}, { providers: { + first: provider({ pinnedReasoningEffort: "high" }), + second: provider(), + omit: provider({ pinnedReasoningEffort: "none" }), + last: provider(), + }, modelPinnedEfforts: { "first/pin-model": "xhigh", "last/pin-model": "medium" } }); + const body: Record = { model: "first/pin-model", messages: [{ role: "user", content: "hello" }], reasoning_effort: "low", reasoning: { summary: "auto" } }; + const req = new Request("http://localhost/v1/chat/completions", { method: "POST" }); + async function send(name: string) { + const response = await handleNativeChatCompletions({ req, config: c, logCtx: { model: "", provider: "" }, + route: routeModel(c, `${name}/pin-model`), chatBody: body, requestedModel: `${name}/pin-model`, + requestedStream: false, translatorBudget: createTestTranslatorBudget() }); + expect(response.status, await response.text()).toBe(200); + } + await send("first"); + c.providers.first!.pinnedReasoningEffort = "max"; + await send("first"); + body.reasoning = { summary: "detailed" }; + body.temperature = 0.2; + await send("second"); + await send("omit"); + await send("last"); + expect(captured.map(({ body }) => body.reasoning_effort)).toEqual(["high", "high", "low", undefined, "medium"]); + expect(body.reasoning).toEqual({ summary: "detailed" }); + expect(body.temperature).toBe(0.2); + }); + + test("native same-target retry keeps the already normalized pin decision", async () => { + failFirst = true; + failureStatus = 429; + const c = config({ pinnedReasoningEffort: "ultra", + retryOn429: { attempts: 1, intervalMs: 100, maxIntervalMs: 100, respectRetryAfter: false } }); + onFirstSend = () => { c.providers.fixture!.pinnedReasoningEffort = "low"; }; + await request(c, "chat"); + expect(captured.map(({ body }) => body.reasoning_effort)).toEqual(["max", "max"]); + }); +}); + +// The normalization entry is request-owned and shared with the real Responses path. +// Use the parser and adapter serializer to observe repeated destination normalization. +describe("repeated Responses effort normalization", () => { + test("restores pre-pin effective effort and raw presence while preserving unrelated edits", () => { + for (const reasoning of [{ effort: "medium", summary: "auto" }, { summary: "auto" }]) { + const parsed = parseRequest({ model: "first/pin-model", input: "hello", stream: false, reasoning }); + const first = { providerName: "first", modelId: "pin-model", provider: { adapter: "openai-chat" as const, + baseUrl: "http://127.0.0.1:65534/v1", pinnedReasoningEffort: "high" } }; + const second = { providerName: "second", modelId: "pin-model", provider: { ...first.provider, pinnedReasoningEffort: undefined } }; + prepareEffortNormalization(parsed, first); + parsed.modelId = first.modelId; + applyPinnedEffort(parsed, first); + const raw = parsed._rawBody as { reasoning: Record }; + raw.reasoning.summary = "detailed"; + parsed.options.temperature = 0.2; + prepareEffortNormalization(parsed, second); + applyPinnedEffort(parsed, second); + const wire = JSON.parse(withTestTranslatorBudget(createOpenAIChatAdapter(second.provider)).buildRequest(parsed).body); + expect(wire.reasoning_effort).toBe("effort" in reasoning ? "medium" : undefined); + expect(Object.hasOwn(raw.reasoning, "effort")).toBe("effort" in reasoning); + expect(raw.reasoning.summary).toBe("detailed"); + expect(parsed.options.temperature).toBe(0.2); + const omit = { ...second, providerName: "omit", provider: { ...second.provider, pinnedReasoningEffort: "none" } }; + prepareEffortNormalization(parsed, omit); + applyPinnedEffort(parsed, omit); + expect(raw.reasoning).toEqual({ summary: "detailed" }); + prepareEffortNormalization(parsed, second); + applyPinnedEffort(parsed, second); + expect(parsed.options.reasoning).toBe("effort" in reasoning ? "medium" : undefined); + } + }); + + test("pre-namespace selectors are destination-scoped and restore parser-normalized effort independently of raw effort", () => { + const parsed = parseRequest({ model: "first/pin-model", input: "hello", reasoning: { effort: "ultra", summary: "auto" } }); + const provider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "http://127.0.0.1:65534/v1" }; + const first = { providerName: "first", modelId: "pin-model", provider }; + const second = { ...first, providerName: "second" }; + const config = { port: 0, providers: { first: provider, second: provider }, + modelPinnedEfforts: { "first/pin-model": "high", "second/pin-model": "none" } }; + prepareEffortNormalization(parsed, first); + parsed.modelId = first.modelId; + applyPinnedEffort(parsed, first, config); + expect(parsed.options.reasoning).toBe("high"); + prepareEffortNormalization(parsed, second); + applyPinnedEffort(parsed, second, config); + expect(parsed.options.reasoning).toBeUndefined(); + const third = { ...first, providerName: "third" }; + prepareEffortNormalization(parsed, third); + applyPinnedEffort(parsed, third, config); + expect(parsed.options.reasoning).toBe("max"); + expect(parsed._rawBody).toMatchObject({ reasoning: { effort: "ultra", summary: "auto" } }); + const wire = JSON.parse(withTestTranslatorBudget(createOpenAIChatAdapter(provider)).buildRequest(parsed).body); + expect(wire.reasoning_effort).toBe("max"); + }); +}); diff --git a/tests/config/model-pinned-effort-config.test.ts b/tests/config/model-pinned-effort-config.test.ts new file mode 100644 index 0000000000..29c1d505fb --- /dev/null +++ b/tests/config/model-pinned-effort-config.test.ts @@ -0,0 +1,391 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + deleteConfigTopLevelKey, getConfigPath, getDefaultConfig, loadConfig, readConfigDiagnostics, + saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate, +} from "../../src/config"; +import { modelPinnedEffortsConfigError, pinnedReasoningEffortConfigError } from "../../src/config/provider-validation"; +import { configRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../src/config/rebase-provenance"; +import * as destinationPolicy from "../../src/lib/destination-policy"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { providerEditorConfigDTO, providerManagementConfigError, safeConfigDTO } from "../../src/server/auth-cors"; +import { handleAgentSettingsRoutes } from "../../src/server/management/agent-settings-routes"; +import { handleProviderRoutes } from "../../src/server/management/provider-routes"; +import type { ManagementContext } from "../../src/server/management/context"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { ManagementRequest } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let directory: string; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome; + +function fixture(): OcxConfig { + return { + ...getDefaultConfig(), defaultProvider: "alpha", + providers: { alpha: { + adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1", apiKey: "fixture-private-key", + pinnedReasoningEffort: "high", modelPinnedReasoningEfforts: { one: "low", two: "none" }, + } }, + effortCap: "max", subagentEffortCap: "medium", modelPinnedEfforts: { "alpha/one": "ultra", two: "minimal" }, + }; +} + +function context(config: OcxConfig, path: string, method: string, body?: unknown): ManagementContext { + const url = new URL(`http://localhost${path}`); + return { + url, config, version: "fixture", + req: new ManagementRequest(url, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }), + deps: { saveConfigPreservingClaudeCode, clearThreadAccountMap: () => {}, clearProviderQuotaCache: () => {} }, + convergeCodexCatalog: mock(async () => ({ status: "committed", changed: true, degraded: false, notices: [] } as const)), + syncClaudeAgentDefsBestEffort: mock(async () => {}), + }; +} + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + directory = mkdtempSync(join(tmpdir(), "ocx-pinned-config-")); + process.env.OPENCODEX_HOME = directory; + codexHome = installIsolatedCodexHome("ocx-pinned-codex-"); + saveConfig(fixture()); +}); + +afterEach(() => { + codexHome.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(directory); +}); + +describe("reasoning pin config boundaries", () => { + test("accepts declared efforts and rejects malformed maps, reserved keys and trim collisions", () => { + for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]) { + expect(pinnedReasoningEffortConfigError(effort)).toBeNull(); + expect(modelPinnedEffortsConfigError({ model: effort })).toBeNull(); + } + for (const value of [null, [], "high", new Date(), Object.create({ inherited: "high" }), + { " ": "high" }, { constructor: "low" }, { prototype: "low" }, + JSON.parse('{"__proto__":"high"}'), { " model ": "low", model: "high" }, { model: undefined }, + { model: "invented" }, { model: null }, { model: "" }]) { + expect(modelPinnedEffortsConfigError(value)).not.toBeNull(); + } + expect(modelPinnedEffortsConfigError({ model: null, other: "" }, "pins", true)).toBeNull(); + expect(modelPinnedEffortsConfigError({ " model ": null, model: "high" }, "pins", true)).not.toBeNull(); + }); + + test("load and diagnostics salvage the same entries without fallback, secret warnings or disk rewrite", () => { + const raw = fixture(); + const provider = raw.providers.alpha! as unknown as Record; + provider.pinnedReasoningEffort = { secret: "do-not-log-pin-value" }; + provider.modelPinnedReasoningEfforts = { keep: "none", bad: "do-not-log-pin-value", " clash ": "low", clash: "high" }; + raw.modelPinnedEfforts = JSON.parse('{"keep":"minimal","__proto__":"high"," ":"high","bad":12}'); + writeFileSync(getConfigPath(), JSON.stringify(raw)); + const before = readFileSync(getConfigPath(), "utf8"); + const filesBefore = readdirSync(directory).sort(); + const warnings: string[] = []; + const warn = spyOn(console, "warn").mockImplementation((...args) => { warnings.push(args.join(" ")); }); + try { + const loaded = loadConfig(); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + for (const config of [loaded, diagnostics.config]) { + expect(config.providers.alpha!.apiKey).toBe("fixture-private-key"); + expect(config.providers.alpha!.pinnedReasoningEffort).toBeUndefined(); + expect(config.providers.alpha!.modelPinnedReasoningEfforts).toEqual({ keep: "none" }); + expect(config.modelPinnedEfforts).toEqual({ keep: "minimal" }); + expect(config.defaultProvider).toBe("alpha"); + } + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.join("\n")).not.toContain("do-not-log-pin-value"); + expect(warnings.join("\n")).not.toContain("clash"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + expect(readdirSync(directory).sort()).toEqual(filesBefore); + } finally { warn.mockRestore(); } + }); + + test("candidate and direct writers reject invalid pins before live or disk mutation", () => { + for (const mutation of [ + (config: OcxConfig) => { config.modelPinnedEfforts = { model: "invalid" }; }, + (config: OcxConfig) => { config.providers.alpha!.pinnedReasoningEffort = "invalid"; }, + (config: OcxConfig) => { config.providers.alpha!.modelPinnedReasoningEfforts = { " ": "high" }; }, + (config: OcxConfig) => { Reflect.set(config, "modelPinnedEfforts", null); }, + ]) { + const config = loadConfig(); + mutation(config); + const beforeLive = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + expect(validateConfigCandidate(config).ok).toBe(false); + expect(() => saveConfig(config)).toThrow(); + expect(() => saveConfigPreservingClaudeCode(config)).toThrow(); + expect(config).toEqual(beforeLive); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + } + }); + + test("malformed whole maps degrade only their own optional fields in both read paths", () => { + const raw = fixture(); + Reflect.set(raw, "modelPinnedEfforts", []); + Reflect.set(raw.providers.alpha!, "modelPinnedReasoningEfforts", null); + writeFileSync(getConfigPath(), JSON.stringify(raw)); + const disk = readFileSync(getConfigPath(), "utf8"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const config of [loadConfig(), readConfigDiagnostics().config]) { + expect(config.modelPinnedEfforts).toBeUndefined(); + expect(config.providers.alpha!.modelPinnedReasoningEfforts).toBeUndefined(); + expect(config.providers.alpha!.pinnedReasoningEffort).toBe("high"); + expect(config.providers.alpha!.apiKey).toBe("fixture-private-key"); + } + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + } finally { warn.mockRestore(); } + }); + + test("strict candidate parsing normalizes pin keys without changing input", () => { + const config = fixture(); + config.modelPinnedEfforts = { " alpha/one ": "none" }; + config.providers.alpha!.modelPinnedReasoningEfforts = { " one ": "minimal" }; + const result = validateConfigCandidate(config); + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + expect(result.config.modelPinnedEfforts).toEqual({ "alpha/one": "none" }); + expect(result.config.providers.alpha!.modelPinnedReasoningEfforts).toEqual({ one: "minimal" }); + expect(config.modelPinnedEfforts).toEqual({ " alpha/one ": "none" }); + }); + + test("canonical OpenAI admits validated pin overlays while retaining transport and credential checks", () => { + const seed = providerConfigSeed(getProviderRegistryEntry("openai")!); + const pins = { pinnedReasoningEffort: "none", modelPinnedReasoningEfforts: { "gpt-test": "ultra" } }; + const provider = { ...seed, ...pins }; + expect(providerManagementConfigError("openai", provider)).toBeNull(); + for (const patch of [ + { pinnedReasoningEffort: "invalid" }, { modelPinnedReasoningEfforts: [] }, + { modelPinnedReasoningEfforts: { constructor: "high" } }, + { baseUrl: "https://elsewhere.example.test/v1" }, { authMode: "local" }, { apiKey: "do-not-admit" }, + ]) expect(providerManagementConfigError("openai", { ...provider, ...patch })).not.toBeNull(); + const config = { ...getDefaultConfig(), providers: { openai: provider } }; + expect(providerEditorConfigDTO(config).providers.openai).toMatchObject(pins); + const privateConfig = fixture(); + expect(providerEditorConfigDTO(privateConfig).providers.alpha).not.toHaveProperty("apiKey"); + expect(JSON.stringify(safeConfigDTO(privateConfig))).not.toContain("fixture-private-key"); + }); +}); + +describe("provider pin management", () => { + test("GET returns provider pins and canonical OpenAI PATCH/POST round-trip them", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const config = getDefaultConfig(); + config.providers.openai = providerConfigSeed(getProviderRegistryEntry("openai")!); + saveConfig(config); + expect((await handleProviderRoutes(context(config, "/api/providers?name=openai", "PATCH", { + pinnedReasoningEffort: "none", modelPinnedReasoningEfforts: { "gpt-test": "ultra" }, + })))?.status).toBe(200); + const response = await handleProviderRoutes(context(config, "/api/providers", "GET")); + const providers = await response!.json() as Array<{ name: string; pinnedReasoningEffort?: string; modelPinnedReasoningEfforts?: Record }>; + expect(providers.find(provider => provider.name === "openai")).toMatchObject({ + pinnedReasoningEffort: "none", modelPinnedReasoningEfforts: { "gpt-test": "ultra" }, + }); + const seed = providerConfigSeed(getProviderRegistryEntry("openai")!); + expect((await handleProviderRoutes(context(config, "/api/providers", "POST", { name: "openai", provider: seed })))?.status).toBe(200); + expect(loadConfig().providers.openai).toMatchObject({ pinnedReasoningEffort: "none", modelPinnedReasoningEfforts: { "gpt-test": "ultra" } }); + } finally { dns.mockRestore(); } + }); + + test("PATCH merges normalized keys, clears entries and persists whole-field clears", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const config = loadConfig(); + let ctx = context(config, "/api/providers?name=alpha", "PATCH", { + modelPinnedReasoningEfforts: { " one ": null, " three ": "ultra" }, + }); + expect((await handleProviderRoutes(ctx))?.status).toBe(200); + expect(config.providers.alpha!.modelPinnedReasoningEfforts).toEqual({ two: "none", three: "ultra" }); + expect(config.providers.alpha!.pinnedReasoningEffort).toBe("high"); + ctx = context(config, "/api/providers?name=alpha", "PATCH", { pinnedReasoningEffort: null, modelPinnedReasoningEfforts: null }); + expect((await handleProviderRoutes(ctx))?.status).toBe(200); + const reloaded = loadConfig(); + expect(reloaded.providers.alpha).not.toHaveProperty("pinnedReasoningEffort"); + expect(reloaded.providers.alpha).not.toHaveProperty("modelPinnedReasoningEfforts"); + } finally { dns.mockRestore(); } + }); + + test("POST omission preserves pins; entry tombstones and explicit null do not remerge old pins", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const config = loadConfig(); + const base = { adapter: "openai-chat", baseUrl: "https://alpha.example.test/v1" }; + for (const [patch, expectedScalar, expectedMap] of [ + [{}, "high", { one: "low", two: "none" }], + [{ modelPinnedReasoningEfforts: { one: "", " three ": "minimal" } }, "high", { two: "none", three: "minimal" }], + [{ pinnedReasoningEffort: null, modelPinnedReasoningEfforts: null }, undefined, undefined], + ] as const) { + const ctx = context(config, "/api/providers", "POST", { name: "alpha", provider: { ...base, ...patch } }); + expect((await handleProviderRoutes(ctx))?.status).toBe(200); + const reloaded = loadConfig().providers.alpha!; + expect(reloaded.pinnedReasoningEffort).toBe(expectedScalar); + expect(reloaded.modelPinnedReasoningEfforts).toEqual(expectedMap); + } + } finally { dns.mockRestore(); } + }); + + test("invalid pin PATCH/POST leaves live and disk unchanged and never calls save", async () => { + const config = loadConfig(); + const beforeLive = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + for (const method of ["PATCH", "POST"]) { + const pins = { pinnedReasoningEffort: "low", modelPinnedReasoningEfforts: { " same ": "none", same: "high" } }; + const ctx = context(config, "/api/providers?name=alpha", method, method === "POST" + ? { name: "alpha", provider: { ...config.providers.alpha, ...pins } } : pins); + ctx.deps.saveConfigPreservingClaudeCode = mock(() => {}); + expect((await handleProviderRoutes(ctx))?.status).toBe(400); + expect(ctx.deps.saveConfigPreservingClaudeCode).not.toHaveBeenCalled(); + expect(config).toEqual(beforeLive); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + } + }); + + test("PATCH and POST save failures restore exact provider ownership and pending deletion metadata", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + for (const method of ["PATCH", "POST"]) { + const config = loadConfig(); + deleteConfigTopLevelKey(config, "modelPickerOrder"); + const row = config.providers.alpha; + const beforeLive = structuredClone(config); + const beforeProjection = projectConfigRebaseProvenance(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + const patch = { pinnedReasoningEffort: null, modelPinnedReasoningEfforts: null }; + const ctx = context(config, "/api/providers?name=alpha", method, method === "POST" + ? { name: "alpha", provider: { ...row, ...patch }, setDefault: true } : patch); + ctx.deps.saveConfigPreservingClaudeCode = () => { + deleteConfigTopLevelKey(config, "modelPinnedEfforts"); + // Restore the value but leave the injected deletion intent pending. + config.modelPinnedEfforts = beforeLive.modelPinnedEfforts; + config.configRebaseProvenance = { version: 1, deletedTopLevelKeys: ["effortCap"] }; + throw new Error("fixture pin save failure"); + }; + await expect(handleProviderRoutes(ctx)).rejects.toThrow("fixture pin save failure"); + expect(config.providers.alpha).toBe(row); + expect(config).toEqual(beforeLive); + expect(projectConfigRebaseProvenance(config)).toEqual(beforeProjection); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + } + } finally { dns.mockRestore(); } + }); + + test("raw editor omission deletes both pin fields while preserving provider credentials", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const config = loadConfig(); + const baseline = providerEditorConfigDTO(config); + const next = structuredClone(baseline); + delete next.providers.alpha!.pinnedReasoningEffort; + delete next.providers.alpha!.modelPinnedReasoningEfforts; + expect((await handleProviderRoutes(context(config, "/api/providers", "PUT", { baseline, next })))?.status).toBe(200); + const provider = loadConfig().providers.alpha!; + expect(provider).not.toHaveProperty("pinnedReasoningEffort"); + expect(provider).not.toHaveProperty("modelPinnedReasoningEfforts"); + expect(provider.apiKey).toBe("fixture-private-key"); + } finally { dns.mockRestore(); } + }); + + test("new provider POST save failure restores registration state, default and absent row", async () => { + const dns = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); + try { + const config = loadConfig(); + config.disabledModels = ["beta/stale", "alpha/keep"]; + config.modelDiscovery = { + knownModels: { beta: { ids: ["stale"], removed: [], updatedAt: "2026-01-01T00:00:00Z" } }, + recentArrivals: { beta: [{ id: "stale", at: "2026-01-01T00:00:00Z" }] }, + }; + const before = structuredClone(config); + const disk = readFileSync(getConfigPath(), "utf8"); + const ctx = context(config, "/api/providers", "POST", { name: "beta", setDefault: true, provider: { + adapter: "openai-chat", baseUrl: "https://beta.example.test/v1", pinnedReasoningEffort: "minimal", + } }); + ctx.deps.saveConfigPreservingClaudeCode = candidate => { + expect(candidate.defaultProvider).toBe("beta"); + expect(candidate.disabledModels).toEqual(["alpha/keep"]); + expect(candidate.modelDiscovery!.knownModels).not.toHaveProperty("beta"); + expect(candidate.modelDiscovery!.recentArrivals).not.toHaveProperty("beta"); + throw new Error("fixture registration save failure"); + }; + await expect(handleProviderRoutes(ctx)).rejects.toThrow("fixture registration save failure"); + expect(config).toEqual(before); + expect(config.providers).not.toHaveProperty("beta"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + } finally { dns.mockRestore(); } + }); +}); + +describe("effort caps pin transaction", () => { + test("GET exposes pins; mixed invalid PUT requests leave live and disk unchanged", async () => { + const config = loadConfig(); + const get = await handleAgentSettingsRoutes(context(config, "/api/effort-caps", "GET")); + expect(await get!.json()).toMatchObject({ modelPinnedEfforts: { "alpha/one": "ultra", two: "minimal" } }); + const before = structuredClone(config); + const disk = readFileSync(getConfigPath(), "utf8"); + for (const patch of [ + { effortCap: "low", modelPinnedEfforts: { bad: "invalid" } }, + { effortCap: null, subagentEffortCap: "invalid", modelPinnedEfforts: null }, + { effortCap: "low", modelPinnedEfforts: { " two ": null, two: "high" } }, + { effortCap: "low", modelPinnedEfforts: JSON.parse('{"__proto__":"high"}') }, + null, [], + ]) { + const ctx = context(config, "/api/effort-caps", "PUT", patch); + ctx.deps.saveConfigPreservingClaudeCode = mock(() => {}); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(400); + expect(ctx.deps.saveConfigPreservingClaudeCode).not.toHaveBeenCalled(); + expect(config).toEqual(before); + expect(configRebaseDeletionKeys(config).size).toBe(0); + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + } + }); + + test("PUT merges pin keys and persists null clears with deletion provenance", async () => { + const config = loadConfig(); + let ctx = context(config, "/api/effort-caps", "PUT", { effortCap: "high", modelPinnedEfforts: { two: "", " third ": "none" } }); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(200); + expect(loadConfig().modelPinnedEfforts).toEqual({ "alpha/one": "ultra", third: "none" }); + ctx = context(config, "/api/effort-caps", "PUT", { effortCap: null, subagentEffortCap: null, modelPinnedEfforts: null }); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(200); + const reloaded = loadConfig(); + for (const key of ["effortCap", "subagentEffortCap", "modelPinnedEfforts"] as const) { + expect(reloaded).not.toHaveProperty(key); + expect(configRebaseDeletionKeys(reloaded).has(key)).toBe(true); + } + }); + + test("save failure rolls back caps, pins, provenance and preexisting pending deletion intent", async () => { + const config = loadConfig(); + deleteConfigTopLevelKey(config, "modelPickerOrder"); + const before = structuredClone(config); + const projection = projectConfigRebaseProvenance(config); + const disk = readFileSync(getConfigPath(), "utf8"); + const ctx = context(config, "/api/effort-caps", "PUT", { effortCap: null, subagentEffortCap: "low", modelPinnedEfforts: null }); + ctx.deps.saveConfigPreservingClaudeCode = () => { throw new Error("fixture disk full"); }; + await expect(handleAgentSettingsRoutes(ctx)).rejects.toThrow("fixture disk full"); + expect(config).toEqual(before); + expect(projectConfigRebaseProvenance(config)).toEqual(projection); + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + saveConfigPreservingClaudeCode(config); + expect(loadConfig().modelPinnedEfforts).toEqual(before.modelPinnedEfforts); + expect(configRebaseDeletionKeys(loadConfig()).has("modelPickerOrder")).toBe(true); + }); + + test("unknown future deletion provenance rejects a clear before mutation", async () => { + const config = loadConfig(); + config.configRebaseProvenance = { version: 2, future: true }; + const before = structuredClone(config); + const ctx = context(config, "/api/effort-caps", "PUT", { modelPinnedEfforts: null }); + ctx.deps.saveConfigPreservingClaudeCode = mock(() => {}); + expect((await handleAgentSettingsRoutes(ctx))?.status).toBe(409); + expect(config).toEqual(before); + expect(ctx.deps.saveConfigPreservingClaudeCode).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 05ce7e87ff..fbd8836dea 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1136,5 +1136,7 @@ "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" } diff --git a/tests/server/model-costs-management-api.test.ts b/tests/server/model-costs-management-api.test.ts index 856bf9ee79..b1994bfb77 100644 --- a/tests/server/model-costs-management-api.test.ts +++ b/tests/server/model-costs-management-api.test.ts @@ -60,7 +60,7 @@ afterEach(() => { function harness(config = fixture(), persist?: (saved: OcxConfig) => void) { const persisted: OcxConfig[] = []; let convergeCalls = 0; - async function call(method: "GET" | "PUT", body?: unknown, provider = PROVIDER, rawBody?: string, rawProvider?: string) { + async function call(method: "GET" | "PUT", body?: unknown, provider = PROVIDER, rawBody?: string | ReadableStream, rawProvider?: string) { const url = new URL(`http://127.0.0.1:10100/api/providers/${rawProvider ?? encodeURIComponent(provider)}/model-costs`); const response = await handleModelRoutes({ version: "test", @@ -89,6 +89,23 @@ function harness(config = fixture(), persist?: (saved: OcxConfig) => void) { return { call, config, persisted, get convergeCalls() { return convergeCalls; } }; } +/** No eager buffering: requested resolves only when the request parser pulls the body. */ +function deferredJsonBody(value: unknown) { + let requestPull!: () => void; + let release!: () => void; + const requested = new Promise(resolve => { requestPull = resolve; }); + const released = new Promise(resolve => { release = resolve; }); + const body = new ReadableStream({ + async pull(controller) { + requestPull(); + await released; + controller.enqueue(new TextEncoder().encode(JSON.stringify(value))); + controller.close(); + }, + }, { highWaterMark: 0 }); + return { body, requested, release }; +} + describe("provider model costs API", () => { test("GET returns the exact configured provider's sanitized map or an empty map", async () => { const h = harness(); @@ -140,6 +157,69 @@ describe("provider model costs API", () => { expect(activeUserCostOverlays().some(row => row.provider === PROVIDER && row.modelId === "org/model")).toBe(false); }); + test("price PUT follows a provider row replaced by a pin edit while parsing its body", async () => { + const config = fixture({ "org/model": ZERO, sibling: SIBLING }); + writeFileSync(join(home, "config.json"), JSON.stringify(config)); + const h = harness(config, saveConfigPreservingClaudeCode); + const oldRow = config.providers[PROVIDER]!; + const oldCosts = oldRow.modelCosts; + const oldSnapshot = structuredClone(oldRow); + const deferred = deferredJsonBody({ modelId: "org/model", cost: COST }); + const pending = h.call("PUT", undefined, PROVIDER, deferred.body); + await deferred.requested; + + // Reproduce the provider PATCH ownership boundary without DNS or catalog side effects. + // This exercises row replacement during body parsing, not the pin PATCH route itself. + const newerSibling: ProviderCostOverlay = { input: 9, output: 11, cacheRead: 1, cacheWrite: 6 }; + const replacement = { + ...oldRow, + pinnedReasoningEffort: "high", + modelCosts: { ...oldRow.modelCosts, sibling: newerSibling, "newer/sibling": SIBLING }, + }; + config.providers[PROVIDER] = replacement; + deferred.release(); + + const response = await pending; + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, provider: PROVIDER, modelId: "org/model", cost: COST }); + expect(config.providers[PROVIDER]).toBe(replacement); + const expected = { "org/model": COST, sibling: newerSibling, "newer/sibling": SIBLING }; + expect(replacement.pinnedReasoningEffort).toBe("high"); + expect(replacement.modelCosts).toEqual(expected); + expect(oldRow).toEqual(oldSnapshot); + expect(oldRow.modelCosts).toBe(oldCosts); + expect(h.persisted).toHaveLength(1); + expect(h.persisted[0]!.providers[PROVIDER]!.pinnedReasoningEffort).toBe("high"); + expect(h.persisted[0]!.providers[PROVIDER]!.modelCosts).toEqual(expected); + const disk = JSON.parse(readFileSync(join(home, "config.json"), "utf8")) as OcxConfig; + expect(disk.providers[PROVIDER]!.pinnedReasoningEffort).toBe("high"); + expect(disk.providers[PROVIDER]!.modelCosts).toEqual(expected); + expect(h.convergeCalls).toBe(0); + }); + + test("price PUT returns 404 without persisting if the provider is removed during body parsing", async () => { + const config = fixture({ "org/model": ZERO, sibling: SIBLING }); + writeFileSync(join(home, "config.json"), JSON.stringify(config)); + const diskBefore = readFileSync(join(home, "config.json"), "utf8"); + const h = harness(config, saveConfigPreservingClaudeCode); + const oldRow = config.providers[PROVIDER]!; + const oldSnapshot = structuredClone(oldRow); + const deferred = deferredJsonBody({ modelId: "org/model", cost: COST }); + const pending = h.call("PUT", undefined, PROVIDER, deferred.body); + await deferred.requested; + delete config.providers[PROVIDER]; + deferred.release(); + + const response = await pending; + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ error: "provider not found" }); + expect(Object.hasOwn(config.providers, PROVIDER)).toBe(false); + expect(oldRow).toEqual(oldSnapshot); + expect(h.persisted).toHaveLength(0); + expect(readFileSync(join(home, "config.json"), "utf8")).toBe(diskBefore); + expect(h.convergeCalls).toBe(0); + }); + test("persist failure restores map identity and own-property absence for set and reset", async () => { for (const costs of [undefined, {}, { "org/model": COST, sibling: SIBLING }]) { for (const cost of [SIBLING, null]) {