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
83 changes: 68 additions & 15 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,47 +226,47 @@ Configure a web search provider so the agent can search the web via the `web_sea
- **Tavily**: https://tavily.com/
- **SerpAPI**: https://serpapi.com/

## Model Providers (first-class OpenRouter)
## Model Providers (OpenRouter and Z.AI)

Route Docker agents (Claude Code, Codex, Goose) through named **provider profiles** — model presets that map an agent to an OpenRouter model with a bound key, no LiteLLM sidecar. See [MODEL_ROUTING.md](MODEL_ROUTING.md#first-class-openrouter) for the quickstart and [docs/designs/openrouter-integration.md](docs/designs/openrouter-integration.md) for the design. Edit via `ironcurtain config` → **Model Providers**, or the web UI Settings view.
Route Docker agents (Claude Code, Codex, Goose) through named **provider profiles** for OpenRouter or direct Z.AI. Each profile binds credentials and model selection. See [MODEL_ROUTING.md](MODEL_ROUTING.md) for the quickstarts. Edit via `ironcurtain config` → **Model Providers**, or the web UI Settings view.

An implicit profile named `native` — today's canonical Anthropic / OpenAI / ChatGPT routing — is always present, cannot be redefined or deleted, and is the fallback when no default is set.

| Field | Type | Default | Description |
| --------------------------------------------------- | ------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `modelProviders.default` | string | `native` | Profile used when no per-session choice is made. Must name a configured profile or `native`. |
| `modelProviders.profiles` | object | `{}` | User-named profiles, keyed by name. A profile named `native` is rejected (reserved). |
| `modelProviders.profiles.<name>.type` | string | — | Discriminator: `openrouter` or `native`. |
| `modelProviders.profiles.<name>.type` | string | — | Discriminator: `openrouter`, `zai`, or `native`. |
| `modelProviders.profiles.<name>.apiKey` | string | — | OpenRouter key (`sk-or-v1-...`). `OPENROUTER_API_KEY` env takes precedence. Sensitive; masked in the editor. |
| `modelProviders.profiles.<name>.modelMap` | array | `*opus/sonnet/haiku* → z-ai/glm-5.2` | Ordered glob→slug rules (first match wins), matched case-insensitively against the requested model. |
| `modelProviders.profiles.<name>.modelMap` | array | Opus → `z-ai/glm-5.3`; Sonnet/Haiku → `z-ai/glm-5.3-flash` | Ordered glob→slug rules (first match wins), matched case-insensitively against the requested model. |
| `modelProviders.profiles.<name>.perAgent` | object | — | Per-agent model override (`claude-code`, `goose`, `codex`). Wins over `modelMap` for that agent. |
| `modelProviders.profiles.<name>.providerPreference` | object | soft z-ai pin | Cache pinning passthrough (`order` / `only` / `allowFallbacks`). Replaces the D3 default when set. |
| `modelProviders.profiles.<name>.sessionAffinity` | boolean | `true` | Inject a stable top-level `session_id` for GLM cache affinity. |

**Defaults (per openrouter profile).** When `modelMap` is omitted it defaults to `DEFAULT_MODEL_MAP`: `*opus*`, `*sonnet*`, and `*haiku*` → `z-ai/glm-5.2`. `sessionAffinity` defaults to `true`. When the mapped slug is `z-ai/*` and `providerPreference` is unset, the MITM injects a soft pin `provider: { order: ["z-ai"] }` for cache affinity. An openrouter profile with just `{ "type": "openrouter", "apiKey": "sk-or-v1-..." }` therefore routes Claude Code to cached GLM-5.2 with no further config.
**Defaults (per openrouter profile).** When `modelMap` is omitted it defaults to `DEFAULT_MODEL_MAP`: `*opus*` → `z-ai/glm-5.3`, and `*sonnet*` / `*haiku*` → `z-ai/glm-5.3-flash`. `sessionAffinity` defaults to `true`. When the mapped slug is `z-ai/*` and `providerPreference` is unset, the MITM injects a soft pin `provider: { order: ["z-ai"] }` for cache affinity. An openrouter profile with just `{ "type": "openrouter", "apiKey": "sk-or-v1-..." }` therefore supplies both GLM tiers with no further config. Explicit maps and per-agent overrides remain authoritative.

**`OPENROUTER_API_KEY` env.** When set, it fills `apiKey` for **every** openrouter profile and takes precedence over any per-profile config `apiKey` (share one key across profiles). A profile's config `apiKey` is used only when the env var is unset. The env value is applied at resolve time and is **never persisted** to `config.json` — editing `modelProviders` via `ironcurtain config` or the web UI strips it from the write, so the env secret is never baked into the file.

**`modelMap: []` (per-agent-only mode).** An explicit empty array is preserved (resolution uses `??`, not `||`): the glob never matches, so routing relies on `perAgent` only.

**Reach of `default`.** The global default applies to **all** Docker Agent Mode sessions — interactive (`ironcurtain mux` PTY and batch `ironcurtain start`), daemon/cron jobs, signal-bot sessions, web-UI-spawned sessions, **and workflow orchestrator bundles** (one profile per shared-container run). A **per-session override** exists only where a surface exposes it: `ironcurtain start --provider-profile <name>` and the mux `/new` profile picker. Code Mode (builtin agent) is unaffected — profiles apply only to Docker Agent Mode.
**Reach of `default`.** The global default applies to **all** Docker Agent Mode sessions — interactive (`ironcurtain mux` PTY and batch `ironcurtain start`), daemon/cron jobs, signal-bot sessions, web-UI-spawned sessions, **and workflow orchestrator bundles** (one profile per shared-container run). A **per-session override** exists only where a surface exposes it: `ironcurtain start --provider-profile <name>` and the mux `/new` profile picker. Builtin and other host roles select profiles separately through `hostModelProfiles`.

**Hard load error on a dangling default.** A hand-edited `default` naming a profile that does not exist is a **hard error at config load** (`modelProviders.default must name a configured profile or "native".`) — it does not silently fall back to `native`. The `ironcurtain config` editor and web UI re-point `default` to `native` in the same write when you delete the profile it named, so they never persist a dangling default.

```json
{
"modelProviders": {
"default": "glm-5.2",
"default": "glm-5.3",
"profiles": {
"glm-5.2": {
"glm-5.3": {
"type": "openrouter",
"apiKey": "sk-or-v1-...",
"modelMap": [
{ "match": "*opus*", "model": "z-ai/glm-5.2" },
{ "match": "*sonnet*", "model": "z-ai/glm-5.2" },
{ "match": "*haiku*", "model": "z-ai/glm-5.2" }
{ "match": "*opus*", "model": "z-ai/glm-5.3" },
{ "match": "*sonnet*", "model": "z-ai/glm-5.3-flash" },
{ "match": "*haiku*", "model": "z-ai/glm-5.3-flash" }
],
"perAgent": { "goose": "z-ai/glm-5.2", "codex": "z-ai/glm-5.2" },
"perAgent": { "goose": "z-ai/glm-5.3", "codex": "z-ai/glm-5.3" },
"providerPreference": { "order": ["z-ai"], "allowFallbacks": false },
"sessionAffinity": true
},
Expand All @@ -279,7 +279,59 @@ An implicit profile named `native` — today's canonical Anthropic / OpenAI / Ch
}
```

Here `glm-5.2` is the default; `kimi` shares the env `OPENROUTER_API_KEY` (no per-profile `apiKey`) and uses a strict wildcard map. `native` need not be listed.
Here `glm-5.3` is the default; `kimi` shares the env `OPENROUTER_API_KEY` (no per-profile `apiKey`) and uses a strict wildcard map. The explicit Goose/Codex overrides select GLM 5.3 regardless of requested tier. `native` need not be listed.

### Direct Z.AI profiles

A `zai` profile connects Claude Code, Codex, and Goose directly to Z.AI with host-side credential swapping. Configure it in `ironcurtain config` → Model Providers or the web Settings provider editor:

```json
{
"modelProviders": {
"profiles": {
"glm": { "type": "zai", "plan": "api", "model": "glm-5.3" }
}
}
}
```

Set `ZAI_API_KEY` on the host, then select the profile with `ironcurtain start --provider-profile glm --agent claude-code "task"` (or `codex` / `goose`). The global `modelProviders.default` and mux picker also support it. Container credentials are sentinels; the real key stays on the host. Environment keys are never persisted during edits or renames.

| Z.AI field | Default | Meaning |
|---|---|---|
| `plan` | `api` | `api` selects the standard Chat Completions endpoint; `coding` selects the Coding Plan endpoint. Messages and Responses use their dedicated Z.AI roots. Account access must match the chosen plan. |
| `model` | `glm-5.3` | Default when no model is requested; also the default Opus map target. |
| `apiKey` | absent | Host credential; `ZAI_API_KEY` overrides every Z.AI profile. |
| `modelMap` | Opus → `model`; Sonnet/Haiku → `glm-5.3-flash` | Ordered, case-insensitive model mappings. Explicit `[]` disables mapping. Applied once when selecting the client model; the proxy preserves that selected model. |
| `perAgent` | absent | Overrides for `claude-code`, `codex`, or `goose`; wins over the model map. |

OpenRouter-specific `providerPreference` and `sessionAffinity` fields do not apply to Z.AI. Codex receives a generated model catalog; Claude Code uses its supported gateway endpoint and tier aliases. `ANTHROPIC_CUSTOM_MODEL_OPTION` is not required.

### Named profiles for host model roles

Host models select profiles independently of container profiles. Omitted roles retain native provider resolution, even when a gateway is the default for containers. Model IDs remain configured through the existing model fields.

```json
{
"modelProviders": {
"profiles": { "glm": { "type": "zai", "plan": "api", "model": "glm-5.3" } }
},
"hostModelProfiles": {
"agent": "glm",
"policy": "glm",
"prefilter": "glm",
"summary": "glm",
"autoApprove": "glm"
},
"autoApprove": { "enabled": false, "modelId": "glm-5.3" }
}
```

Select bindings in `ironcurtain config` → Model Providers → Host model profiles. Roles are `agent` (builtin), `policy`, `prefilter`, `summary` (conversation compaction), and `autoApprove` (both session modes). These bindings support `native`, OpenRouter, and Z.AI profiles. Host roles use the profile's model map, without Docker per-agent overrides. OpenRouter host requests preserve provider preferences, including strict pins and disabled fallback. Auto-approval remains disabled by default; a binding alone does not enable it. Model or output failures continue to escalate to a human.

A referenced profile cannot be deleted until its host roles are reassigned. Web profile renames migrate bindings and the default atomically, preserving stored keys. Existing profiles retain their service type; create a new profile to change service. Unknown host-profile references are configuration errors.

See [Z.AI profile design and validation](docs/designs/zai-provider-profiles.md).

## Server Credentials

Expand All @@ -306,13 +358,14 @@ API keys can be set via environment variables (preferred) or in the config file.
| `ANTHROPIC_BASE_URL` | `anthropicBaseUrl` | Override the Anthropic upstream endpoint (typically paired with a LiteLLM key) |
| `GOOGLE_GENERATIVE_AI_API_KEY` | `googleApiKey` | Google AI API key |
| `OPENAI_API_KEY` | `openaiApiKey` | OpenAI API key |
| `OPENROUTER_API_KEY` | `modelProviders.profiles.<name>.apiKey` | OpenRouter key; fills every openrouter profile (see [Model Providers](#model-providers-first-class-openrouter)) |
| `ZAI_API_KEY` | `modelProviders.profiles.<name>.apiKey` | Z.AI key; fills every Z.AI profile without persisting the environment value |
| `OPENROUTER_API_KEY` | `modelProviders.profiles.<name>.apiKey` | OpenRouter key; fills every openrouter profile (see [Model Providers](#model-providers-openrouter-and-zai)) |

In Docker mode, IronCurtain auto-detects OAuth credentials from `~/.claude/.credentials.json` (created by `claude login`) and prefers them over API keys. Set `IRONCURTAIN_DOCKER_AUTH=apikey` to force API key mode.

### Routing through a non-Anthropic gateway

For OpenRouter, prefer the first-class [Model Providers](#model-providers-first-class-openrouter) section above — no sidecar, prompt caching preserved, accurate cost. For any other gateway, IronCurtain talks to Anthropic via the official SDK with `x-api-key` auth; run [LiteLLM](https://docs.litellm.ai/) as a local sidecar that translates Anthropic-format requests to your target provider, then point IronCurtain at it:
For OpenRouter and Z.AI, use the first-class [Model Providers](#model-providers-openrouter-and-zai) section above. For other gateways, IronCurtain talks to Anthropic via the official SDK with `x-api-key` auth; run [LiteLLM](https://docs.litellm.ai/) as a local sidecar that translates Anthropic-format requests to your target provider, then point IronCurtain at it:

```bash
export ANTHROPIC_API_KEY="<your-litellm-virtual-key>"
Expand Down
24 changes: 16 additions & 8 deletions MODEL_ROUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@

IronCurtain supports redirecting LLM API traffic to a custom upstream — an OpenAI-compatible gateway like [LiteLLM](https://docs.litellm.ai/) fronting [OpenRouter](https://openrouter.ai/), a regional Anthropic endpoint, or a corporate proxy. The same configuration works in both Code Mode and Docker Agent Mode.

## First-class Z.AI

Named `zai` profiles route directly to the protocol supported by each harness: Anthropic Messages for Claude Code, OpenAI Responses for Codex, and Chat Completions for Goose. Profiles define the API plan, default model, optional ordered model map, and per-agent overrides. Credentials remain host-side. [Configuration and host model roles](CONFIG.md#direct-zai-profiles) describes setup; [design and validation](docs/designs/zai-provider-profiles.md) records the compatibility contract.

The default tier map sends Opus to `glm-5.3` and Sonnet/Haiku to `glm-5.3-flash`. OpenRouter uses the same tier split with `z-ai/`-prefixed model IDs. Explicit maps and per-agent overrides take precedence.

Host roles can select named Z.AI or OpenRouter profiles through `hostModelProfiles` independently of the container's active profile. Auto-approval remains optional and disabled by default.

## First-class OpenRouter

For OpenRouter specifically there is a dedicated, no-external-proxy path: **named provider profiles**. A profile is a _model preset_ — "run this session on GLM", "on Kimi" — that routes a Docker agent (Claude Code, Codex, Goose) straight through `openrouter.ai` with a bound model map and key, **no LiteLLM sidecar**. See [docs/designs/openrouter-integration.md](docs/designs/openrouter-integration.md) for the full design.

Profiles live in the `modelProviders` section of `~/.ironcurtain/config.json` (see [CONFIG.md](CONFIG.md#model-providers-first-class-openrouter)). An implicit `native` profile — today's canonical Anthropic / OpenAI / ChatGPT routing — is always present and is the fallback.
Profiles live in the `modelProviders` section of `~/.ironcurtain/config.json` (see [CONFIG.md](CONFIG.md#model-providers-openrouter-and-zai)). An implicit `native` profile — today's canonical Anthropic / OpenAI / ChatGPT routing — is always present and is the fallback.

There are three selection surfaces:

Expand All @@ -18,28 +26,28 @@ There are three selection surfaces:

The generic base-URL mechanism below (`anthropicBaseUrl` etc. + LiteLLM) **remains the escape hatch** for any other gateway (Bedrock, a regional endpoint, or a non-OpenRouter provider). When an OpenRouter profile is active it takes precedence over `anthropicBaseUrl` for that session; the base-URL override only applies to sessions on the `native` profile.

### Quickstart: GLM-5.2 via OpenRouter
### Quickstart: GLM-5.3 via OpenRouter

Fresh install → working, cached GLM in four steps:

1. Get an OpenRouter API key (`sk-or-v1-...`) from [openrouter.ai](https://openrouter.ai/).
2. Run `ironcurtain config` → **Model Providers** → **Add profile...** → choose type `openrouter`, name it (e.g. `glm-5.2`), and paste the key.
3. **Set default** to that profile (or pick it later at the mux `/new` picker, or pass `--provider-profile glm-5.2`).
4. Done. Because `DEFAULT_MODEL_MAP` maps `*opus*` / `*sonnet*` / `*haiku*` → `z-ai/glm-5.2` and D3 injects the soft z-ai pin, a session on this profile routes Claude Code to cached GLM-5.2 with no further config.
2. Run `ironcurtain config` → **Model Providers** → **Add profile...** → choose type `openrouter`, name it (e.g. `glm-5.3`), and paste the key.
3. **Set default** to that profile (or pick it later at the mux `/new` picker, or pass `--provider-profile glm-5.3`).
4. Done. `DEFAULT_MODEL_MAP` maps `*opus*` → `z-ai/glm-5.3` and `*sonnet*` / `*haiku*` → `z-ai/glm-5.3-flash`. The default soft z-ai pin and session affinity apply to both models.

Per-session instead of default:

```bash
ironcurtain start --provider-profile glm-5.2 "your task"
ironcurtain start --provider-profile glm-5.3 "your task"
```

The equivalent minimal `config.json` — no `modelMap` / `providerPreference` needed; the defaults supply GLM mapping + soft z-ai pin + session affinity:

```json
{
"modelProviders": {
"default": "glm-5.2",
"profiles": { "glm-5.2": { "type": "openrouter", "apiKey": "sk-or-v1-..." } }
"default": "glm-5.3",
"profiles": { "glm-5.3": { "type": "openrouter", "apiKey": "sk-or-v1-..." } }
}
}
```
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ snapshots, or backups. The identity key is not rotated or deleted by these comma

To route LLM traffic through a gateway like LiteLLM or OpenRouter (in both Code Mode and Docker Agent Mode), see [MODEL_ROUTING.md](MODEL_ROUTING.md).

Route Docker agents through model-provider profiles (e.g. GLM-5.2 via OpenRouter, no sidecar) with `ironcurtain config` → Model Providers, then pick a profile at `/new` or with `--provider-profile` — see [MODEL_ROUTING.md](MODEL_ROUTING.md#first-class-openrouter).
Route Docker agents through model-provider profiles (e.g. GLM-5.3 / GLM-5.3-Flash via OpenRouter, no sidecar) with `ironcurtain config` → Model Providers, then pick a profile at `/new` or with `--provider-profile` — see [MODEL_ROUTING.md](MODEL_ROUTING.md#first-class-openrouter).

## Built-in Capabilities

Expand Down
Loading
Loading