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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ through to one another.
8. **Default provider** — if nothing matched, the id is sent to `config.defaultProvider` unchanged.
(If no default provider is configured, or it is disabled, routing throws.)

## Model alias templates

`aliasTemplate` (opt-in, per provider) names every model of that provider that has no manual or built-in alias, for example `"aliasTemplate": "{providerAlias}-{modelBasename}"` on a provider whose alias is `CC` turns `reflection/beam:free` into `CC-beam-free`. Only two literal placeholders exist: `{providerAlias}` (the provider's alias; a template that uses it does nothing for a provider without one) and `{modelBasename}` (the last `/` segment of the model id with `:` written as `-`), and `{modelBasename}` is required. The aliases are derived whenever models are resolved and are never written to the config, so a new model gets its alias as soon as it is discovered and removing the template removes them at once.

Manual aliases win, then built-in ones, then the template. A generated name is skipped, never renamed, when it is not a valid alias, starts with a reserved prefix (`gpt-`, `o1-`, `o3-`, `o4-`, `codex-`), or matches anything already claimed: a native model id or alias of the provider, a built-in alias name, a provider name or alias, another provider's manual alias, or a combo id or alias. When two models produce the same name, both are skipped, so the result does not depend on the order models are listed. A bare template alias that another provider's template also produced is ambiguous like any other alias; the `<provider-alias>/<alias>` form still works. Set it with `ocx alias template set <provider> <template>` (or `PUT /api/providers/<name>/alias-template` with `{ "template": … }`, `null` to remove), which reports how many aliases it generates and lists skipped ones with their reason; `ocx alias list` shows generated rows with source `template`. An invalid template in a hand-edited `config.json` is ignored with a warning.

## API keys and environment variables

Whatever route is chosen, the provider's `apiKey` is resolved through `resolveEnvValue()`: a value of
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ to standard error, leaves standard output empty, and exits 1.

### `ocx alias`

`ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set <provider>[/<native-model-id>] <alias>` and `ocx alias rm <provider>[/<native-model-id>]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider <name>]`.
`ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set <provider>[/<native-model-id>] <alias>` and `ocx alias rm <provider>[/<native-model-id>]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider <name>]`. `ocx alias template set <provider> <template>`, `ocx alias template rm <provider>` and `ocx alias template show [<provider>]` manage a provider's opt-in alias template; see [Model alias templates](/guides/model-routing/#model-alias-templates).

### `ocx remote-workspace`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ predictions. Explicit provider/model price overrides still take precedence.

## Provider namespace aliases

Providers can expose a built-in shorthand, such as `agy` for `google-antigravity`. A configured provider name or explicit alias claims that shorthand case-insensitively; a different provider's built-in shorthand is then suppressed in both catalog names and alias routing. For example, configuring a provider named `agy` keeps Google's models under `google-antigravity/<model>`, while `agy/<model>` selects the configured provider. Canonical provider names still require an exact case match, and unrecognized prefixes retain the existing model-routing fallback.
Providers can expose a built-in shorthand, such as `agy` for `google-antigravity`. A configured provider name or explicit alias claims that shorthand case-insensitively; a different provider's built-in shorthand is then suppressed in both catalog names and alias routing. For example, configuring a provider named `agy` keeps Google's models under `google-antigravity/<model>`, while `agy/<model>` selects the configured provider. Canonical provider names still require an exact case match, and unrecognized prefixes retain the existing model-routing fallback. An opt-in per-provider `aliasTemplate` can name models that have no alias; see [Model alias templates](/guides/model-routing/#model-alias-templates).

## Provider entries (`OcxProviderConfig`)

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-providers.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"provider-alias-template.test.ts": "providers",
"claude-chat-gateway-tool-schema.test.ts": "providers",
"provider-anthropic-usage-windows.test.ts": "providers",
"provider-anthropic-pool-capacity.test.ts": "providers",
Expand Down
18 changes: 15 additions & 3 deletions skills/ocx/references/01_management_surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ These answer in the CLI head and never reach the proxy, so they work with nothin
| Chapter | Declared capabilities |
|---|---|
| [lifecycle](01_surface_lifecycle.md) | 14 |
| [providers-models](01_surface_providers-models.md) | 50 |
| [providers-models](01_surface_providers-models.md) | 53 |
| [accounts](01_surface_accounts.md) | 43 |
| [agents-routing](01_surface_agents-routing.md) | 68 |
| [integrations](01_surface_integrations.md) | 41 |
Expand Down Expand Up @@ -527,6 +527,18 @@ Original invocation order. These headings preserve links to the previous single-

[State-changing task](01_surface_providers-models.md#ocx-alias-defaults)

### `ocx alias template set`

[State-changing task](01_surface_providers-models.md#ocx-alias-template-set)

### `ocx alias template rm`

[State-changing task](01_surface_providers-models.md#ocx-alias-template-rm)

### `ocx alias template show`

[Read-oriented task](01_surface_providers-models.md#ocx-alias-template-show)

### `ocx provider pacing`

[State-changing task](01_surface_providers-models.md#ocx-provider-pacing)
Expand Down Expand Up @@ -1569,6 +1581,6 @@ Original invocation order. These headings preserve links to the previous single-

## Counts

- declared capabilities: 380
- of those, state-changing: 236
- declared capabilities: 383
- of those, state-changing: 238
- head-resolved invocations: 2
52 changes: 51 additions & 1 deletion skills/ocx/references/01_surface_providers-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Use these declarations to choose a task, then check its flags and authority before execution.
Non-mutating probes may still contact providers, consume quota or refresh caches.

Declared capabilities: 50.
Declared capabilities: 53.

### `ocx models price`

Expand Down Expand Up @@ -861,6 +861,56 @@ State-changing: yes. JSON mode: `payload`.
| `--provider` | string | Limit the toggle to one provider. |
| `--json` | boolean | Emit the alias settings receipt. |

### `ocx alias template set`

Usage: `ocx alias template set <provider> <template> [--json]`

Set a provider's opt-in alias template for models without an alias.

State-changing: yes. JSON mode: `payload`.

| Method | Route |
|---|---|
| PUT | `/api/providers/{provider}/alias-template` |

| Flag | Value | Meaning |
|---|---|---|
| `--json` | boolean | Emit the receipt with the generated and skipped preview. |

- Only the literal {providerAlias} and {modelBasename} placeholders are allowed, and {modelBasename} is required. Aliases are derived when models resolve and never written to config. Manual and built-in aliases win; a generated name that collides with anything already claimed is skipped, never renamed.

### `ocx alias template rm`

Usage: `ocx alias template rm <provider> [--json]`

Remove a provider's alias template; its generated aliases disappear at once.

State-changing: yes. JSON mode: `payload`.

| Method | Route |
|---|---|
| PUT | `/api/providers/{provider}/alias-template` |

| Flag | Value | Meaning |
|---|---|---|
| `--json` | boolean | Emit the receipt. |

### `ocx alias template show`

Usage: `ocx alias template show [<provider>] [--json]`

Read configured alias templates.

State-changing: no. JSON mode: `payload`.

| Method | Route |
|---|---|
| GET | `/api/aliases` |

| Flag | Value | Meaning |
|---|---|---|
| `--json` | boolean | Emit the template map, or one provider's template. |

### `ocx provider pacing`

Usage: `ocx provider pacing <name> [--json]; ocx provider pacing <name> [--enabled <on|off>] [--rpm <number>] [--min-interval-ms <integer>] [--max-concurrent <integer>] [--json]; ocx provider pacing <name> --file <FILE|-> [--json]`
Expand Down
39 changes: 38 additions & 1 deletion src/cli/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ const USAGE = `Usage:
ocx alias set <provider> <alias>
ocx alias set <provider>/<native-model-id> <alias>
ocx alias rm <provider>[/<native-model-id>]
ocx alias defaults <on|off> [--provider <name>]`;
ocx alias defaults <on|off> [--provider <name>]
ocx alias template set <provider> <template>
ocx alias template rm <provider>
ocx alias template show [<provider>]`;

function selector(value: string): { provider: string; model?: string } {
const slash = value.indexOf("/");
Expand All @@ -32,6 +35,7 @@ async function runAliasAction(argv: string[], deps: RuntimeApiDeps): Promise<num
printData(result, wantsJson, lines.length ? lines : ["No aliases configured."]);
return 0;
}
if (action === "template") return runTemplateAction(args, wantsJson, deps);
if (action === "defaults") {
const state = args.shift()?.toLowerCase();
const provider = takeOption(args, "--provider");
Expand Down Expand Up @@ -70,3 +74,36 @@ async function runAliasAction(argv: string[], deps: RuntimeApiDeps): Promise<num
}
return 0;
}

/**
* #7705: opt-in per-provider alias templates. The template is stored as text and the aliases are
* derived when models resolve; `set` prints how many aliases it produces and how many it skipped.
*/
async function runTemplateAction(args: string[], wantsJson: boolean, deps: RuntimeApiDeps): Promise<number> {
const sub = args.shift()?.toLowerCase();
if (sub === "show") {
const provider = args.shift()?.trim();
rejectArgs(args, USAGE);
const result = await runtimeRequest<{ templates?: Record<string, string> }>("/api/aliases", {}, deps);
const templates = Object.entries(result.templates ?? {}).filter(([name]) => !provider || name === provider);
printData(provider ? { provider, template: Object.fromEntries(templates)[provider] ?? null } : (result.templates ?? {}), wantsJson,
templates.length ? templates.map(([name, template]) => `${name}: ${template}`) : ["No alias templates configured."]);
return 0;
}
if (sub !== "set" && sub !== "rm") throw new CliUsageError("alias template requires set, rm or show", USAGE);
const provider = args.shift()?.trim();
if (!provider) throw new CliUsageError("alias template requires a provider", USAGE);
const template = sub === "set" ? args.shift() : null;
if (sub === "set" && !template?.trim()) throw new CliUsageError("alias template set requires a template", USAGE);
rejectArgs(args, USAGE);
const result = await runtimeRequest<{ preview?: { generated: number; skippedTotal?: number; skipped?: unknown[] } }>(
`/api/providers/${encodeURIComponent(provider)}/alias-template`,
{ method: "PUT", body: JSON.stringify({ template }) },
deps,
);
const skipped = result.preview?.skippedTotal ?? result.preview?.skipped?.length ?? 0;
printData(result, wantsJson, sub === "set"
? [`${provider}: ${template} (${result.preview?.generated ?? 0} aliases, ${skipped} skipped)`]
: [`Removed the alias template for ${provider}.`]);
return 0;
}
16 changes: 16 additions & 0 deletions src/cli/capabilities-provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,22 @@ export const PROVIDER_MODEL_CAPABILITIES: readonly Capability[] = [
usage: "ocx alias defaults <on|off> [--provider <name>] [--json]", routes: [{ method: "PUT", path: "/api/default-aliases" }], mutates: true, json: "payload",
flags: [{ name: "--provider", value: "string", summary: "Limit the toggle to one provider." }, { name: "--json", value: "boolean", summary: "Emit the alias settings receipt." }],
},
{
command: ["alias", "template", "set"], summary: "Set a provider's opt-in alias template for models without an alias.",
usage: "ocx alias template set <provider> <template> [--json]", routes: [{ method: "PUT", path: "/api/providers/{provider}/alias-template" }], mutates: true, json: "payload",
flags: [{ name: "--json", value: "boolean", summary: "Emit the receipt with the generated and skipped preview." }],
details: ["Only the literal {providerAlias} and {modelBasename} placeholders are allowed, and {modelBasename} is required. Aliases are derived when models resolve and never written to config. Manual and built-in aliases win; a generated name that collides with anything already claimed is skipped, never renamed."],
},
{
command: ["alias", "template", "rm"], summary: "Remove a provider's alias template; its generated aliases disappear at once.",
usage: "ocx alias template rm <provider> [--json]", routes: [{ method: "PUT", path: "/api/providers/{provider}/alias-template" }], mutates: true, json: "payload",
flags: [{ name: "--json", value: "boolean", summary: "Emit the receipt." }],
},
{
command: ["alias", "template", "show"], summary: "Read configured alias templates.",
usage: "ocx alias template show [<provider>] [--json]", routes: [{ method: "GET", path: "/api/aliases" }], mutates: false, json: "payload",
flags: [{ name: "--json", value: "boolean", summary: "Emit the template map, or one provider's template." }],
},
{
command: ["provider","pacing"],
usage: "ocx provider pacing <name> [--json]; ocx provider pacing <name> [--enabled <on|off>] [--rpm <number>] [--min-interval-ms <integer>] [--max-concurrent <integer>] [--json]; ocx provider pacing <name> --file <FILE|-> [--json]",
Expand Down
2 changes: 1 addition & 1 deletion src/clients/config-export/alias-selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function attachExportAliasSelectors(config: OcxConfig, input: readonly Ex
// A minimal or partially repaired config can omit providers; it then has no aliases.
const aliases = new Map(Object.entries(config.providers ?? {}).map(([name, provider]) => [name, {
namespace: effectiveProviderAlias(name, provider, config) ?? name,
models: effectiveModelAliases(config, provider, knownModelIdsForProvider(name, provider, config)),
models: effectiveModelAliases(config, provider, knownModelIdsForProvider(name, provider, config), name),
}]));
const candidates = models.map(model => {
if (model.native || model.provider === "combo") return undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog/routed-gather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ async function gatherRoutedModelsUncached(
// ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
const providerModels = models.filter(model => model.provider === name);
const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))];
const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id), name)];
return aliases.flatMap(([id, { alias }]) => {
const exact = providerModels.filter(model => model.id === id);
const matches = exact.length > 0
Expand Down
18 changes: 18 additions & 0 deletions src/config/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { aliasTemplateError } from "../providers/alias-template";
import { parseDashboardTrustedProxy } from "./dashboard-trusted-proxy";
import { readExistingSpendSalt, spendPoolAliasesError, validatePoolAliasOwners } from "../lib/spend-pool-alias-validation";
import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
Expand Down Expand Up @@ -635,6 +636,22 @@ function dashboardTrustedProxyConfigError(value: unknown): string | null {
return typeof parsed === "string" ? `schema_invalid: dashboardTrustedProxy: ${parsed}` : null;
}

/**
* #7705: load drops a hand-edited template that does not validate; a live write that carries one
* (a provider-wide save, `ocx config set`) gets the same reason the alias-template route gives.
*/
function providerAliasTemplateConfigError(value: unknown): string | null {
const providers = rawConfigRecord(rawConfigRecord(value)?.providers);
if (!providers) return null;
for (const [name, provider] of Object.entries(providers)) {
const template = rawConfigRecord(provider)?.aliasTemplate;
if (template === undefined) continue;
const problem = typeof template === "string" ? aliasTemplateError(template) : "template must be a string";
if (problem) return `schema_invalid: providers.${name}.aliasTemplate: ${problem}`;
}
return null;
}

/**
* Validate the hub management ingress at the live-write boundary.
*
Expand Down Expand Up @@ -794,6 +811,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
?? clientRolePairError(value)
?? loopbackListenerPortError(value)
?? managementIngressConfigError(value)
?? providerAliasTemplateConfigError(value)
?? dashboardTrustedProxyConfigError(value)
?? metricsExportConfigError(value)
?? serviceHealthRecoveryConfigError(value)
Expand Down
9 changes: 8 additions & 1 deletion src/config/load-degrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { isPassiveRead } from "../lib/passive-read";
import { redactSecretString } from "../lib/redact";
import { isValidProviderName } from "./provider-name";
import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases";
import { aliasTemplateError } from "../providers/alias-template";
import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits";
import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry";
import { providerFastSwitchOff } from "../providers/fast-opt-in";
Expand Down Expand Up @@ -1032,7 +1033,13 @@ export function sanitizeAliasesForLoad(raw: unknown, warn: (message: string) =>
const comboAliases = new Set(Object.values((root.combos as Record<string, { alias?: unknown }> | undefined) ?? {})
.map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean));
const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record<string, unknown> | undefined) ?? {}).map(name => name.toLowerCase()));
for (const provider of Object.values(providers)) {
for (const [providerName, provider] of Object.entries(providers)) {
// #7705: a hand-edited template that would not validate loses only itself, not the config.
if (provider.aliasTemplate !== undefined
&& (typeof provider.aliasTemplate !== "string" || aliasTemplateError(provider.aliasTemplate) !== null)) {
warn(`Ignoring invalid alias template for provider ${providerName} in config.json`);
delete provider.aliasTemplate;
}
const alias = provider.alias;
if (typeof alias !== "string" || !isValidProviderName(alias)
|| providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase())
Expand Down
2 changes: 2 additions & 0 deletions src/config/schema/leaf-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,8 @@ export const providerConfigSchema = z.object({
tlsProfile: z.literal("antigravity-browser").optional(),
alias: z.string().optional(),
modelAliases: z.record(z.string(), z.string()).optional(),
// Grammar is checked by sanitizeAliasesForLoad and the alias-template route (#7705).
aliasTemplate: z.string().optional(),
modelDisplayNames: modelDisplayNamesSchema.optional(),
defaultAliases: z.boolean().optional(),
initialModelSelection: z.object({
Expand Down
Loading
Loading