# Conifer gateway — setup runbook for coding agents Conifer is one API key for OpenAI-compatible and Anthropic-compatible endpoints, routed across local and cloud models. The gateway is https://api.conifer.build. It serves the OpenAI chat wire, the OpenAI Responses wire, and the Anthropic Messages wire on one credential. This page tells an AI coding agent how to wire Conifer into a codebase. Follow it top to bottom. ## Install the CLI macOS: ```sh curl -fsSL https://conifer.build/setup | bash ``` Linux (the script is POSIX sh; Alpine often has no bash): ```sh curl -fsSL https://conifer.build/setup | sh ``` Windows (paste into an already-open PowerShell window; do not wrap in powershell -c): ```powershell curl.exe -fsSL https://conifer.build/setup.ps1 | iex ``` The script detects OS/arch, verifies SHA-256, puts `conifer` on PATH, then runs `conifer setup` (account, card, API key, a local model, agent CLIs). Linux is a thin client: no local inference after setup. ## Get a key Mint a key at https://conifer.build/console#/keys, under Mint API keys. Minting unlocks after pay-as-you-go billing is set up (https://conifer.build/console#/billing). The key is one long opaque token string. It is shown exactly once; the server keeps no copy it can re-show. Keys are revocable from the same page (effective in seconds) and expire after at most 90 days. The console currently mints keys with access to your full model catalog and the maximum lifetime. Revocation is the control you actually hold, so treat every key accordingly. Store it as CONIFER_API_KEY. The recipes below map that one variable into each client's own name. ## Wire it up OpenAI SDK (any language) — set two env vars, change no code: ```sh export OPENAI_BASE_URL=https://api.conifer.build/v1 export OPENAI_API_KEY=$CONIFER_API_KEY ``` Anthropic SDK (any language) — note: no /v1 suffix on this base URL: ```sh export ANTHROPIC_BASE_URL=https://api.conifer.build export ANTHROPIC_API_KEY=$CONIFER_API_KEY ``` ANTHROPIC_AUTH_TOKEN also works and takes precedence when both are set. ## Official SDKs There is no first-party Conifer package. Do not `npm install @conifer/sdk` or `pip install conifer`. The recommended client is the official OpenAI package pointed at the gateway. The official Anthropic package is the Messages-wire twin. Human pages: https://conifer.build/docs/sdk/ TypeScript — `npm install openai`: ```ts import OpenAI from "openai"; export function conifer() { const apiKey = process.env.CONIFER_API_KEY; if (!apiKey) { throw new Error("CONIFER_API_KEY is missing. Set it before calling the gateway."); } return new OpenAI({ baseURL: process.env.OPENAI_BASE_URL ?? "https://api.conifer.build/v1", apiKey, }); } export async function POST(req: Request) { const res = await conifer().chat.completions.create({ model: "claude-haiku-4-5", max_tokens: 1024, messages: [{ role: "user", content: "three names for a build cache" }], }); return Response.json({ content: res.choices[0].message.content }); } ``` Python — `pip install openai`: ```py from openai import OpenAI import os client = OpenAI( base_url=os.environ.get("OPENAI_BASE_URL", "https://api.conifer.build/v1"), api_key=os.environ["CONIFER_API_KEY"], ) res = client.chat.completions.create( model="claude-haiku-4-5", max_tokens=1024, messages=[{"role": "user", "content": "three names for a build cache"}], ) print(res.choices[0].message.content) ``` Anthropic Python — `pip install anthropic`. Base URL has no /v1 suffix: ```py from anthropic import Anthropic import os client = Anthropic( base_url=os.environ.get("ANTHROPIC_BASE_URL", "https://api.conifer.build"), api_key=os.environ["CONIFER_API_KEY"], ) res = client.messages.create( model="claude-haiku-4-5", max_tokens=1024, messages=[{"role": "user", "content": "three names for a build cache"}], ) print(res.content) ``` Anthropic TypeScript — `npm install @anthropic-ai/sdk`. App Router handler: paste into a route, or put `conifer()` in `lib/` and call it from the handler. Base URL has no /v1 suffix. ```ts import Anthropic from "@anthropic-ai/sdk"; export function conifer() { const apiKey = process.env.CONIFER_API_KEY; if (!apiKey) { throw new Error("CONIFER_API_KEY is missing. Set it before calling the gateway."); } return new Anthropic({ baseURL: process.env.ANTHROPIC_BASE_URL ?? "https://api.conifer.build", apiKey, }); } export async function POST(req: Request) { const res = await conifer().messages.create({ model: "claude-haiku-4-5", max_tokens: 1024, messages: [{ role: "user", content: "three names for a build cache" }], }); return Response.json({ content: res.content }); } ``` Verify (https://conifer.build/docs/sdk/install/). TypeScript is an App Router handler: paste into a route, or put `conifer()` in `lib/` and call it from the handler. Python is a script: ```ts import OpenAI from "openai"; export function conifer() { const apiKey = process.env.CONIFER_API_KEY; if (!apiKey) { throw new Error("CONIFER_API_KEY is missing. Set it before calling the gateway."); } return new OpenAI({ baseURL: "https://api.conifer.build/v1", apiKey, }); } export async function POST(req: Request) { const res = await conifer().chat.completions.create({ model: "claude-haiku-4-5", max_tokens: 64, messages: [{ role: "user", content: "Say hello from Conifer and nothing else." }], }); return Response.json({ content: res.choices[0].message.content }); } ``` ```py import os from openai import OpenAI client = OpenAI( base_url="https://api.conifer.build/v1", api_key=os.environ["CONIFER_API_KEY"], ) res = client.chat.completions.create( model="claude-haiku-4-5", max_tokens=64, messages=[{"role": "user", "content": "Say hello from Conifer and nothing else."}], ) print(res.choices[0].message.content) ``` Streaming: `stream: true` / `stream=True` on chat completions. Tools: send OpenAI-shaped `tools` only after `GET /v1/models` `caps` includes `tools`; handle `tool_calls` or `[]`. Reasoning: read `choices[0].message.reasoning` (and `reasoning_content`) only when present; pass `reasoning: {effort:"medium"}` (Python: `extra_body`). Cache on the chat wire: `usage.prompt_tokens_details` may be omitted; when present read `cached_tokens` and `cache_write_tokens` (0 if none). Do not assume a thinking-token count on usage unless that field exists. Azure BYOK model id: `azure/` or the deployment name. Errors: catch the official SDK class and branch on status and `code`. OpenAI-compat (`/v1/chat/completions`, `/v1/completions`, `/v1/responses`): 401 type `invalid_request_error`, code `invalid_api_key`, message "Incorrect API key provided", header `WWW-Authenticate: Bearer` (all auth failures are this one 401); 402 billing stays 402 `insufficient_allowance` with additive code `insufficient_quota` (not remapped to 429); spend cap stays `cost_ceiling_exceeded`; 429 type `rate_limit_error`, code `rate_limit_exceeded`, `Retry-After: 1` (no invented `x-ratelimit-*` figures); 404 code `model_not_found` + param `model`; context length type `invalid_request_error`, code `context_length_exceeded`. A missing capability (tools, vision, or another omitted `caps` string) is a 400 that names the problem, not a 402 and not a 429. Stream in-band error object matches the buffered envelope. `request-id` and `x-request-id` echo `x-conifer-request-id`. Anthropic `/v1/messages` types were already industry-shaped and stay that way. Local CLI Azure endpoint (distinct from gateway custody): `printf %s "$AZURE_KEY" | conifer endpoints add byok azure --url https://{resource}.openai.azure.com/openai/v1`. OpenRouter ai-sdk provider — the base URL is the only change: ```ts import { createOpenRouter } from "@openrouter/ai-sdk-provider"; export function conifer() { const apiKey = process.env.CONIFER_API_KEY; if (!apiKey) { throw new Error("CONIFER_API_KEY is missing. Set it before calling the gateway."); } const openrouter = createOpenRouter({ baseURL: "https://api.conifer.build/v1", apiKey, }); return openrouter("anthropic/claude-opus-5"); } ``` OpenRouter-only body fields (provider, route, transforms, models) and the HTTP-Referer / X-Title headers are accepted and ignored, so an unmodified request is served rather than refused. Plain HTTP — the key goes in `Authorization: Bearer ` or `x-api-key: ` (Authorization wins if both are sent). OpenAI chat wire: ```sh curl https://api.conifer.build/v1/chat/completions \ -H "Authorization: Bearer $CONIFER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-haiku-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "three names for a build cache"}] }' ``` Anthropic Messages wire (Anthropic model ids only): ```sh curl https://api.conifer.build/v1/messages \ -H "x-api-key: $CONIFER_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-haiku-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "three names for a build cache"}] }' ``` max_tokens is required on /v1/messages. On the chat wire it is optional; the server applies 4096 when it is absent. ## Tool calling and agents Tool/function calling works on the chat wire (/v1/chat/completions): send OpenAI-shaped `tools` and handle `tool_calls` in the reply, exactly as with OpenAI. Not every model supports tools. Each entry in GET /v1/models carries a `caps` list of freeform strings (observed values include `chat`, `tools`, `vision`); pick a model whose caps include "tools". Sending tools to a model whose caps do not include it is a clear 400 that says so — never a silent drop, never a 402, never a 429. A successful reply with no `tool_calls` means the model did not call a tool. ```ts console.log(res.choices[0].message.tool_calls ?? []); ``` SSE streaming works on the chat wire (`stream: true`). For agent runs on your own infrastructure (sandboxed workers, CI, bots), hold the key server-side as an env var, exactly like the proxy pattern in the client-side section below. Today's minted keys carry full-catalog access for up to 90 days, so server-side custody plus fast revocation is the posture that actually protects you. ## Pick a model ```sh curl -s https://api.conifer.build/v1/models \ -H "Authorization: Bearer $CONIFER_API_KEY" ``` The response is the catalog your key can call (~81 models today), with prices and `caps`. Keep `caps` in any jq projection; dropping it hides divergence. The `id` field is the exact string for a request's `model` field. OpenRouter-style namespaced ids also work: `anthropic/claude-opus-5` resolves to the catalog's `claude-opus-5`. The response headers `x-conifer-requested-model` and `x-conifer-effective-model` name both spellings. An id the catalog does not serve is refused by name — 404, never substituted with something near it. Named versus routed: a catalog id is served as that id. A saved route, a reach preset (`all` / `balanced`), or Palm is how the router chooses. There is no public catalog id named `auto`. Pinning `claude --model claude-haiku-4-5` is a named request and turns that routing off. Conifer's own catalog ids are `conifer-bramble`, `conifer-quickbeam`, and `conifer-ironwood`. Bring-your-own-key traffic is billed by the provider. Conifer's take-rate is 0% (`fee_pct` on the catalog entry). Azure BYOK is the same: the gateway's fee on those requests is nothing. ```sh curl -s https://api.conifer.build/v1/models \ -H "Authorization: Bearer $CONIFER_API_KEY" \ | jq '.data[] | {id, caps, context_window, pricing}' ``` ## When models diverge Do not treat every catalog id as GPT-4o-with-tools. Sandbox each feature: - Tools: require `caps` to include `tools` before sending `tools`. Absence is a 400 that names the problem. `tool_calls` missing on a 200 is not an error. - Reasoning traces: read `reasoning` / `reasoning_content` only when the field exists. An omitted field means this model produced no trace. - Cache / thinking tokens: read usage fields that exist. Chat `prompt_tokens_details` may be omitted. Do not invent a thinking-token count. Messages-wire cache fields are listed below. - Switching models mid-conversation changes caps. Recheck the new id. - 402 is billing or the spend ceiling. 429 is rate limit. Neither is a missing capability. ## Reasoning traces Chat completions (`POST /v1/chat/completions`): read `choices[0].message.reasoning` (OpenRouter / OpenAI-compat) when present. Also `choices[0].message.reasoning_content` when upstream used DeepSeek's name. Both stream on `choices[0].delta`. Not every model emits either field. Optional request knobs: `reasoning` as `{effort|max_tokens}`, or `reasoning_effort` as `high|medium|low|minimal|none`. ```sh curl https://api.conifer.build/v1/chat/completions \ -H "Authorization: Bearer $CONIFER_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-haiku-4-5", "max_tokens": 1024, "reasoning": {"effort": "medium"}, "messages": [{"role": "user", "content": "three names for a build cache"}] }' ``` TypeScript — the official client does not type these fields. Read them, then test presence: ```ts const res = await client.chat.completions.create({ model: "claude-haiku-4-5", max_tokens: 1024, reasoning: { effort: "medium" }, messages: [{ role: "user", content: "three names for a build cache" }], }); const msg = res.choices[0].message; const trace = msg.reasoning ?? msg.reasoning_content; if (trace) console.log(trace); ``` ```py res = client.chat.completions.create( model="claude-haiku-4-5", max_tokens=1024, extra_body={"reasoning": {"effort": "medium"}}, messages=[{"role": "user", "content": "three names for a build cache"}], ) msg = res.choices[0].message trace = getattr(msg, "reasoning", None) or getattr(msg, "reasoning_content", None) if trace: print(trace) ``` Anthropic Messages (`POST /v1/messages`): a thinking block is a content item ```json { "type": "thinking", "thinking": "the model scratch work", "signature": "…" } ``` ## Cache information Chat completions (`POST /v1/chat/completions`): when `usage.prompt_tokens_details` is present, read `cached_tokens` and `cache_write_tokens` (0 if none). The details object itself may be omitted. Do not assume a thinking-token count on usage unless that field is on the body. ```json { "prompt_tokens_details": { "cached_tokens": 80, "cache_write_tokens": 0 } } ``` ```ts const details = res.usage?.prompt_tokens_details; console.log(details?.cached_tokens); console.log(details?.cache_write_tokens); ``` ```py details = getattr(getattr(res, "usage", None), "prompt_tokens_details", None) if details is not None: print(getattr(details, "cached_tokens", None)) print(getattr(details, "cache_write_tokens", None)) ``` Prompt cache directives ride `/v1/messages`. Send `cache_control` breakpoints; they pass through unchanged. ```sh curl https://api.conifer.build/v1/messages \ -H "x-api-key: $CONIFER_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-haiku-4-5", "max_tokens": 1024, "system": [{ "type": "text", "text": "You are a build-cache namer. Keep answers to three words.", "cache_control": {"type": "ephemeral"} }], "messages": [{"role": "user", "content": "three names for a build cache"}] }' ``` Read cache from `response.usage`: ```json { "input_tokens": 120, "cache_creation_input_tokens": 80, "cache_read_input_tokens": 0, "output_tokens": 24 } ``` `cache_read_input_tokens` and `cache_creation_input_tokens` are the `/v1/messages` usage fields. ## Bring your own Azure OpenAI The gateway can route through your own Azure OpenAI resource, so usage is billed to your Azure credits while your callers keep the one Conifer wire. Azure is not a normal OpenAI key. PUT /v1/keys/azure with `{"api_key":"...","base_url":"https://{resource}.openai.azure.com/openai/v1"}`. No `deployments` field (retired). A 422 names `azure` if the row fails; a 404 means no row. The upstream header is `api-key`. Setup is two values, self-serve: your Azure API key and your resource's v1 surface (https://{resource}.openai.azure.com/openai/v1). Add them under Azure in https://conifer.build/console#/keys (signed-in users can add, see, and remove the endpoint there), or with the CLI: `printf %s "$AZURE_KEY" | conifer keys add azure --url https://{resource}.openai.azure.com/openai/v1`. That is gateway custody: callers keep using CONIFER_API_KEY. To register Azure as a local CLI endpoint instead (`conifer run` hits the resource directly): `printf %s "$AZURE_KEY" | conifer endpoints add byok azure --url https://{resource}.openai.azure.com/openai/v1`. The key is verified live against your resource at paste time and stored sealed. Plain API keys on a publicly reachable resource are the supported path today; Entra ID / managed identity is built on the gateway but not yet offered on this flow. Then set `model` to the Azure deployment name, or `azure/`, on /v1/chat/completions — Azure resolves them; the gateway maps nothing and its fee on these requests is nothing (the same 0% take-rate as other BYOK). Your deployments are not listed in GET /v1/models (they are yours, not catalog entries), and a name your resource does not serve returns Azure's own error. One rule: a deployment named exactly like a model the gateway itself serves (e.g. gpt-4o) routes to the catalog, not your resource — give deployments distinct names. Requests ride the api-key header and the current OpenAI dialect; streaming on Azure routes is buffered today. ## Deployed client-side (browser / app) use Direct browser and webview calls work. CORS is granted on the gateway, including the `x-api-key`, `anthropic-version`, and `anthropic-beta` headers, so a client-side fetch to the endpoints above succeeds without a proxy. Rules to follow before shipping a key to a client: - A key embedded in client code is visible to every end user. Assume it will be extracted. - A minted key currently grants your full model catalog for up to 90 days — the console has no scope-narrowing or expiry knob yet. Plan around revocation, not scoping. - If the key leaks, revoke it at https://conifer.build/console#/keys. Revocation reaches the gateway in seconds. - Because of the two points above: for anything with real spend attached, prefer a thin server proxy that holds the key and forwards the same wire. The client speaks the identical protocol to your proxy; only the base URL differs. Minimal proxy (Next.js route handler, app/api/chat/route.ts): ```ts export async function POST(req: Request) { const upstream = await fetch("https://api.conifer.build/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${process.env.CONIFER_API_KEY}`, "Content-Type": "application/json", }, body: await req.text(), }); return new Response(upstream.body, { status: upstream.status, headers: { "Content-Type": upstream.headers.get("Content-Type") ?? "application/json" }, }); } ``` Point the client's base URL at your app's /api and keep CONIFER_API_KEY server-side only. ## What is not served As of 2026-08-22: - /v1/embeddings — not served yet; shipping shortly. Keep your provider key for embeddings until then; the two coexist fine. - /v1/completions (legacy) — served, buffered JSON only; no streaming (the SSE wire is /v1/chat/completions). - /v1/moderations — not served. - /v1/messages serves Anthropic model ids only. A non-Anthropic id there is a 400 (`wire_upstream_mismatch`). Every other model is on /v1/chat/completions. ## Errors OpenAI-compat (`/v1/chat/completions`, `/v1/completions`, `/v1/responses`): - 401: type `invalid_request_error`, code `invalid_api_key`, message "Incorrect API key provided", header `WWW-Authenticate: Bearer`. All auth failures are this one 401. Missing, malformed, expired, and revoked look the same. - 402 billing: stays 402. type `insufficient_allowance`, additive code `insufficient_quota`. Not remapped to 429. - 402 spend cap: stays 402. type `cost_ceiling_exceeded`. - 429: type `rate_limit_error`, code `rate_limit_exceeded`, header `Retry-After: 1`. No invented `x-ratelimit-*` remaining-quota figures. - 404: code `model_not_found`, param `model`. The body does not echo the id; when your key can call something close, `error.suggestions` lists up to three ids. See GET /v1/models with your key. - context length: type `invalid_request_error`, code `context_length_exceeded`. - Other 400s: the body names the problem, including a non-Anthropic id on /v1/messages (`wire_upstream_mismatch`), tools on a model whose caps omit them, or a modality the model does not declare. Capability refusals stay 400; they are not remapped to 402 or 429. - Stream in-band error object matches the buffered envelope. - `request-id` and `x-request-id` echo the same value as `x-conifer-request-id`. ```json { "error": { "message": "Incorrect API key provided", "type": "invalid_request_error", "code": "invalid_api_key" } } ``` ```json { "error": { "type": "insufficient_allowance", "code": "insufficient_quota", "docs_url": "https://conifer.build/console#/billing" } } ``` ```json { "error": { "type": "cost_ceiling_exceeded" } } ``` ```json { "error": { "type": "rate_limit_error", "code": "rate_limit_exceeded" } } ``` ```json { "error": { "code": "model_not_found", "param": "model" } } ``` ```json { "error": { "type": "invalid_request_error", "code": "context_length_exceeded" } } ``` Anthropic `/v1/messages` types were already industry-shaped and stay that way. Product honesty: all auth failures are one 401; 402 is not remapped to 429; upstream identity is not leaked (no OpenRouter provider metadata); no fake remaining-quota headers.