Sargo

A small model request router written in Rust. One provider-neutral inference request in, forwarded to Anthropic or OpenAI, one neutral reply out. Tool calling is supported; the config is a TOML file and the API is described by a JSON Schema.

What it is

A caller POSTs one provider-neutral inference request; sargo forwards it to Anthropic or OpenAI and returns the reply in the same neutral shape. It lives in rust/sargo in the gligabao repository and is not used by gligabao itself yet.

One request shape

The caller never writes Anthropic or OpenAI JSON. The shape follows Anthropic's content blocks (text, tool calls, tool results), which can say everything OpenAI can, so translating to OpenAI loses nothing.

Tool calling

Tools are supported with the same tool_use and tool_result blocks for both providers. sargo is stateless, so the caller runs the loop.

Small and blocking

An HTTP server on the standard library only, one thread per connection. No streaming yet. The server and the HTTP client sit behind small interfaces that can be swapped.

No keys in files

Provider API keys come from environment variables, never from the config file. A provider is on when its variable is set and not empty; at least one must be.

Build and run

./run_build_rust.sh builds the release binary (rust/sargo/target/release/sargo); with --static it builds a fully static musl binary that runs on any Linux of the same CPU architecture (on Ubuntu it needs musl-tools). ./run_server_sargo.sh starts the compiled server and passes extra arguments to it.

terminal
# build (needs cargo)
$ ./run_build_rust.sh            # or --static for a musl binary

# run (at least one provider key must be set)
$ ANTHROPIC_API_KEY=... OPENAI_API_KEY=... ./run_server_sargo.sh path/to/config.toml

# or from the project directory
$ cd rust/sargo && cargo run      # reads ./config.toml (optional)

Config

A config.toml read once at start-up: from the first command-line argument, or SARGO_CONFIG, or ./config.toml. Every key is optional; if no path is given and ./config.toml does not exist, the defaults below are used. Unknown keys are rejected.

config.toml (the defaults)
[server]
address = "127.0.0.1:8431"
read_timeout_secs = 30
max_body_bytes = 10485760

[http_client]
timeout_secs = 300

[providers.anthropic]
base_url = "https://api.anthropic.com"
api_key_env = "ANTHROPIC_API_KEY"

[providers.openai]
base_url = "https://api.openai.com"
api_key_env = "OPENAI_API_KEY"

[server] sets the address, how long to wait for a client that stops sending and the largest request body; [http_client] how long to wait for a model provider (models can be slow); [providers.*] the base URL and the name of the environment variable that holds the API key.

The API

GET /health answers {"status":"ok"}. POST /v1/infer takes a request like this one:

POST /v1/infer
{
  "provider": "anthropic",
  "model": "claude-sonnet-4-5",
  "system": "You are helpful.",
  "messages": [{"role": "user", "content": [{"type": "text", "text": "Weather in Oslo?"}]}],
  "tools": [{"name": "weather", "description": "Get weather",
            "input_schema": {"type": "object", "properties": {"city": {"type": "string"}},
                            "required": ["city"]}}],
  "max_tokens": 1024
}

The reply has the answer as content blocks, the stop reason and the token usage. stop_reason is end_turn, tool_use, max_tokens or the provider's raw value.

reply
{
  "provider": "anthropic", "model": "claude-sonnet-4-5",
  "content": [{"type": "tool_use", "id": "tu_1", "name": "weather", "input": {"city": "Oslo"}}],
  "stop_reason": "tool_use",
  "usage": {"input_tokens": 50, "output_tokens": 20}
}

Errors are {"error": "..."} with 400 (bad request), 502 (the provider failed; may be retried) or 503 (the provider is not configured in sargo). system, tools, max_tokens (default 1024) and temperature are optional.

Tool calling

sargo is stateless: the caller runs the loop. Repeat until stop_reason is end_turn.

1. Ask with tools

POST a request with tools. When the model wants a tool, the reply has stop_reason tool_use and one or more tool_use blocks (id, name, input).

2. Run the tools

The caller runs them itself.

3. Send the same request again

With two messages appended: an assistant message with the returned content unchanged, and a user message with one {"type": "tool_result", "tool_use_id": "...", "content": "..."} block per tool call (add "is_error": true if the tool failed).

JSON Schema

rust/sargo/inference.schema.json (JSON Schema draft 2020-12) describes the whole API for client authors. The root is the POST /v1/infer request; $defs holds the reply, error and health bodies, and x-endpoints lists each endpoint with its request and reply schemas. Validate requests against it before sending. A unit test keeps its property names in step with the Rust structs.

Go client

The package internal/sargo is a client for the server: New, Client.Infer and Client.Health. Its types follow the JSON Schema, and a request is checked against the schema's rules before it is sent, so a bad one fails in Go instead of with a 400. Server errors come back as *sargo.APIError (Retryable() is true for 502); Response.AssistantMessage() helps with the tool loop.

go (internal/sargo)
c, err := sargo.New("http://127.0.0.1:8431")
resp, err := c.Infer(ctx, &sargo.Request{
	Provider: sargo.ProviderAnthropic,
	Model:    "claude-sonnet-4-5",
	Messages: []sargo.Message{{Role: sargo.RoleUser, Content: []sargo.ContentBlock{sargo.TextBlock("Hello")}}},
})
fmt.Println(resp.Text())

Swapping parts

HTTP server

Implement HttpServer (src/http_server.rs) and change one line in main.rs.

HTTP client

Only src/http_client.rs calls out.

A new provider

Implement Provider in a new file in src/providers/, register it in providers/mod.rs, and add a field and a match arm in src/router.rs.