Skip to content

Latest commit

 

History

History
455 lines (339 loc) · 18.7 KB

README.md

File metadata and controls

455 lines (339 loc) · 18.7 KB

acp-openai-proxy

Dependency-light Node.js OpenAI-compatible HTTP proxy for Agent Client Protocol (ACP) agents.

The service launches configured ACP-compatible CLIs as stdio subprocesses, speaks newline-delimited JSON-RPC to them, and exposes OpenAI-compatible endpoints:

  • GET /health and GET /healthz
  • GET /v1/models
  • GET /v1/models/{model}
  • POST /v1/chat/completions
  • POST /v1/completions
  • POST /v1/responses

All generation is produced by configured ACP agents. The proxy does not call model providers directly.

Why Node.js

ACP agents and tooling are commonly shipped as Node/TypeScript CLIs. This implementation keeps the proxy in the same operational ecosystem while avoiding unnecessary framework dependencies. It uses Node's built-in HTTP server and child_process.spawn.

Install and test

npm test

No runtime npm dependencies are required. Node.js 20+ is expected.

Write default config

node src/index.js --write-default-config config.json

Run

node src/index.js --config config.json

The app writes logs to stdout/stderr. debug and info events go to stdout; warn and error events go to stderr. Set ACP_OPENAI_PROXY_LOG_LEVEL=debug, info, warn, error, or silent to adjust verbosity. Logs are newline-delimited JSON by default; set ACP_OPENAI_PROXY_LOG_FORMAT=text for plain text logs.

You can also configure logging in config.json:

{
  "server": {
    "logging": {
      "level": "info",
      "format": "json"
    }
  }
}

Environment variables override the config values when set.

Configuration is JSON only. See examples/config.json for a complete example.

Example request:

curl http://127.0.0.1:11435/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer local-proxy-token' \
  -d '{
    "model": "opencode-big-pickle",
    "stream": true,
    "messages": [{"role": "user", "content": "Say hello."}]
  }'

Agent environment variables

Environment variables live directly inside each agent block. There is no env_sections indirection.

{
  "agents": [
    {
      "name": "opencode",
      "instance_id": "opencode-a",
      "command": "npx",
      "args": ["-y", "opencode-ai@latest", "acp"],
      "models": ["opencode-big-pickle"],
      "env": {"XDG_DATA_HOME": "${AUTH_ROOT:-$HOME/.acp-auth}/opencode-a/.local/share"},
      "permission": "deny"
    }
  ]
}

Supported expansion forms in agents[].env and server.api_key:

  • {var:NAME} and {env:NAME}
  • {var:NAME:-fallback} and {env:NAME:-fallback}
  • ${NAME}, ${NAME:-fallback}, and $NAME
  • {file:/absolute/path/to/secret}

{file:...} reads a UTF-8 file during config loading and strips one trailing \n or \r\n. The file path is trimmed, may start with ~/, and may contain Windows-style %VAR% environment references. The resolved path must be absolute; relative {file:secret} tokens are left unchanged. Missing or unreadable files fail startup with a contextual Cannot read secret file ... error.

{file:...} expansion runs before environment expansion. This means env references cannot dynamically construct file paths in the same value. File contents are inserted literally and are not expanded again. Keep secret files outside the repo and permission them for the proxy user only, for example 0600.

The old env_sections, env_section, and envSections fields are intentionally rejected so stale configs fail loudly.

Multiple ACP agents behind one model id

Configure multiple agents with the same models entry. This starts multiple ACP subprocesses and exposes one OpenAI-compatible model id.

{
  "server": {
    "host": "127.0.0.1",
    "port": 11435,
    "api_key": "local-proxy-token",
    "routing_strategy": "sticky_failover",
    "max_retries": 1,
    "failure_cooldown_seconds": 60
  },
  "agents": [
    {
      "name": "opencode",
      "instance_id": "opencode-a",
      "command": "npx",
      "args": ["-y", "opencode-ai@latest", "acp"],
      "models": ["opencode-big-pickle"],
      "env": {"XDG_DATA_HOME": "${AUTH_ROOT:-$HOME/.acp-auth}/opencode-a/.local/share"},
      "permission": "deny"
    },
    {
      "name": "opencode",
      "instance_id": "opencode-b",
      "command": "npx",
      "args": ["-y", "opencode-ai@latest", "acp"],
      "models": ["opencode-big-pickle"],
      "env": {"XDG_DATA_HOME": "${AUTH_ROOT:-$HOME/.acp-auth}/opencode-b/.local/share"},
      "permission": "deny"
    }
  ]
}

GET /v1/models returns one model id with backing runtime metadata:

{
  "id": "opencode-big-pickle",
  "owned_by": "acp-agent-pool",
  "acp_agents": ["opencode-a", "opencode-b"],
  "x_acp_pool_size": 2,
  "x_acp_routing_strategy": "sticky_failover"
}

Generation responses include:

X-ACP-Agent: opencode-a
X-ACP-Model: opencode-big-pickle
X-ACP-Upstream-Model: opencode/big-pickle     # only when model_selection fired
X-Request-ID: req_a1b2c3d4e5f6

What models means

agents[].models means “OpenAI-compatible model ids exposed by this proxy and routed to this ACP runtime.” It does not automatically change the provider model inside the ACP agent.

So this config:

{
  "args": ["-y", "opencode-ai@latest", "acp"],
  "models": ["fast", "smart"]
}

exposes two OpenAI model ids, but both ids still talk to the same started agent process running its own default model, unless the agent also supports ACP session model configuration and model_selection is configured.

For agents that pin their model at launch time, define one agent block per actual upstream model:

{
  "agents": [
    {
      "name": "copilot-mini",
      "instance_id": "copilot-mini-a",
      "command": "npx",
      "args": ["-y", "@github/copilot@latest", "--acp", "--stdio", "--model", "gpt-5-mini"],
      "models": ["gpt-5-mini"],
      "env": {"HOME": "${AUTH_ROOT:-$HOME/.acp-auth}/copilot-mini-a"},
      "permission": "deny"
    },
    {
      "name": "copilot-full",
      "instance_id": "copilot-full-a",
      "command": "npx",
      "args": ["-y", "@github/copilot@latest", "--acp", "--stdio", "--model", "gpt-5.2"],
      "models": ["gpt-5.2"],
      "env": {"HOME": "${AUTH_ROOT:-$HOME/.acp-auth}/copilot-full-a"},
      "permission": "deny"
    }
  ]
}

To pool multiple accounts for the same exact model, duplicate that model-specific block with different instance_id and env.

Optional ACP session model selection

Some ACP agents expose session configuration options, including a model selector. For those agents, the proxy can dynamically set the ACP model config option per request, before session/prompt.

How it works

  1. When the proxy creates a new ACP session (session/new), the agent may return a configOptions array of configuration knobs.
  2. If model_selection is configured on the agent, the proxy looks for a config option matching config_id, or falls back to one with category: "model" or id: "model".
  3. The proxy maps the OpenAI model string from the request (e.g. "opencode-big-pickle") to an ACP config value using the values map.
  4. The proxy calls session/set_config_option with the resolved value before sending the prompt.

Example

{
  "agents": [
    {
      "name": "agent-with-model-selector",
      "instance_id": "agent-a",
      "command": "some-acp-agent",
      "args": ["acp"],
      "models": ["fast", "smart"],
      "model_selection": {
        "type": "session_config",
        "config_id": "model",
        "values": {
          "fast": "flash",
          "smart": "pro"
        }
      },
      "env": {"API_KEY": "{var:API_KEY_A}"},
      "permission": "deny"
    }
  ]
}

model_selection fields

Field Type Default Description
type string "auto" "session_config" or "auto". Both resolve the model through the agent's session config options; "auto" is simply the default when type is omitted.
config_id string auto-detect ACP config option id to set. When omitted, the proxy auto-detects by looking for a config option with category: "model" or id: "model" in the agent's session/new response.
values object {} Maps OpenAI model ids to upstream values. Keys are the strings clients send as model; values are what gets passed to session/set_config_option. When a model id is not in this map, the proxy tries to match it against the agent's own option list by value or name.
required boolean true Controls error behaviour when model selection fails. See "What required does" below.

You can also set model_selection: true as shorthand for { "type": "auto", "required": true } with auto-detection and no explicit value mappings.

How to know if an ACP agent supports model selection

There is no way to know in advance from configuration alone. The proxy discovers support at runtime when the agent responds to session/new. The agent supports model selection if its response contains a configOptions entry with a model category. If it does not, the proxy cannot switch models for that agent.

To check: start the proxy with ACP_OPENAI_PROXY_LOG_LEVEL=debug and inspect the session/new response logged for each agent.

OpenCode example

OpenCode returns a model config option from session/new, with one entry per model its account can reach. Configure it like:

{
  "name": "opencode",
  "command": "npx",
  "args": ["-y", "opencode-ai@latest", "acp"],
  "models": ["opencode-big-pickle", "opencode-mimo", "opencode-nemotron"],
  "model_selection": {
    "type": "session_config",
    "config_id": "model",
    "required": true,
    "values": {
      "opencode-big-pickle": "opencode/big-pickle",
      "opencode-mimo":       "opencode/mimo-v2.5-free",
      "opencode-nemotron":   "opencode/nemotron-3-ultra-free"
    }
  }
}

The proxy issues session/set_config_option with the mapped value before each prompt. Run the model probe trick (session/new with no follow-up) to discover the exact option values the installed CLI advertises; they change as the account's provider list changes.

What required does

required decides what happens when the proxy cannot complete model selection for a request — either silently fall back to whatever model the CLI was launched with, or fail the request loudly.

There are four points where selection can fail:

Failure required: true required: false
Agent exposes no configOptions 502 acp_error to client skip; send prompt as-is
Your values map has no entry for the requested model id (and no automatic match from the agent's own option list) 502 skip
The mapped value is not in the agent's allowed value list 502 skip
No configId resolvable 502 skip

In every "skip" case the proxy does not call session/set_config_option. The agent receives the prompt and answers using whatever model its session already had.

Practical recommendation:

  • required: true — when a model id maps to a specific upstream model and a broken mapping should fail loudly. Good for cost-control or compliance pinning.
  • required: false — when the values map is best-effort and an unmapped id should silently use the agent's default rather than 502. The trade-off is that x-acp-model in the response reflects only the OpenAI-style id the client sent, not the upstream model that actually answered.

Concrete example with the OpenCode agent and required: false:

Client requests model: … What happens
"opencode-mimo" (mapped) session/set_config_option { configId: "model", value: "opencode/mimo-v2.5-free" } → that model answers
"some-unknown-model" no set_config_option call → agent answers using its session default

With required: true, the second row would fail with 502 acp_error: agent ... cannot map requested model "some-unknown-model" to an ACP model config option.

When to use model_selection vs. separate agent blocks

Approach When to use
One agent block per model Simple, reliable. Each subprocess runs a fixed model. No dependency on ACP config option support. Recommended for most setups.
model_selection Single subprocess serves multiple models. Fewer processes, but requires the ACP agent to support session/set_config_option. Use when you have confirmed the agent supports it and want to reduce resource usage.

Routing choices

sticky_failover is the recommended default for repeated long-prefix/cache-sensitive traffic. It routes the same explicit key or prompt prefix to the same runtime, then tries another runtime only when the selected one fails before producing output.

primary_failover always tries configured agents in order. Use it when one account/process should stay hot and backups should only handle failures.

round_robin spreads first attempts across runtimes. Use it when quota/load spreading matters more than cache locality.

least_busy is a small local-pool helper that prefers idle runtimes; it is not a queue broker.

Retry budget:

{
  "routing_strategy": "sticky_failover",
  "max_retries": 2,
  "max_attempts_per_request": 0,
  "failure_cooldown_seconds": 60,
  "retry_backoff_seconds": 0,
  "retry_on_any_acp_error": false,
  "affinity_prefix_chars": 4096
}

With two runtimes and max_retries = 2, retryable failures can attempt a, b, a, b. If all attempts fail, the proxy returns an OpenAI-style acp_error with per-runtime summaries.

Health tracking and cooldown: when a runtime fails (rate limit, quota exhausted, etc.) it is marked in cooldown for failure_cooldown_seconds. On every subsequent request the proxy reorders the pool so healthy runtimes are tried first — the failing agent is skipped until its cooldown expires, without any change needed in client code. A successful response immediately clears the cooldown. The state is in-memory and resets on proxy restart.

Multimodal input and attachments

Forwarded input mappings:

Input ACP block
Text text
data:image/...;base64,... image if the agent advertises image prompt support
Image URL / file URI resource_link
Base64 audio / data:audio/...;base64,... audio if the agent advertises audio prompt support
Audio URL / file URI resource_link
File URL / URI resource_link
Inline file text/base64/data URI resource if the agent advertises embedded-context support

file_id is rejected because a local ACP agent cannot dereference OpenAI-hosted files. Remote URLs are forwarded, not downloaded.

Tools and permissions

Chat Completions accepts OpenAI client-owned function tools through tools/tool_choice and deprecated functions/function_call. The proxy adds the tool contract to the ACP prompt and, when the ACP agent responds with a tool-call JSON envelope, returns OpenAI-compatible message.tool_calls with finish_reason: "tool_calls".

The proxy does not execute those tools. Your OpenAI-compatible client must execute the returned function call and send the result back as a role: "tool" message. ACP/MCP tools remain agent-owned and separate from this client-side mechanism.

Streaming Chat Completions with client tools are buffered until the ACP turn completes, so the proxy can decide whether to emit text or a tool_calls delta.

Permission modes:

{"permission": "deny"}
{"permission": "read_only"}
{"permission": "allow"}

deny is the default. allow should only be used in trusted disposable sandboxes.

The read_only mode uses keyword-substring heuristics on permission-request params to decide what to approve. It is best-effort, not a guarantee — use deny for hostile or untrusted CLIs.

Docker

Build:

docker build -t acp-openai-proxy .

Run with Compose:

docker compose up --build

The image installs the ACP CLIs it needs at build time, so the container does not fetch npx packages on startup. The compose file mounts a persistent auth volume at /auth. Each agent uses a subdirectory under that volume for HOME, and OpenCode also uses XDG_DATA_HOME and XDG_CONFIG_HOME, so CLI state and logins survive container restarts.

The compose service runs as a non-root user with a read-only root filesystem, no added Linux capabilities, no-new-privileges, and a private /tmp scratch mount. It also binds the published port to 127.0.0.1 only.

config.docker.json binds the proxy to 0.0.0.0 inside the container. It is copied into the image as /app/config.json.

AUTH_ROOT is the only directory root the docker config needs. Agent-specific home values are derived from it so they stay persistent without hardcoding a host path.

Agent startup commands

The proxy only launches the commands listed in agents[].command and agents[].args. In the docker image, those commands are the installed binaries.

Current config:

Agent Command Persistent home
OpenCode opencode acp XDG_DATA_HOME=${AUTH_ROOT}/opencode-a/.local/share
OpenCode opencode acp XDG_DATA_HOME=${AUTH_ROOT}/opencode-b/.local/share
Claude ACP claude-agent-acp HOME=${AUTH_ROOT}/claude-ka
GitHub Copilot copilot --acp --stdio --model gpt-5-mini --effort high HOME=${AUTH_ROOT}/github-gpt-5-mini-ka

The image build installs these packages by default:

  • opencode-ai@latest
  • @agentclientprotocol/claude-agent-acp@latest
  • @github/copilot@latest

Reference startup shapes (not installed by default Docker image)

Agent Command Persistent home
Qwen Code npx @qwen-code/qwen-code@latest --model qwen3.6-plus --acp HOME=${AUTH_ROOT}/qwen-code
Auggie CLI npx @augmentcode/auggie@latest --model <model-id> --acp HOME=${AUTH_ROOT}/auggie
Qoder CLI npx @qoder-ai/qodercli@latest --model auto --acp HOME=${AUTH_ROOT}/qoder
Kilo Code npx --yes --package @kilocode/cli@latest kilo --model kilo/minimax/minimax-m2.7 acp HOME=${AUTH_ROOT}/kilo-code
Codex CLI npx @zed-industries/codex-acp@latest -c model="o3" HOME=${AUTH_ROOT}/codex
OpenClaw openclaw acp HOME=${AUTH_ROOT}/openclaw
Kiro CLI kiro-cli settings chat.defaultModel claude-opus-4.7 && kiro-cli acp HOME=${AUTH_ROOT}/kiro
Hermes Agent hermes model && hermes acp HOME=${AUTH_ROOT}/hermes

These are reference startup shapes for extending the config. The default Docker image does not install them unless you add the matching packages or binaries to the build.

If a CLI needs a provider key or first-run login, add that secret to the container environment as well. The auth volume only persists the state that the CLI writes to disk.

Detailed spec

See docs/ARCHITECTURE.md.

Verification

npm run check
npm test

Current expected test result: 46 passing tests.