diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 73214bb37..05d371bc2 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -24,6 +24,10 @@ is deliberately loopback-only; remote `x-opencodex-api-key` wiring is deferred. `HERMES_HOME`, `KIMI_CODE_HOME`, and `XDG_CONFIG_HOME` paths are likewise followed rather than guessed at. The table lists each client's default. +For native OpenAI models, the generated OMP block selects its model-level Responses API, preserving +image input and reasoning-effort controls. Routed models retain the provider's Chat Completions +dialect so their existing adapters remain compatible. + OpenClaw has several, and they do different jobs. `OPENCLAW_CONFIG_PATH` selects the file; `OPENCLAW_STATE_DIR`, `OPENCLAW_PROFILE` and `OPENCLAW_HOME` select the state directory, which is also what detection looks at — so a profile or relocated home @@ -66,11 +70,12 @@ edits were yours. ## What to expect, honestly -**Formatting is not preserved.** Applying parses your config and writes it back out, so -every format may be reformatted, and YAML, JSON5 and TOML additionally lose their -comments. Your settings survive the round trip and the bytes change. If you need the -file exactly as it was, use Restore rather than Disable: the snapshot is a verbatim -copy. +**Formatting is generally not preserved.** Applying parses a config and writes it back +out, so JSON, JSON5 and TOML may be reformatted and comments in JSON5 or TOML are lost. +OMP is the exception: its YAML writer patches only `providers.opencodex`, preserving +unrelated provider comments and formatting byte-for-byte. If that exact source range +cannot be identified safely, the operation refuses instead. For other clients, use +Restore when you need the previous file bytes: the snapshot is a verbatim copy. **If a value cannot be rewritten faithfully, the switch refuses instead.** The round trip covers the value kinds these formats use in practice, and where it does not — diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index b5a4e386b..c338aece9 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -391,6 +391,9 @@ export interface ExportModel { displayName?: string; contextWindow?: number; inputModalities?: string[]; + /** Optional effort ladder exported only to clients that support it. */ + reasoningEfforts?: string[]; + defaultReasoningEffort?: string; } export interface ExportContext { @@ -637,6 +640,50 @@ export interface PiGeneratedConfig { providers: Record; } +/** + * omp accepts a model-level API override. Keep the provider on Chat + * Completions so routed providers retain their established wire format, while + * native OpenAI models can use the lossless Responses surface. + */ +export interface OmpModelEntry extends PiModelEntry { + api?: "openai-responses"; + /** omp requires this flag before it honors a thinking block. */ + reasoning?: true; + thinking?: { + mode: "effort"; + efforts: string[]; + defaultLevel?: string; + }; +} + +export interface OmpProviderBlock { + baseUrl: string; + api: typeof PI_API_DIALECT; + apiKey: string; + models: OmpModelEntry[]; +} + +export interface OmpGeneratedConfig { + providers: Record; +} + +/** + * omp validates model entries strictly. These are its documented effort + * values; omit an unknown value rather than invalidating the whole provider. + */ +const OMP_EFFORT_VOCABULARY = new Set(["minimal", "low", "medium", "high", "xhigh", "max"]); + +function ompEfforts(model: ExportModel): string[] { + const efforts: string[] = []; + for (const effort of model.reasoningEfforts ?? []) { + const normalized = effort.trim().toLowerCase(); + if (OMP_EFFORT_VOCABULARY.has(normalized) && !efforts.includes(normalized)) { + efforts.push(normalized); + } + } + return efforts; +} + /** * Hermes `~/.hermes/config.yaml`. We emit ONLY the provider entry — never * `model.default` — because hijacking the user's main model is not what a @@ -772,6 +819,51 @@ function buildPiClientConfig(ctx: ExportContext): PiGeneratedConfig { }; } +/** + * omp's models.yml is Pi-like, but it supports effort metadata and a per-model + * API dialect. Native OpenAI models use Responses; all routed models inherit + * the provider's existing Chat Completions dialect. + */ +function buildOmpClientConfig(ctx: ExportContext): OmpGeneratedConfig { + const models: OmpModelEntry[] = []; + for (const model of normalizeExportModels(ctx.models)) { + const input = inputModalitiesForClient("pi", model.inputModalities); + if (input === null) continue; + const entry: OmpModelEntry = { + id: model.namespaced, + name: exportModelLabel(model), + input, + ...(model.native && model.provider === "openai" ? { api: "openai-responses" } : {}), + }; + const context = authoritativeContextWindow(model.contextWindow); + if (context !== undefined) { + entry.contextWindow = context; + entry.maxTokens = outputBudgetFor(context); + } + const efforts = ompEfforts(model); + if (efforts.length > 0) { + const defaultLevel = model.defaultReasoningEffort?.trim().toLowerCase(); + entry.reasoning = true; + entry.thinking = { + mode: "effort", + efforts, + ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}), + }; + } + models.push(entry); + } + return { + providers: { + [OPENCODE_PROVIDER_ID]: { + baseUrl: ctx.baseUrl, + api: PI_API_DIALECT, + apiKey: LOOPBACK_API_KEY_PLACEHOLDER, + models, + }, + }, + }; +} + /** Extra headers a non-loopback bind needs, or nothing on loopback. */ function proxyAdmissionHeaders(config: OcxConfig | undefined, envRef: string): Record | undefined { return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined; @@ -899,6 +991,11 @@ function summarizePi(document: unknown): { modelCount: number; modelsWithoutLimi return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; } +function summarizeOmp(document: unknown): { modelCount: number; modelsWithoutLimits: number } { + const models = (document as OmpGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; + return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length }; +} + function summarizeHermes(document: unknown): { modelCount: number; modelsWithoutLimits: number } { const models = (document as HermesGeneratedConfig | undefined)?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? []; // Hermes carries selectors only; it has no per-model limit to be missing. @@ -938,7 +1035,7 @@ function buildPiContribution(ctx: ExportContext): ManagedContribution { } function buildOmpContribution(ctx: ExportContext): ManagedContribution { - const doc = buildPiClientConfig(ctx); + const doc = buildOmpClientConfig(ctx); return singleFragment("omp", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]); } @@ -1007,9 +1104,9 @@ export const EXPORT_CLIENTS: Record = { destination: env => ompModelsConfigPath(env), apiKeyEnv: "", exportHint: "OMP reads a non-secret placeholder from models.yml; loopback needs no key.", - build: buildPiClientConfig, + build: buildOmpClientConfig, format: "yaml", - summarize: summarizePi, + summarize: summarizeOmp, buildContribution: buildOmpContribution, // OMP supports provider-level headers, but remote credential wiring is // intentionally deferred from this initial loopback-only integration. diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index a76610b93..7b26cb9eb 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; +export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeInputModalities, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; diff --git a/src/integrations/omp-yaml-source.ts b/src/integrations/omp-yaml-source.ts new file mode 100644 index 000000000..22a146d52 --- /dev/null +++ b/src/integrations/omp-yaml-source.ts @@ -0,0 +1,224 @@ +/** + * Source-preserving mutation for the one YAML fragment managed by OMP. + * + * The general integration writer operates on parsed documents. Re-rendering a + * shared YAML file would preserve values but destroy comments and formatting + * outside `providers.opencodex`. OMP is the only client whose ownership model + * deliberately permits those unrelated source edits, so its writer replaces + * or removes only the exact block-style mapping entry it owns. + * + * Unsupported or ambiguous YAML returns `null`. The caller treats that as an + * unsafe refusal instead of falling back to whole-document serialization. + */ +import { renderYaml } from "./serialize"; + +interface SourceLine { + start: number; + end: number; + body: string; +} + +export type OmpYamlMutation = + | { kind: "upsert"; value: unknown } + | { kind: "remove"; removeEmptyProviders: boolean }; + +function sourceLines(text: string): SourceLine[] { + const lines: SourceLine[] = []; + const matcher = /[^\r\n]*(?:\r\n|\n|$)/gu; + for (const match of text.matchAll(matcher)) { + const raw = match[0]; + if (raw.length === 0) continue; + const start = match.index; + const body = raw.endsWith("\r\n") + ? raw.slice(0, -2) + : raw.endsWith("\n") ? raw.slice(0, -1) : raw; + lines.push({ start, end: start + raw.length, body }); + } + return lines; +} + +function leadingSpaces(line: string): number | null { + const leading = line.match(/^[ \t]*/u)?.[0] ?? ""; + return leading.includes("\t") ? null : leading.length; +} + +function isBlank(line: string): boolean { + return line.trim().length === 0; +} + +function isComment(line: string): boolean { + return line.trimStart().startsWith("#"); +} + +function hasInlineComment(line: string): boolean { + return line.includes("#"); +} + +function isPlainBlockKey(line: string, indent: number, key: string): boolean { + const spaces = leadingSpaces(line); + if (spaces !== indent) return false; + const rest = line.slice(indent); + return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest); +} + +function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null { + for (let index = start + 1; index < lines.length; index += 1) { + const body = lines[index]!.body; + const spaces = leadingSpaces(body); + if (spaces === null) return null; + if (isBlank(body)) continue; + if (isComment(body)) { + if (spaces <= indent) return index; + continue; + } + if (spaces <= indent) return index; + } + return lines.length; +} + +function childEnd( + lines: readonly SourceLine[], + start: number, + parentEnd: number, + indent: number, +): number | null { + for (let index = start + 1; index < parentEnd; index += 1) { + const body = lines[index]!.body; + const spaces = leadingSpaces(body); + if (spaces === null) return null; + // Blank lines and same-level comments are conservatively outside the + // managed entry. Deeper comments would be destroyed by replacement, so + // refuse rather than guessing whether the user meant to keep them. + if (isBlank(body)) return index; + if (isComment(body)) return spaces <= indent ? index : null; + if (spaces <= indent) return index; + } + return parentEnd; +} + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, canonicalValue(child)]), + ); + } + return value; +} + +function semanticallyMatches(text: string, expected: unknown): boolean { + try { + const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text); + return JSON.stringify(canonicalValue(parsed)) + === JSON.stringify(canonicalValue(expected)); + } catch { + return false; + } +} + +function lineEnding(text: string): "\n" | "\r\n" { + return text.includes("\r\n") ? "\r\n" : "\n"; +} + +function renderedEntry(value: unknown, indent: number, eol: "\n" | "\r\n"): string { + return renderYaml({ opencodex: value }, indent).replaceAll("\n", eol); +} + +/** + * Patch `providers.opencodex` while preserving every byte outside that entry. + * The returned text is parsed and compared with `expected` before it is + * accepted, so a scanner mistake fails closed rather than writing bad YAML. + */ +export function patchOmpYamlSource( + text: string, + mutation: OmpYamlMutation, + expected: unknown, +): string | null { + const lines = sourceLines(text); + const eol = lineEnding(text); + const providerIndexes = lines + .map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1) + .filter(index => index >= 0); + + if (providerIndexes.length === 0) { + if (mutation.kind === "remove") return null; + const separator = text.length === 0 || text.endsWith("\n") ? "" : eol; + const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`; + return semanticallyMatches(patched, expected) ? patched : null; + } + if (providerIndexes.length !== 1) return null; + + const providersIndex = providerIndexes[0]!; + const providersEnd = containerEnd(lines, providersIndex, 0); + if (providersEnd === null) return null; + + let inferredIndent: number | null = null; + for (let index = providersIndex + 1; index < providersEnd; index += 1) { + const body = lines[index]!.body; + const spaces = leadingSpaces(body); + if (spaces === null) return null; + if (!isBlank(body) && !isComment(body) && spaces > 0) { + inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces); + } + } + const childIndexes = inferredIndent === null + ? [] + : lines.slice(providersIndex + 1, providersEnd) + .map((line, offset) => ( + isPlainBlockKey(line.body, inferredIndent!, "opencodex") + ? providersIndex + 1 + offset + : -1 + )) + .filter(index => index >= 0); + if (childIndexes.length > 1) return null; + + const childIndex = childIndexes[0]; + if (childIndex === undefined) { + if (mutation.kind === "remove") return null; + const indent = inferredIndent ?? 2; + const insertAt = providersEnd < lines.length ? lines[providersEnd]!.start : text.length; + const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : ""; + const patched = `${text.slice(0, insertAt)}${prefix}${renderedEntry(mutation.value, indent, eol)}${text.slice(insertAt)}`; + return semanticallyMatches(patched, expected) ? patched : null; + } + + const childIndent = leadingSpaces(lines[childIndex]!.body); + if (childIndent === null || childIndent <= 0) return null; + if (hasInlineComment(lines[childIndex]!.body)) return null; + const endIndex = childEnd(lines, childIndex, providersEnd, childIndent); + if (endIndex === null) return null; + const startOffset = lines[childIndex]!.start; + const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length; + + if (mutation.kind === "upsert") { + const patched = `${text.slice(0, startOffset)}${renderedEntry(mutation.value, childIndent, eol)}${text.slice(endOffset)}`; + return semanticallyMatches(patched, expected) ? patched : null; + } + + let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`; + if (mutation.removeEmptyProviders) { + const remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null; + if (remaining && Object.hasOwn(remaining, "providers")) { + const provider = remaining.providers; + const empty = provider === null || ( + provider && typeof provider === "object" && !Array.isArray(provider) + && Object.keys(provider as Record).length === 0 + ); + if (!empty) return semanticallyMatches(patched, expected) ? patched : null; + if (hasInlineComment(lines[providersIndex]!.body)) return null; + + const providerStart = lines[providersIndex]!.start; + // Removing a container we created is safe only when nothing but our + // entry occupied its source range. Comments or blank formatting make + // that range user-owned and therefore ambiguous. + for (let index = providersIndex + 1; index < providersEnd; index += 1) { + if (index >= childIndex && index < endIndex) continue; + if (lines[index]!.body.length > 0) return null; + } + patched = `${text.slice(0, providerStart)}${text.slice(endOffset)}`; + } + } + return semanticallyMatches(patched, expected) ? patched : null; +} diff --git a/src/integrations/ownership.ts b/src/integrations/ownership.ts index ba394572a..7d7e1bef0 100644 --- a/src/integrations/ownership.ts +++ b/src/integrations/ownership.ts @@ -2,10 +2,10 @@ * What opencodex remembers about a client between operations. * * Two hashes, because the two questions are genuinely independent: the FILE - * hash answers "did anyone touch this after us", and the BLOCK hash answers "is - * our content still what we would write today". One hash cannot do both, and - * conflating them is what lets a foreign edit read as ordinary drift — which - * would then be silently overwritten. + * hash identifies the exact result for restore and for clients whose writer + * re-serializes the whole document. The BLOCK hash identifies OMP's surgically + * patched fragment and detects catalog drift. One hash cannot safely answer + * both questions in a shared client config. * * Design of record: devlog/_fin/260802_client_toggle_api/021 §2. */ @@ -37,7 +37,7 @@ export function canonicalContribution(contribution: ManagedContribution): string export interface OwnershipRecord { clientId: IntegrationClientId; configPath: string; - /** Hash of the WHOLE file as we left it — detects foreign edits after us. */ + /** Hash of the WHOLE file as we left it — protects whole-file restore. */ fileFingerprint: string; /** Hash of our contribution — detects catalog/port drift. */ blockFingerprint: string; diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 290bd4716..b6b970de1 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -2,9 +2,9 @@ * "What is on disk, and did we put it there?" * * The classifier is deliberately ordered, and the order is load-bearing: an - * unreadable file can never be reported as absent, and a foreign edit can never - * be reported as ordinary drift. Getting that wrong would let `disable` delete - * a user's own edits. + * unreadable file can never be reported as absent, and an edit to a fragment + * we own can never be reported as ordinary drift. Getting that wrong would let + * `disable` delete a user's own edits. * * Design of record: devlog/_fin/260802_client_toggle_api/021 §3. */ @@ -100,8 +100,43 @@ export function blockedContainerPath( } /** - * The two-axis rule: the FILE hash proves nobody touched the file after us, and - * the BLOCK hash proves our content is still what we would write today. + * Fingerprint the recorded fragments as they appear in the document now. + * + * The record intentionally names every path we own. Comparing just those + * values lets another integration or a user add a sibling without blocking a + * later refresh, while a change inside our block still fails closed. + */ +function recordedFragmentFingerprint( + doc: unknown, + record: OwnershipRecord, +): string | null { + if ( + !Array.isArray(record.fragmentPaths) + || record.fragmentPaths.length === 0 + || !record.fragmentPaths.every(path => ( + Array.isArray(path) + && path.length > 0 + && path.every(key => typeof key === "string") + )) + ) return null; + const fragments = []; + for (const path of record.fragmentPaths) { + const value = readPath(doc, path); + if (value === undefined) return null; + fragments.push({ path, value }); + } + return fingerprint(canonicalContribution({ + clientId: record.clientId, + fragments, + })); +} + +/** + * The two-axis rule: the recorded bytes or fragments prove nobody changed + * what we may rewrite, and the contribution hash proves our catalog has not + * moved on. OMP is the sole fragment-scoped client because its writer patches + * only `providers.opencodex`; every whole-document serializer retains the + * whole-file fingerprint guard. */ export function classifyIntegration(input: { fileText: string | null; @@ -144,7 +179,11 @@ export function classifyIntegration(input: { if (input.configPath !== undefined && input.record.configPath !== input.configPath) { return { state: "conflict", reason: "unowned-key" }; } - if (fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) { + const clientId = input.clientId ?? input.record.clientId; + if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) { + return { state: "conflict", reason: "foreign-edit" }; + } + if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) { return { state: "conflict", reason: "foreign-edit" }; } return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution)) diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index bdfd41aa9..9afbc4df1 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -10,7 +10,7 @@ * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031. */ import { dirname } from "node:path"; -import { EXPORT_CLIENTS, type ExportModel } from "../clients/config-export"; +import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export"; import { isLoopbackHostname } from "../codex/inject"; import type { OcxConfig } from "../types"; import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; @@ -23,6 +23,7 @@ import { serializeDocument, UnserializableValueError } from "./serialize"; import { ClientPathError } from "../clients/config-export"; import { matchesOperationResult, newOpId, type JournalEntry } from "./journal"; import { createIntegrationStateStore, type IntegrationStateStore } from "./store"; +import { patchOmpYamlSource } from "./omp-yaml-source"; export type RefusalReason = | "not_installed" @@ -168,6 +169,15 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str return snapshot.kind === "stored" ? snapshot.path : undefined; } +function ompFragmentValue(contribution: ManagedContribution): unknown | undefined { + const fragment = contribution.fragments.find(item => ( + item.path.length === 2 + && item.path[0] === "providers" + && item.path[1] === "opencodex" + )); + return fragment?.value; +} + /** Shared preflight: detect, gate, read, parse and classify. */ function preflight(input: IntegrationWriteInput) { const store = input.store ?? createIntegrationStateStore(); @@ -280,9 +290,22 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome { * user as a 500 with no path and no advice; it is a refusal like any other, * and the file is untouched because this happens before any write. */ + const nextDocument = mergeContribution(base, contribution); let text: string; try { - text = serializeDocument(mergeContribution(base, contribution), exportSpec.format); + if (clientId === "omp" && before !== null) { + const value = ompFragmentValue(contribution); + const patched = value === undefined + ? null + : patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument); + if (patched === null) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`); + } + text = patched; + } else { + text = serializeDocument(nextDocument, exportSpec.format); + } } catch (error) { if (!(error instanceof UnserializableValueError)) throw error; return refuse(clientId, "unsafe", "unsafe", @@ -357,7 +380,19 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { } let text: string; try { - text = serializeDocument(doc, exportSpec.format); + if (clientId === "omp" && before !== null) { + const patched = patchOmpYamlSource(before, { + kind: "remove", + removeEmptyProviders: record!.createdContainers?.includes("providers") === true, + }, doc); + if (patched === null) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); + } + text = patched; + } else { + text = serializeDocument(doc, exportSpec.format); + } } catch (error) { if (!(error instanceof UnserializableValueError)) throw error; return refuse(clientId, "unsafe", "unsafe", diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 20a621b4a..765cebc73 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -85,7 +85,6 @@ async function handleChatCompletionsWithBudget( try { chatBody = await readChatBody(req, translatorBudget); internalBody = chatCompletionsToResponsesBody(chatBody); - translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" }); } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; @@ -180,8 +179,24 @@ async function handleChatCompletionsWithBudget( } } - const internalBodyJson = JSON.stringify(internalBody); - translatorBudget.chargeRetained(new TextEncoder().encode(internalBodyJson).byteLength, { kind: "request_copies" }); + let internalBodyJson: string; + try { + internalBodyJson = JSON.stringify(internalBody); + translatorBudget.chargeRetained( + new TextEncoder().encode(internalBodyJson).byteLength, + { kind: "request_copies" }, + ); + } catch (err) { + const overflow = isTranslatorBudgetExceededError(err); + const status = overflow ? 413 : 500; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse( + status, + overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err), + overflow ? "request_too_large" : undefined, + overflow ? "translation_buffer_limit" : undefined, + ); + } const internalReq = new Request("http://localhost/v1/responses", { method: "POST", headers, diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 7cfa482b5..fc7f0f162 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,7 +9,14 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, nativeModelRows, nativeReasoningEfforts, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { + catalogModelSlug, + nativeDefaultReasoningEffort, + nativeInputModalities, + nativeModelRows, + nativeReasoningEfforts, + uniqueCatalogModelsForPublicList, +} from "../../codex/catalog"; import type { ExportModel } from "../../clients/config-export"; import { providerContextCap } from "../../providers/context-cap"; import { isVisionReasoningEffort } from "../../reasoning-effort"; @@ -42,16 +49,21 @@ export async function listManagementModelRows(config: OcxConfig): Promise ({ - provider: "openai", - id: row.slug, - namespaced: row.slug, - disabled: row.disabled, - native: true, - // The Codex catalog may advertise `ultra`, but the image describer accepts only low..max. - reasoningEfforts: nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort), - ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), - })); + const native: ManagementModelRow[] = nativeModelRows(config).map(row => { + const reasoningEfforts = nativeReasoningEfforts(row.slug).filter(isVisionReasoningEffort); + const defaultReasoningEffort = nativeDefaultReasoningEffort(row.slug); + return { + provider: "openai", + id: row.slug, + namespaced: row.slug, + disabled: row.disabled, + native: true, + reasoningEfforts, + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + inputModalities: nativeInputModalities(row.slug), + ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), + }; + }); const customModels: ManagementModelRow[] = (config.customModels ?? []).map(cm => { const namespaced = routedSlug(cm.provider, cm.modelId); return { @@ -102,6 +114,8 @@ export function toExportModel(row: ManagementModelRow): ExportModel { ...(row.displayName ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), + ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), + ...(row.defaultReasoningEffort ? { defaultReasoningEffort: row.defaultReasoningEffort } : {}), }; } diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index f22b459ee..447d8b122 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -379,6 +379,60 @@ test("invalid chat completions body returns OpenAI-style 400", async () => { } }); +test("large image chat-completions request remains within its bounded replay budget", async () => { + const upstream = mockChatUpstream(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + messages: [{ + role: "user", + content: [{ + type: "image_url", + image_url: { url: `data:image/png;base64,${"a".repeat(25 * 1024 * 1024)}` }, + }], + }], + }), + }); + expect(response.status).toBe(200); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-completions replay copy overflow returns JSON 413", async () => { + // The serialized replay body is the one retained request copy. A payload above + // the 32 MiB turn limit must remain a structured client error. + saveConfig(mockConfig("http://127.0.0.1:1/v1")); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + messages: [{ role: "user", content: "x".repeat(33 * 1024 * 1024) }], + }), + }); + expect(response.status).toBe(413); + expect(response.headers.get("content-type") ?? "").toContain("application/json"); + const json = await response.json() as { error?: { message?: string; type?: string; code?: string } }; + expect(json.error).toMatchObject({ + message: "request translation buffer exceeded the safe limit", + type: "request_too_large", + code: "translation_buffer_limit", + }); + } finally { + await server.stop(true); + } +}); test("chatCompletionsToResponsesBody maps response_format and rejects unknown types", () => { const jsonObject = chatCompletionsToResponsesBody({ diff --git a/tests/client-config-export.test.ts b/tests/client-config-export.test.ts index defce6a8a..a5fc20c88 100644 --- a/tests/client-config-export.test.ts +++ b/tests/client-config-export.test.ts @@ -238,6 +238,75 @@ describe("OMP serializer", () => { expect(built.text).toContain("anthropic/claude-opus-5"); expect(built.text).toContain("apiKey: opencodex-loopback"); }); + + test("keeps the provider on completions while opting native OpenAI models into Responses", () => { + const models = [ + { + namespaced: "gpt-5.6-terra", + native: true, + provider: "openai", + id: "gpt-5.6-terra", + contextWindow: 272_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + }, + { + namespaced: "command-code/muse-spark-1.2", + provider: "command-code", + id: "muse-spark-1.2", + contextWindow: 128_000, + reasoningEfforts: ["medium", "xhigh"], + }, + ] satisfies ExportModel[]; + const document = buildClientConfig("omp", ctx({ models })) as { + providers: { opencodex: { api: string; models: Array> } }; + }; + const provider = document.providers.opencodex; + expect(provider.api).toBe("openai-completions"); + + const native = provider.models.find(model => model.id === "gpt-5.6-terra")!; + expect(native.api).toBe("openai-responses"); + expect(native.input).toEqual(["text", "image"]); + expect(native.reasoning).toBe(true); + expect(native.thinking).toEqual({ mode: "effort", efforts: ["low", "high"], defaultLevel: "high" }); + + const routed = provider.models.find(model => model.id === "command-code/muse-spark-1.2")!; + expect(routed).not.toHaveProperty("api"); + }); + + test("emits OMP reasoning metadata only for the accepted effort vocabulary", () => { + const document = buildClientConfig("omp", ctx({ + models: [{ + namespaced: "gpt-5.6-luna", + native: true, + provider: "openai", + id: "gpt-5.6-luna", + reasoningEfforts: [" LOW ", "high", "ultra", "none", "high"], + defaultReasoningEffort: "ULTRA", + }], + })) as { + providers: { opencodex: { models: Array> } }; + }; + const model = document.providers.opencodex.models[0]!; + expect(model.reasoning).toBe(true); + expect(model.thinking).toEqual({ mode: "effort", efforts: ["low", "high"] }); + }); + + test("does not emit reasoning or thinking when no supported efforts are declared", () => { + const document = buildClientConfig("omp", ctx({ + models: [{ + namespaced: "command-code/muse-spark-1.2", + provider: "command-code", + id: "muse-spark-1.2", + reasoningEfforts: ["ultra", "none"], + }], + })) as { + providers: { opencodex: { models: Array> } }; + }; + expect(document.providers.opencodex.models[0]).not.toHaveProperty("reasoning"); + expect(document.providers.opencodex.models[0]).not.toHaveProperty("thinking"); + }); }); describe("no credential ever reaches the output (accept criterion 3)", () => { diff --git a/tests/integrations-state.test.ts b/tests/integrations-state.test.ts index ef4540582..5e854f86c 100644 --- a/tests/integrations-state.test.ts +++ b/tests/integrations-state.test.ts @@ -62,10 +62,10 @@ function input(overrides: Partial[0]> = } /** Write a Pi config carrying our provider entry, and return its exact text. */ -function seedOurConfig(): string { +function seedOurConfig(models: readonly ExportModel[] = MODELS): string { const document = EXPORT_CLIENTS.pi.build({ baseUrl: "http://127.0.0.1:10100/v1", - models: MODELS, + models, config: CONFIG, }); const text = `${JSON.stringify(document, null, 2)}\n`; @@ -75,10 +75,14 @@ function seedOurConfig(): string { return text; } -function seedRecord(fileText: string, blockOverride?: string): OwnershipRecord { +function seedRecord( + fileText: string, + blockOverride?: string, + recordedModels: readonly ExportModel[] = MODELS, +): OwnershipRecord { const contribution = EXPORT_CLIENTS.pi.buildContribution({ baseUrl: "http://127.0.0.1:10100/v1", - models: MODELS, + models: recordedModels, config: CONFIG, }); const record: OwnershipRecord = { @@ -113,16 +117,30 @@ describe("the five states, each triggered directly", () => { }); test("stale: untouched, but no longer what we would write now", () => { - // Same file, but the record remembers a different contribution. - seedRecord(seedOurConfig(), fingerprint("a-previous-catalog")); + // The file still carries the contribution from the previous catalog. The + // desired contribution has moved on, so this is drift rather than a user + // edit to our fragment. + const previousModels: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-7", provider: "anthropic", id: "claude-opus-4-7", contextWindow: 200_000 }, + ]; + const text = seedOurConfig(previousModels); + seedRecord(text, undefined, previousModels); expect(readIntegrationState(input()).state).toBe("stale"); }); - test("conflict: the file changed after we wrote it", () => { + test("conflict: an owned fragment changed after we wrote it", () => { const text = seedOurConfig(); seedRecord(text); - // A user edit after our write: the file hash no longer matches. - writeFileSync(join(home, ".pi", "agent", "models.json"), `${text}\n`); + // A user edit inside our provider block must still win over the catalog + // drift checks, even though unrelated siblings are allowed to change. + const editedDocument = JSON.parse(text) as { + providers: Record>; + }; + editedDocument.providers.opencodex!.baseUrl = "http://user-edited.invalid/v1"; + writeFileSync( + join(home, ".pi", "agent", "models.json"), + `${JSON.stringify(editedDocument, null, 2)}\n`, + ); const status = readIntegrationState(input()); expect(status.state).toBe("conflict"); expect(status.reason).toBe("foreign-edit"); @@ -168,12 +186,22 @@ describe("ordering guards", () => { }); test("a foreign edit is never reported as stale", () => { - // Both axes differ: the file changed AND the contribution moved on. The - // foreign edit must win, because reporting drift here would let a later - // disable delete the user's change. - const text = seedOurConfig(); - seedRecord(text, fingerprint("a-previous-catalog")); - writeFileSync(join(home, ".pi", "agent", "models.json"), `${text} `); + // Both axes differ: the owned fragment changed AND the contribution moved + // on. The foreign edit must win, because reporting drift here would let a + // later disable delete the user's change. + const previousModels: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-7", provider: "anthropic", id: "claude-opus-4-7", contextWindow: 200_000 }, + ]; + const text = seedOurConfig(previousModels); + seedRecord(text, undefined, previousModels); + const editedDocument = JSON.parse(text) as { + providers: Record>; + }; + editedDocument.providers.opencodex!.baseUrl = "http://user-edited.invalid/v1"; + writeFileSync( + join(home, ".pi", "agent", "models.json"), + `${JSON.stringify(editedDocument, null, 2)}\n`, + ); const status = readIntegrationState(input()); expect(status.state).toBe("conflict"); expect(status.reason).toBe("foreign-edit"); @@ -362,6 +390,107 @@ describe("classifier unit behavior", () => { }); }); +describe("ownership is scoped to recorded fragments", () => { + const ownedValue = { + baseUrl: "http://127.0.0.1:10100/v1", + api: "openai-chat", + }; + const ownedContribution = { + clientId: "omp" as const, + fragments: [{ path: ["providers", "opencodex"], value: ownedValue }], + }; + const extraValue = { + baseUrl: "https://freebuff.invalid/v1", + api: "openai-chat", + }; + const documentWithExtra = { + providers: { opencodex: ownedValue, freebuff: extraValue }, + }; + const originalText = "providers:\n opencodex:\n baseUrl: http://127.0.0.1:10100/v1\n api: openai-chat\n"; + const textWithExtra = `${originalText} freebuff:\n baseUrl: https://freebuff.invalid/v1\n api: openai-chat\n`; + const record: OwnershipRecord = { + clientId: "omp", + configPath: "/tmp/models.yml", + // The extra provider was added after this record was written. The + // classifier must not use this whole-file hash to claim our block changed. + fileFingerprint: fingerprint(originalText), + blockFingerprint: fingerprint(canonicalContribution(ownedContribution)), + fragmentPaths: [["providers", "opencodex"]], + appliedAt: "2026-08-02T00:00:00.000Z", + opId: "seeded-op", + }; + + test("an unrelated extra fragment remains current", () => { + const result = classifyIntegration({ + fileText: textWithExtra, + fileIsRegular: true, + parsed: documentWithExtra, + record, + contribution: ownedContribution, + }); + + expect(result).toEqual({ state: "current" }); + }); + + test("an unrelated extra fragment remains stale when our catalog moves", () => { + const newerContribution = { + ...ownedContribution, + fragments: [ + { + ...ownedContribution.fragments[0], + value: { ...ownedValue, model: "new-model" }, + }, + ], + }; + const result = classifyIntegration({ + fileText: textWithExtra, + fileIsRegular: true, + parsed: documentWithExtra, + record, + contribution: newerContribution, + }); + + expect(result).toEqual({ state: "stale" }); + }); + + test("modifying an owned fragment remains a conflict", () => { + const editedDocument = { + providers: { + opencodex: { ...ownedValue, baseUrl: "http://user-edited.invalid/v1" }, + freebuff: extraValue, + }, + }; + const result = classifyIntegration({ + fileText: `${JSON.stringify(editedDocument, null, 2)}\n`, + fileIsRegular: true, + parsed: editedDocument, + record, + contribution: ownedContribution, + }); + + expect(result).toEqual({ state: "conflict", reason: "foreign-edit" }); + }); + + test("whole-document serializers still conflict on an unrelated source edit", () => { + const piContribution = { ...ownedContribution, clientId: "pi" as const }; + const piRecord: OwnershipRecord = { + ...record, + clientId: "pi", + configPath: "/tmp/pi-models.json", + blockFingerprint: fingerprint(canonicalContribution(piContribution)), + }; + const result = classifyIntegration({ + fileText: textWithExtra, + fileIsRegular: true, + parsed: documentWithExtra, + record: piRecord, + contribution: piContribution, + }); + + expect(result).toEqual({ state: "conflict", reason: "foreign-edit" }); + }); +}); + describe("installation detection is independent of config state", () => { test("installed is false when the client's directory is absent", () => { expect(readIntegrationState(input()).installed).toBe(false); diff --git a/tests/integrations-writer.test.ts b/tests/integrations-writer.test.ts index 7e4f84b09..7477e11fd 100644 --- a/tests/integrations-writer.test.ts +++ b/tests/integrations-writer.test.ts @@ -72,6 +72,14 @@ function installHermes(): string { return configPath; } +function installOmp(): string { + const spec = INTEGRATION_CLIENTS.omp; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; +} + function input(overrides: Partial = {}): IntegrationWriteInput { return { clientId: "hermes", @@ -127,16 +135,20 @@ describe("apply", () => { expect((doc.providers as Record).other).toEqual({ api: "http://elsewhere" }); }); - test("refuses when the file changed after we wrote it", () => { + test("refuses when a managed provider field changes after we wrote it", () => { const configPath = installHermes(); expect(applyIntegration(input()).ok).toBe(true); - writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# a human edited this\n`); + const edited = readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + ); + writeFileSync(configPath, edited); const result = applyIntegration(input()); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("conflict"); - // The user's comment is still there. - expect(readFileSync(configPath, "utf8")).toContain("# a human edited this"); + // The user's edit is still there. + expect(readFileSync(configPath, "utf8")).toContain("api_mode: user_edited"); }); test("refuses an unparseable config rather than overwriting it", () => { @@ -203,15 +215,19 @@ describe("disable", () => { if (result.ok) expect(result.changed).toBe(false); }); - test("refuses to delete a block after someone edited the file", () => { + test("refuses to delete a block after someone edits a managed provider field", () => { const configPath = installHermes(); expect(applyIntegration(input()).ok).toBe(true); - writeFileSync(configPath, `${readFileSync(configPath, "utf8")}# mine\n`); + const edited = readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + ); + writeFileSync(configPath, edited); const result = disableIntegration(input()); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("conflict"); - expect(readFileSync(configPath, "utf8")).toContain("opencodex"); + expect(readFileSync(configPath, "utf8")).toContain("api_mode: user_edited"); }); test("kimi loses its provider AND every model entry it owns", () => { @@ -230,6 +246,73 @@ describe("disable", () => { }); }); +describe("OMP source preservation", () => { + test("disables a generated OMP config without leaving its created container", () => { + const configPath = installOmp(); + expect(applyIntegration(input({ clientId: "omp" })).ok).toBe(true); + expect(disableIntegration(input({ clientId: "omp" })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe(""); + }); + + test("preserves unrelated provider comments and formatting through apply, refresh, and disable", () => { + const configPath = installOmp(); + const original = [ + "# user header", + "providers:", + " freebuff: # keep provider comment", + " baseUrl: \"https://freebuff.invalid/v1\"", + " api: openai-completions # keep inline comment", + "# user tail", + "settings:", + " compact: false", + "", + ].join("\n"); + writeFileSync(configPath, original); + + const ompInput = input({ clientId: "omp" }); + expect(applyIntegration(ompInput).ok).toBe(true); + const applied = readFileSync(configPath, "utf8"); + expect(applied).toContain(" freebuff: # keep provider comment\n"); + expect(applied).toContain(" api: openai-completions # keep inline comment\n"); + + // This edit happens after OpenCodex recorded its file fingerprint. OMP's + // source patcher must preserve it while refreshing only our stale block. + const externallyEdited = applied.replace("# user header", "# user header edited later"); + writeFileSync(configPath, externallyEdited); + const refreshedModels = [...MODELS, { + namespaced: "openai/gpt-5.6", + provider: "openai", + id: "gpt-5.6", + contextWindow: 272_000, + }]; + expect(applyIntegration(input({ clientId: "omp", models: refreshedModels })).ok).toBe(true); + const refreshed = readFileSync(configPath, "utf8"); + expect(refreshed).toContain("# user header edited later\n"); + expect(refreshed).toContain(" freebuff: # keep provider comment\n"); + expect(refreshed).toContain(" api: openai-completions # keep inline comment\n"); + + expect(disableIntegration(input({ clientId: "omp", models: refreshedModels })).ok).toBe(true); + expect(readFileSync(configPath, "utf8")).toBe( + original.replace("# user header", "# user header edited later"), + ); + }); + + test("refuses to rewrite an ambiguous comment inside the managed YAML block", () => { + const configPath = installOmp(); + expect(applyIntegration(input({ clientId: "omp" })).ok).toBe(true); + const edited = readFileSync(configPath, "utf8").replace( + " baseUrl:", + " # user note inside managed block\n baseUrl:", + ); + writeFileSync(configPath, edited); + + const result = disableIntegration(input({ clientId: "omp" })); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe(edited); + }); +}); + describe("restore", () => { test("undoes an apply back to the exact prior bytes", () => { const configPath = installHermes(); diff --git a/tests/management-integration-routes.test.ts b/tests/management-integration-routes.test.ts index be88ac1cb..cbfca690d 100644 --- a/tests/management-integration-routes.test.ts +++ b/tests/management-integration-routes.test.ts @@ -412,10 +412,13 @@ function bookkeeping(): Pick { - test("conflict rejects disable without changing foreign-edited bytes", async () => { + test("conflict rejects disable without changing a managed-field edit", async () => { const configPath = installHermes(); expect((await put("hermes", true)).status).toBe(200); - const edited = `${readFileSync(configPath, "utf8")}# a human edited this\n`; + const edited = readFileSync(configPath, "utf8").replace( + "api_mode: chat_completions", + "api_mode: user_edited", + ); writeFileSync(configPath, edited); const journalBefore = store.listOperations().length;