dragoman (n.) — an interpreter and guide, acting as both linguist and cultural mediator for travelers, merchants and diplomats.
Translates between LLM protocols — Responses in, translated to the target provider, Responses out.
A service is a name, an endpoint, a protocol, and the environment variable holding that endpoint's key. Six are built in:
| service | protocol | endpoint | key |
|---|---|---|---|
openai |
openai-responses |
https://api.openai.com/v1 |
OPENAI_API_KEY |
anthropic |
anthropic-messages |
https://api.anthropic.com/v1 |
ANTHROPIC_API_KEY |
gemini |
gemini-interactions |
https://generativelanguage.googleapis.com/v1beta |
GEMINI_API_KEY |
deepseek |
openai-completions |
https://api.deepseek.com |
DEEPSEEK_API_KEY |
openrouter |
openai-completions |
https://openrouter.ai/api/v1 |
OPENROUTER_API_KEY |
openai-chat |
openai-completions |
https://api.openai.com/v1 |
OPENAI_API_KEY |
A service whose variable is unset is still served. It reports itself unavailable on GET /services and fails when it is called, so one missing key does not stop the rest. Your own config can override the default set.
--config names a YAML document. Every command that reads the table takes it.
mode: append # the default: these join the built-in services
groq:
endpoint: https://api.groq.com/openai/v1
protocol: openai-completions
auth: GROQ_API_KEY| field | what it changes |
|---|---|
endpoint |
scheme, host and base path. The protocol appends its own path to it. |
protocol |
which of the four wire formats this endpoint speaks. Required. |
auth |
the name of the variable holding the key, never the key. Required. |
max_output_tokens_as |
which field carries the output cap: max_completion_tokens or max_tokens. openai-completions only. |
schema_as_prompt |
see below. openai-completions only. |
mode: override disables the built in services. Under append, the built in services remain available.
Important
schema_as_prompt. Of the built-in services it applies to deepseek alone. Its JSON mode takes no schema and wants the shape described in the prompt, so an incoming json_schema is written into the instructions and sent with a response_format of {"type": "json_object"}. text.format is dropped on every such request.
| protocol | path appended to the endpoint |
|---|---|
openai-responses |
/responses |
openai-completions |
/chat/completions |
anthropic-messages |
/messages |
gemini-interactions |
/interactions |
Not all protocols carry the same field. A field that cannot be carried across is dropped and named by its path in the logs on --verbose.
--verbose writes to stderr, serve logs them at debug level, and --strict turns any drop into an error before the call goes out.
Needs Go 1.25.1 and one provider key.
$ go install github.com/mdijkstra-oss/dragoman/cmd/dragoman@latestconfig prints what this binary would serve, in the same YAML a document uses.
$ dragoman config
mode: override
anthropic:
endpoint: https://api.anthropic.com/v1
protocol: anthropic-messages
auth: ANTHROPIC_API_KEY
...The request arrives on stdin and the response leaves on stdout.
$ export ANTHROPIC_API_KEY=...
$ echo '{"input":"Say hello in three words."}' \
| dragoman --backend anthropic --model claude-sonnet-5 --max-tokens 100 --timing
elapsed 1.837s
{"id":"msg_011Cduw...","object":"response","status":"completed","model":"claude-sonnet-5","output":[{"type":"message","id":"msg_0","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Hello, hi there!"}]}],"usage":{"input_tokens":14,"output_tokens":10,"total_tokens":24}}--stream writes the event sequence instead, one JSON object per line. --no-execute prints the translated request and makes no call. --raw returns the backend's answer untranslated.
The same translation is served over HTTP, where stream comes back as server-sent events:
$ dragoman serve --addr 127.0.0.1:8080
$ curl -sN -X POST http://127.0.0.1:8080/responses \
-d '{"model":"gemini/gemini-3.6-flash","input":"Say hello.","stream":true}'
event: response.created
data: {"type":"response.created","sequence_number":1,"response":{"id":"v1_Chds...","status":"in_progress","model":"gemini-3.6-flash","output":[]}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":7,"item_id":"msg_1","output_index":1,"content_index":0,"delta":"Hello!"}| route | body | response |
|---|---|---|
POST /responses |
an openai-responses request |
an openai-responses response, or server-sent events when the body sets stream |
GET /services |
— | listing of available services |
GET /health |
— | {"status":"ok"} when running |
Over HTTP there is no --backend: the model names its service. The split is on the first slash only, so openrouter/anthropic/claude-3 reaches openrouter and asks it for anthropic/claude-3.
A provider's own status is passed through, so a rate limit keeps the retry semantics an SDK already implements; a provider error arriving inside a 200 becomes 502.
Every service names the variable holding its key, and those names are the table's. The listen address is a flag, not a variable.
LOG_REQUEST_HEADERS names caller headers to put on every log line for the request — X- names only, at most 16, and a name reading as a credential is refused at boot.
Logs are JSON on stderr:
{"time":"2026-08-11T01:33:23.323898+02:00","level":"INFO","msg":"request served","service":"anthropic","model":"claude-sonnet-5","status":200,"duration_ms":2044,"input_tokens":11,"cached_tokens":0,"output_tokens":17,"reasoning_tokens":0,"total_tokens":28,"headers":{"x-session-id":"s-42"}}docker build -t dragoman .
docker run -p 8080:8080 -e ANTHROPIC_API_KEY dragomanDockerfile builds a static binary onto scratch, listening on 0.0.0.0:8080. It carries no key and no configuration document, so the image is the same bytes for every deployment. Nothing outside this repository is read, so a git URL is a complete build context.
make run PROMPT="Say hello." # one request against the default model
make run < request.json # a whole request off stdin
make serve # the built-in table over HTTP
make serve-dev # the same, rebuilt and restarted on a change
go test ./...MODEL, REASONING and ADDR override the defaults, ARGS goes straight to dragoman, and JQ=. prints the whole response instead of a summary. Keys come from .env.local, which .env.example describes.
The service table is embedded, so editing it is a source change and rebuilds the binary.
Every command takes --config, except healthcheck.
| command | does |
|---|---|
| (default) | Translate one request from stdin to stdout. |
serve |
Serve the service table over HTTP. |
config |
Print the table this binary would serve. |
healthcheck |
Report whether a listener is serving. |
No flag takes a credential — only the name of a variable — because argv is visible in ps, in shell history and in CI logs.
Exit codes are 0 for an answer, 1 for a backend that refused, and 2 for a request that was never sendable.
AGPL-3.0. The full text is in LICENSE.
- chancery — an agent server built from Markdown files. Each file is a prompt and its own HTTP route.