Connecting an OpenAI-Compatible Provider
Many providers, self-hosted servers and company gateways accept the same requests as OpenAI's Chat Completions API. CodeCompanion's openai_compatible adapter speaks that API, so you only need to give it a URL, a key and a name.
Adding the Adapter
Extend openai_compatible under a name of your own. The name is what you'll pick in the chat buffer and set in your config:
require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
formatted_name = "Acme Gateway",
env = {
url = "https://llm-gateway.acme.internal",
api_key = "ACME_GATEWAY_KEY",
},
schema = {
model = {
default = "gpt-4.1",
},
},
})
end,
},
},
})require("codecompanion").setup({
adapters = {
http = {
lm_studio = function()
return require("codecompanion.adapters").extend("openai_compatible", {
formatted_name = "LM Studio",
env = {
url = "http://localhost:1234",
api_key = "lm-studio",
},
})
end,
},
},
})require("codecompanion").setup({
adapters = {
http = {
vllm = function()
return require("codecompanion.adapters").extend("openai_compatible", {
formatted_name = "vLLM",
env = {
url = "http://gpu-box.local:8000",
api_key = "VLLM_API_KEY",
},
})
end,
},
},
})The adapter builds its URLs from four env keys:
| Key | Default | Purpose |
|---|---|---|
url | http://localhost:11434 | The server's base URL, without /v1 |
chat_url | /v1/chat/completions | Appended to url for every chat and inline request |
models_endpoint | /v1/models | Appended to url to list the available models |
api_key | OPENAI_API_KEY | Sent as Authorization: Bearer <key> |
If a provider gives you a base URL that ends in /v1, such as https://api.tsubasa.sh/v1, drop the /v1 from url. The default chat_url and models_endpoint already include it.
formatted_name is what appears in the chat buffer's header and the debug window. Without it, every adapter built on openai_compatible is labelled "OpenAI Compatible".
Choosing the Model
If you don't set a model, the adapter asks models_endpoint for the list of models and uses the first one it returns. The list is cached for 30 minutes, which you can change with adapters.http.opts.cache_models_for (seconds).
Some providers don't serve a model list. If yours returns a 404, or lists models you can't use, set both the default and the choices yourself so the endpoint is never called:
require("codecompanion").setup({
adapters = {
http = {
tsubasa = function()
return require("codecompanion.adapters").extend("openai_compatible", {
formatted_name = "Tsubasa",
env = {
url = "https://api.tsubasa.sh",
api_key = "TSUBASA_API_KEY",
},
schema = {
model = {
default = "tsubasa-fast",
choices = { "tsubasa-fast", "tsubasa-pro" },
},
},
})
end,
},
},
})Setting only default picks the model, but the endpoint is still called when you open the model picker.
Keeping the API Key Out of Your Config
api_key is resolved when a request is sent, so the key itself never needs to be in your config. Give it the name of an environment variable, a command or a file:
require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://llm-gateway.acme.internal",
api_key = "ACME_GATEWAY_KEY",
},
})
end,
},
},
})require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://llm-gateway.acme.internal",
api_key = "cmd:op read op://work/Acme Gateway/credential --no-newline",
},
})
end,
},
},
})require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://llm-gateway.acme.internal",
api_key = "file:~/.config/acme/gateway_key",
},
})
end,
},
},
})A value that isn't an environment variable, a cmd: or a file: is sent as it is. That's why api_key = "lm-studio" works for a local server that ignores the key. Commands time out after 20 seconds, which you can change with adapters.opts.cmd_timeout (milliseconds). See environment variables for every form env accepts.
IMPORTANT
If you don't set api_key, the adapter sends the value of OPENAI_API_KEY. Set it to something else so your OpenAI key isn't sent to another provider
Using It in Chat and Inline
To make your adapter the default:
require("codecompanion").setup({
interactions = {
chat = {
adapter = "acme_gateway",
},
inline = {
adapter = {
name = "acme_gateway",
model = "gpt-4.1-mini",
},
},
},
})Every adapter you define under adapters.http is listed when you press ga in the chat buffer, alongside the presets. To show only your own, set adapters.http.opts.show_presets = false. See Configuring HTTP Adapters.
When the Provider Differs
Extra Headers
headers are merged with the adapter's own, so Content-Type and Authorization stay in place. Headers can reference any env key with ${key}:
require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://llm-gateway.acme.internal",
api_key = "ACME_GATEWAY_KEY",
team_id = "ACME_TEAM_ID",
},
headers = {
["X-Team-Id"] = "${team_id}",
},
})
end,
},
},
})A Different Endpoint Path
Gateways often mount the API under a prefix. Change chat_url and models_endpoint to match:
require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://api.acme.com",
api_key = "ACME_GATEWAY_KEY",
chat_url = "/llm/openai/v1/chat/completions",
models_endpoint = "/llm/openai/v1/models",
},
})
end,
},
},
})No Streaming, Tools or Images
The adapter assumes the server supports streaming, tool calls and images. Turn off whatever yours doesn't:
require("codecompanion").setup({
adapters = {
http = {
acme_gateway = function()
return require("codecompanion.adapters").extend("openai_compatible", {
env = {
url = "https://llm-gateway.acme.internal",
api_key = "ACME_GATEWAY_KEY",
},
opts = {
stream = false,
tools = false,
vision = false,
},
})
end,
},
},
})With tools = false, tool definitions are left out of the request, so the LLM never sees @{files} or any other tool. With vision = false, the image slash command is disabled.
The Responses API
If your provider or gateway serves OpenAI's Responses API at /v1/responses, extend openai_responses instead. CodeCompanion uses it to carry a model's reasoning between turns and for server-side compaction. It has no env.url, so set the full url:
require("codecompanion").setup({
adapters = {
http = {
acme_responses = function()
return require("codecompanion.adapters").extend("openai_responses", {
formatted_name = "Acme Gateway (Responses)",
url = "https://llm-gateway.acme.internal/v1/responses",
env = {
api_key = "ACME_GATEWAY_KEY",
},
})
end,
},
},
})When You Need a Custom Adapter
"OpenAI-compatible" sometimes only means close. The HAWKI API, for example, expects the model and messages wrapped in a payload object, and rejects the request with a 422 until they are. When a provider changes the shape of the request or the response, you need your own handlers. See Extending with Adapters.
To see exactly what's being sent, set opts.log_level = "DEBUG" and send a message. The log records the path of each request body, and at that level the file is kept on disk. :checkhealth codecompanion shows where the log file is.
Limitations
- Every adapter built on
openai_compatibleshares one model cache. If you define two, the second can be offered the first server's models until the cache expires, so setschema.model.defaultandchoiceson each optsapply to the whole adapter, not per model. To mix models that do and don't support tools on one server, define an adapter for each
See Choosing an Adapter to compare this with the built-in adapters, or Running Local Models for Ollama.