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
1 change: 1 addition & 0 deletions docs-site/src/content/docs/reference/cli/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,7 @@ See [client integrations](/guides/integrations/) for managed refresh and upgrade
| --- | --- |
| `--client <opencode\|pi\|omp\|hermes\|openclaw\|kimi\|gajae\|dsh\|mcode\|zcode\|prime\|aside\|raycast\|omo\|cline\|kilo\|commandcode\|droid>` | Required. Selects the client config dialect. |
| `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. |
| `--model-id-mode <routed\|alias>` | Hermes and Command Code only. `routed` (default) keeps canonical `provider/id` selectors; `alias` uses a qualified `<provider alias>/<model alias>` selector only when the router proves it is the same model. Previews the retained roster; nothing is written unless `--out` is given. |
| `--out <path>` | Write the client's native config format to `<path>`. Refuses to replace an existing file. |
| `--force` | Allow `--out` to replace an existing file. |

Expand Down
15 changes: 15 additions & 0 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,21 @@ configuration value, file location or selected member identity is returned.
`canApply: true` with `willChange: false` means the operation succeeds and changes nothing in
the managed client document, such as applying what is already applied.

Hermes and Command Code take an optional `modelIdMode`: `routed` (the canonical `provider/id` selector,
the default) or `alias` (the qualified `<provider alias>/<model alias>` selector). It is accepted in the
body of `POST /api/client-integrations/preview` and `PUT /api/client-integrations/{clientId}`, and as the
`modelIdMode` query parameter of `GET /api/client-config?client=hermes|commandcode`. Any other client, or
any value other than `routed` or `alias`, is refused with 400 (`invalid_model_id_mode` on the integration routes;
the config export returns a 400 error message without that code). The config export is a passive preview of the retained roster: it gathers nothing and answers 503 `roster_unavailable` when
no roster is retained. An alias is used only when the passive router proves it resolves to the same
provider and model; an alias that collides with another model's key falls back to the routed key.
Previews write nothing, as described above. A confirmed apply saves the mode
with the integration's ownership record. Omitting the field keeps the saved mode, and a record without
one reads as `routed`. A named Hermes profile saves its own mode and changes only through an explicit Apply.
Disable takes no mode. The CLI rejects `--model-id-mode` with a usage error. The management API refuses any
`modelIdMode` on a disable body with 400 `invalid_model_id_mode`, whether the value is valid or not. Send no
`modelIdMode` with a disable request.

An Aside profile change still saves one thing in that case. Confirming it records the desired sync
preference for that profile before any client document is touched, so disabling a profile whose
managed block is already absent stores the preference and leaves the document and its history
Expand Down
31 changes: 27 additions & 4 deletions src/cli/export-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ import {
EXPORT_CLIENT_IDS,
buildClientConfigText,
isExportClientId,
isModelIdMode,
opencodeProxyBaseUrl,
type ExportClientId,
type ExportModel,
type ModelIdMode,
} from "../clients/config-export";
import { opencodeCatalogFromProxyRows, type OpencodeProxyModelRow } from "./opencode";
import type { OcxConfig } from "../types";
Expand All @@ -50,11 +52,13 @@ import {
runtimeRequest,
takeFlag,
takeOption,
takeOptionWithSyntax,
type RuntimeApiDeps,
} from "./runtime-api";

const USAGE = `Usage:
ocx export --client <${EXPORT_CLIENT_IDS.join("|")}> [--json] [--out <path>] [--force]`;
ocx export --client <${EXPORT_CLIENT_IDS.join("|")}> [--json] [--out <path>] [--force]
ocx export --client <hermes|commandcode> --model-id-mode <routed|alias> [--json] [--out <path>] [--force]`;

export interface ExportCommandDeps extends RuntimeApiDeps {
/** Live config seam; tests inject a fixture instead of reading the user's config. */
Expand Down Expand Up @@ -139,6 +143,16 @@ function parseClient(args: string[]): ExportClientId {
return client;
}

function parseModelIdMode(args: string[], client: ExportClientId): ModelIdMode | undefined {
const option = takeOptionWithSyntax(args, "--model-id-mode");
if (option === undefined) return undefined;
if (client !== "hermes" && client !== "commandcode") {
throw new CliUsageError(`--model-id-mode applies only to hermes and commandcode, not ${client}`, USAGE);
}
if (!isModelIdMode(option.value)) throw new CliUsageError("--model-id-mode must be routed or alias", USAGE);
return option.value;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/**
* Write the config, refusing to replace an existing file without `--force`.
*
Expand Down Expand Up @@ -167,22 +181,31 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep
const wantsJson = takeFlag(args, "--json");
const force = takeFlag(args, "--force");
const out = takeOption(args, "--out");
const modelIdMode = parseModelIdMode(args, client);
rejectArgs(args, USAGE);

const spec = EXPORT_CLIENTS[client];
const root = await runtimeBaseUrl(deps);
let built: { document: unknown; text: string };
if (client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") {
if (modelIdMode !== undefined || client === "raycast" || client === "droid" || client === "qoder" || client === "qoder-cn" || client === "workbuddy" || client === "workbuddy-ai") {
// The dial address alone cannot distinguish a wildcard authenticated bind
// from loopback. Let the live server resolve its admission/listener policy;
// saved config can differ from the process serving this request.
const query = modelIdMode === undefined ? "" : `&modelIdMode=${encodeURIComponent(modelIdMode)}`;
const exported = await runtimeRequest<{
client: string; format: string; config: unknown; text: string;
}>(`/api/client-config?client=${client}`, {}, { ...deps, baseUrl: root });
client: string; format: string; config: unknown; text: string; modelIdMode?: unknown;
}>(`/api/client-config?client=${client}${query}`, {}, { ...deps, baseUrl: root });
if (!exported || exported.client !== client || exported.format !== spec.format
|| typeof exported.text !== "string" || exported.config === undefined) {
throw new RuntimeApiError(`Management API returned an unexpected ${client} export payload.`, 502, null);
}
if (modelIdMode !== undefined && exported.modelIdMode !== modelIdMode) {
throw new RuntimeApiError(
`Management API did not apply --model-id-mode ${modelIdMode} to ${client}; nothing was written. Update the proxy to a version that supports it.`,
502,
null,
);
}
built = { document: exported.config, text: exported.text };
} else {
const rows = await runtimeRequest<OpencodeProxyModelRow[]>("/api/models", {}, { ...deps, baseUrl: root });
Expand Down
7 changes: 7 additions & 0 deletions src/cli/integration-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export function integrationFingerprint(args: string[]): string | undefined {
return value;
}

export function integrationModelIdMode(args: string[]): "routed" | "alias" | undefined {
const value = integrationOption(args, "--model-id-mode");
if (value === undefined) return undefined;
if (value !== "routed" && value !== "alias") throw new CliUsageError("--model-id-mode must be routed or alias");
return value;
}

/** Entries replace the map; omission preserves it; explicit clear sends {}. */
export function integrationDroidDefaults(args: string[]): Record<string, string> | undefined {
const clear = integrationFlag(args, "--clear-reasoning-defaults");
Expand Down
11 changes: 9 additions & 2 deletions src/cli/integration-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "./runtime-api";
import {
clientIntegrationPath, validateAsideProfile, integrationOption, integrationFlag,
integrationFingerprint, integrationDroidDefaults,
integrationFingerprint, integrationDroidDefaults, integrationModelIdMode,
} from "./integration-input";
import { decodeIntegrationPlan, FILE_INTEGRATION_CLIENTS, INTEGRATION_STATES, INTEGRATION_REFUSAL_REASONS } from "./integration-plan-dto";

Expand All @@ -17,7 +17,8 @@ const USAGE = `Usage:
ocx integration client restore --op ID --preview [--client aside --profile N] [--confirm-drift] [--json]
ocx integration client <enable|disable> --client ID [--profile N] [--overwrite-conflict] [--plan-fingerprint TOKEN] [--json]
ocx integration client restore --op ID [--client aside --profile N] [--confirm-drift] [--plan-fingerprint TOKEN] [--json]
Droid apply/overwrite: [--reasoning-default MODEL=EFFORT ... | --clear-reasoning-defaults]`;
Droid apply/overwrite: [--reasoning-default MODEL=EFFORT ... | --clear-reasoning-defaults]
Hermes and Command Code enable/preview: [--model-id-mode routed|alias]`;

type Intent = {
preview: boolean; json: boolean; client?: string; profile?: string;
Expand All @@ -36,6 +37,7 @@ function parseIntent(argv: string[]): Intent {
const previewFlag = integrationFlag(args, "--preview");
const token = integrationFingerprint(args);
const defaults = integrationDroidDefaults(args);
const modelIdMode = integrationModelIdMode(args);
const overwrite = integrationFlag(args, "--overwrite-conflict");
const confirmDrift = integrationFlag(args, "--confirm-drift");
const op = integrationOption(args, "--op");
Expand All @@ -61,6 +63,10 @@ function parseIntent(argv: string[]): Intent {
}
if (client === "aside" && profile === undefined) throw new CliUsageError("A preview or bound operation requires one Aside --profile", USAGE);
if (defaults !== undefined && (client !== "droid" || !["apply", "overwrite"].includes(operation))) throw new CliUsageError("Reasoning defaults apply only to Droid apply or overwrite", USAGE);
const modelIdModeOperation = action === "enable" || action === "preview" ? operation : undefined;
if (modelIdMode !== undefined && ((client !== "hermes" && client !== "commandcode") || (modelIdModeOperation !== "apply" && modelIdModeOperation !== "overwrite"))) {
throw new CliUsageError("--model-id-mode applies only to Hermes and Command Code apply, overwrite or enable", USAGE);
}
let path: string;
let body: Record<string, unknown>;
if (action === "restore") {
Expand All @@ -75,6 +81,7 @@ function parseIntent(argv: string[]): Intent {
body = { enabled: action === "enable", ...(overwrite ? { overwriteConflict: true } : {}) };
}
if (defaults !== undefined) body.droidReasoningDefaults = defaults;
if (modelIdMode !== undefined) body.modelIdMode = modelIdMode;
if (token !== undefined) { body.operation = operation; body.planFingerprint = token; }
return { preview, json, client, profile, operation, path, body };
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const CLIENT_USAGE = `Usage:
ocx integration client sync --client aside [--json]
enable/disable/restore accept --plan-fingerprint <token>; bound Aside operations require one profile.
Droid enable/preview accept repeatable --reasoning-default <model=effort> or --clear-reasoning-defaults.
Hermes and Command Code enable/preview accept --model-id-mode routed|alias; omitted keeps the saved form.
--profile selects one Aside account-backed profile; omitted Aside toggles affect all profiles.`;

function parseMap(raw: string): Record<string, string> {
Expand Down Expand Up @@ -239,7 +240,7 @@ export async function handleClientIntegrationCommand(
const { handleIntegrationAsideSync } = await import("./integration-aside-sync");
return handleIntegrationAsideSync(argv.slice(1), deps);
}
if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults"]
if (argv[0] === "preview" || argv.some(arg => ["--preview", "--plan-fingerprint", "--reasoning-default", "--clear-reasoning-defaults", "--model-id-mode"]
.some(flag => arg === flag || arg.startsWith(`${flag}=`)))) {
const { handleIntegrationPreviewCommand } = await import("./integration-preview");
return handleIntegrationPreviewCommand(argv, deps, expectedClientId);
Expand Down
2 changes: 1 addition & 1 deletion src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
},
{
name: "export",
usage: "ocx export --client <qoder|qoder-cn|workbuddy|workbuddy-ai|opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|commandcode|prime|aside|raycast|omo|cline|kilo|droid|gentle-shell> [--json] [--out <path>] [--force]",
usage: "ocx export --client <qoder|qoder-cn|workbuddy|workbuddy-ai|opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|commandcode|prime|aside|raycast|omo|cline|kilo|droid|gentle-shell> [--model-id-mode <routed|alias>] [--json] [--out <path>] [--force]",
summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, gjc, DeepSeek Harness, MiniMax Code, ZCode, Command Code, Prime Agent, Aside, Raycast, omo, Cline, Kilo, Factory Droid, Qoder, Qoder CN, WorkBuddy, WorkBuddy AI, Gentle Shell) wired to the running proxy.",
details: [
"--json prints the generated document as JSON on stdout; use --out for the client's native format.",
Expand Down
9 changes: 7 additions & 2 deletions src/clients/config-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ import { buildKiloClientConfig, summarizeKilo, buildKiloContribution, kiloConfig
export { kiloConfigPath, kiloHomeDir, kiloCandidatePath, KILO_CONFIG_CANDIDATES } from "./config-export/kilo";
export type { KiloGeneratedConfig, KiloProviderBlock, KiloModelEntry } from "./config-export/kilo";
import { droidConfigPath, buildDroidClientConfig, summarizeDroid, buildDroidContribution } from "./config-export/droid";
import { DEFAULT_MODEL_ID_MODE, modelIdKeysFor } from "./config-export/model-id-mode";
export { DEFAULT_MODEL_ID_MODE, MODEL_ID_MODES, isModelIdMode, modelIdKeyFor, modelIdKeysFor } from "./config-export/model-id-mode";
export type { ModelIdMode, QualifiedAliasResolver } from "./config-export/model-id-mode";
import {
buildCommandCodeClientConfig,
summarizeCommandCode,
Expand Down Expand Up @@ -1082,10 +1085,12 @@ function containsEnvInterpolation(value: string): boolean {

function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
const models: Record<string, HermesModelEntry> = {};
for (const model of normalizeExportModels(ctx.models)) {
const normalized = normalizeExportModels(ctx.models);
const keys = modelIdKeysFor(normalized, ctx.modelIdMode ?? DEFAULT_MODEL_ID_MODE, ctx.qualifiedAliasFor);
for (const model of normalized) {
if (containsEnvInterpolation(model.namespaced)) continue;
const declared = model.inputModalities;
models[model.namespaced] = declared && declared.length > 0
models[keys.get(model.namespaced) ?? model.namespaced] = declared && declared.length > 0
? { supports_vision: declared.includes("image") }
: {};
}
Expand Down
8 changes: 5 additions & 3 deletions src/clients/config-export/commandcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ExportContext, ManagedContribution } from "./contracts";
import { normalizeExportModels, authoritativeContextWindow, singleFragment } from "./model-metadata";
import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants";
import { sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
import { DEFAULT_MODEL_ID_MODE, modelIdKeysFor } from "./model-id-mode";

export interface CommandCodeModelEntry {
reasoningEfforts?: string[];
Expand Down Expand Up @@ -54,8 +55,9 @@ export function buildCommandCodeClientConfig(ctx: ExportContext): CommandCodeGen
// occurrence wins per provider+id identity; distinct model ids that happen to
// share a lossy spelling collision must both remain addressable.
const emittedIdentitiesByCollision = new Map<string, Set<string>>();
for (const model of normalizeExportModels(ctx.models)) {
const emittedKey = model.namespaced;
const normalized = normalizeExportModels(ctx.models);
const keys = modelIdKeysFor(normalized, ctx.modelIdMode ?? DEFAULT_MODEL_ID_MODE, ctx.qualifiedAliasFor);
for (const model of normalized) {
const collisionKey = canonicalSpellingOf(model.namespaced);
const identity = `${model.provider}\u0000${model.id}`;
const identities = emittedIdentitiesByCollision.get(collisionKey) ?? new Set<string>();
Expand All @@ -72,7 +74,7 @@ export function buildCommandCodeClientConfig(ctx: ExportContext): CommandCodeGen
if (efforts && efforts.length > 0) {
entry.reasoningEfforts = efforts;
}
models[emittedKey] = entry;
models[keys.get(model.namespaced) ?? model.namespaced] = entry;
}
return {
provider: {
Expand Down
3 changes: 3 additions & 0 deletions src/clients/config-export/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { canonicalizeReasoningEfforts } from "../../reasoning-effort";
import type { OcxConfig } from "../../types";
import type { ConfigFormat } from "../../integrations/serialize";
import type { ModelIdMode, QualifiedAliasResolver } from "./model-id-mode";

export const DROID_DEFAULT_EFFORT_HEADER = "x-opencodex-droid-default-effort";

Expand Down Expand Up @@ -198,6 +199,8 @@ export interface ExportContext {
*/
config?: OcxConfig;
droidReasoningDefaults?: DroidReasoningDefaults;
modelIdMode?: ModelIdMode;
qualifiedAliasFor?: QualifiedAliasResolver;
/**
* The parsed target document, when the caller has one in hand.
*
Expand Down
40 changes: 40 additions & 0 deletions src/clients/config-export/model-id-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ExportModel } from "./contracts";

export const MODEL_ID_MODES = ["routed", "alias"] as const;
export type ModelIdMode = (typeof MODEL_ID_MODES)[number];
export const DEFAULT_MODEL_ID_MODE: ModelIdMode = "routed";

export function isModelIdMode(value: unknown): value is ModelIdMode {
return typeof value === "string" && (MODEL_ID_MODES as readonly string[]).includes(value);
}

export type QualifiedAliasResolver = (model: ExportModel) => string | undefined;

export function modelIdKeyFor(
model: ExportModel,
mode: ModelIdMode,
resolveQualifiedAlias: QualifiedAliasResolver | undefined,
): string {
if (mode !== "alias" || resolveQualifiedAlias === undefined) return model.namespaced;
return resolveQualifiedAlias(model) ?? model.namespaced;
}

export function modelIdKeysFor(
models: readonly ExportModel[],
mode: ModelIdMode,
resolveQualifiedAlias: QualifiedAliasResolver | undefined,
): Map<string, string> {
const candidates = models.map(model => ({
routed: model.namespaced,
key: modelIdKeyFor(model, mode, resolveQualifiedAlias),
}));
const counts = new Map<string, number>();
for (const { key } of candidates) counts.set(key, (counts.get(key) ?? 0) + 1);
const routedKeys = new Set(candidates.map(({ routed }) => routed));
const keys = new Map<string, string>();
for (const { routed, key } of candidates) {
const collides = key !== routed && ((counts.get(key) ?? 0) > 1 || routedKeys.has(key));
keys.set(routed, collides ? routed : key);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return keys;
}
Loading
Loading