Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs-site/src/content/docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down
103 changes: 100 additions & 3 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -637,6 +640,50 @@ export interface PiGeneratedConfig {
providers: Record<string, PiProviderBlock>;
}

/**
* 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<string, OmpProviderBlock>;
}

/**
* 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
Expand Down Expand Up @@ -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<string, string> | undefined {
return shouldInjectApiAuthHeader(config) ? { "x-opencodex-api-key": envRef } : undefined;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]);
}

Expand Down Expand Up @@ -1007,9 +1104,9 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
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.
Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading