Skip to content

Extending with Adapters ​

An adapter is the bridge between CodeCompanion and an LLM's API. It describes the endpoint, the headers and parameters to send, and how to turn CodeCompanion's messages into a request and the LLM's response back into text, reasoning and tool calls. The built-in adapters live in the adapters directory.

TIP

If your LLM is "OpenAI compatible", extend the openai_legacy adapter or use openai_compatible instead of writing one from scratch. The xAI adapter does this

The Interface ​

An HTTP adapter is a table with the following fields (abridged from lua/codecompanion/adapters/http/init.lua):

lua
---@class CodeCompanion.HTTPAdapter
---@field name string The name of the adapter e.g. "openai"
---@field formatted_name string The formatted name of the adapter e.g. "OpenAI"
---@field roles table The mapping of roles in the config to the LLM's defined roles
---@field features table The features that the adapter supports
---@field url string The URL of the LLM to connect to
---@field env? table Environment variables which can be referenced in the parameters
---@field env_replaced? table Replacement of environment variables with their actual values
---@field headers table The headers to pass to the request
---@field parameters table The parameters to pass to the request
---@field body table Additional body parameters to pass to the request
---@field raw? table Any additional curl arguments to pass to the request
---@field opts? table Additional options for the adapter
---@field handlers CodeCompanion.HTTPAdapter.Handlers Functions which link the output from the request to CodeCompanion
---@field schema table Set of parameters for the LLM that the user can customise in the chat buffer

The fields up to handlers describe the API and are passed to curl. The handlers do the real work, translating between CodeCompanion and the LLM.

roles maps CodeCompanion's roles to the LLM's. Messages reach your handlers with their roles already mapped:

lua
roles = {
  llm = "assistant",
  user = "user",
  tool = "tool",
},

opts holds flags such as stream, tools, vision and documents. features.tokens = true is required for the parse_tokens handler to be called.

Environment Variables ​

APIs need values injected into different parts of the request. Azure OpenAI puts the endpoint, deployment and API version in the URL, whilst OpenAI takes the API key in an Authorization header. Declare them in env and reference them with ${}:

lua
url = "${endpoint}/openai/deployments/${deployment}/chat/completions?api-version=${api_version}",
env = {
  api_key = "AZURE_OPENAI_API_KEY",
  endpoint = "AZURE_OPENAI_ENDPOINT",
  api_version = "2024-06-01",
  deployment = "schema.model.default",
},
headers = {
  ["Content-Type"] = "application/json",
  ["api-key"] = "${api_key}",
},

Variables are replaced in the url, headers, parameters and raw fields on every request. Each value is resolved in this order:

ValueExampleResolves to
cmd: prefix"cmd:op read op://personal/Gemini_API/credential --no-newline"The command's output, with trailing whitespace removed
file: prefix"file:~/.secrets/gemini"The file's contents, with trailing whitespace removed
Environment variable"GEMINI_API_KEY"The variable's value, if it's set
Functionfunction(self) return os.getenv("GEMINI_API_KEY") endThe function's return value, called with the adapter
Schema path"schema.model.default"The value at that path on the adapter
Anything else"2024-06-01"The string, unchanged

Commands time out after 20 seconds. This can be changed with:

lua
require("codecompanion").setup({
  adapters = {
    opts = {
      cmd_timeout = 20000, -- milliseconds
    },
  },
})

WARNING

A schema path returns the raw value. If schema.model.default is a function, use a function in env instead

Handlers ​

Handlers are grouped into four tables. Each one takes self and a single args table, so new fields can be added without breaking your adapter:

lua
handlers = {
  lifecycle = {
    setup = function(self) end,
    on_exit = function(self, args) end,
    teardown = function(self) end,
  },
  request = {
    build_parameters = function(self, args) end,
    build_messages = function(self, args) end,
    build_tools = function(self, args) end,
    build_structured_output = function(self, args) end,
    build_reasoning = function(self, args) end,
    build_body = function(self, args) end,
  },
  response = {
    parse_chat = function(self, args) end,
    parse_inline = function(self, args) end,
    parse_tokens = function(self, args) end,
    parse_meta = function(self, args) end,
  },
  tools = {
    format_calls = function(self, args) end,
    format_response = function(self, args) end,
  },
}

All of them are optional. Every built-in adapter has its own tests, which show how each one handles the API's output.

Lifecycle ​

HandlerargsDescription
setupRun before the request is sent and before environment variables are resolved. Return false to cancel the request
on_exitdataRun when the request completes, or with no data when the user stops it
teardownRun last, after on_exit

Request ​

Except for build_reasoning, their return values are merged into the request body:

HandlerargsDescription
build_parametersparams, messagesSet the request's parameters
build_messagesmessagesFormat the messages for the LLM
build_toolstoolsConvert CodeCompanion's tool schemas into the LLM's format
build_structured_outputschemaConvert a structured output schema into the LLM's format
build_reasoningdataCombine the streamed reasoning chunks into one, to store on the LLM's message
build_bodypayloadAdd anything else to the body

Response ​

HandlerargsDescription
parse_chatdata, toolsParse a response for the chat buffer, writing any tool calls into tools
parse_inlinedata, contextParse a response for the inline interaction
parse_tokensdataReturn the token count from a response
parse_metadataProcess non-standard fields that parse_chat returns in extra

Tools ​

HandlerargsDescription
format_callstoolsFormat the LLM's tool calls for the next request
format_responsetool_call, outputFormat a tool's output as a message for the LLM

Building the Handlers ​

The examples below build the main handlers for OpenAI's Chat Completions API, as in the openai_legacy adapter. Put this at the top of your adapter:

lua
local adapter_utils = require("codecompanion.adapters.utils")
local log = require("codecompanion.utils.log")

build_messages ​

OpenAI expects a messages array of role and content:

sh
curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "messages": [
      {
        "role": "user",
        "content": "Explain Ruby in two words"
      }
    ]
  }'

CodeCompanion passes the chat buffer's messages in args.messages, each with at least a role and content:

lua
{
  {
    role = "user",
    content = "Explain Ruby in two words",
  },
}

That's already the right shape, so the handler returns them as they are:

lua
handlers = {
  request = {
    build_messages = function(self, args)
      return { messages = args.messages }
    end,
  },
}

build_parameters ​

OpenAI needs no extra parameters, so this returns them unchanged:

lua
handlers = {
  request = {
    build_parameters = function(self, args)
      return args.params
    end,
  },
}

parse_chat ​

A streamed response arrives in chunks, and CodeCompanion calls parse_chat with each one in args.data:

txt
data: {"id":"chatcmpl-90DdmqMKOKpqFemxX0OhTVdH042gu","object":"chat.completion.chunk","created":1709839462,"model":"gpt-4.1","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"chatcmpl-90DdmqMKOKpqFemxX0OhTVdH042gu","object":"chat.completion.chunk","created":1709839462,"model":"gpt-4.1","choices":[{"index":0,"delta":{"content":"Programming"},"finish_reason":null}]}

data: [DONE]

Each chunk is prefixed with data: , so it isn't valid JSON. adapter_utils.clean_streamed_data strips the prefix, and luanil = { object = true } keeps null values as nil:

lua
handlers = {
  response = {
    parse_chat = function(self, args)
      if not args.data or args.data == "" then
        return nil
      end

      local data = adapter_utils.clean_streamed_data(args.data)
      local ok, json = pcall(vim.json.decode, data, { luanil = { object = true } })
      if not ok or not json.choices or #json.choices == 0 then
        return nil
      end

      local delta = json.choices[1].delta
      return {
        status = "success",
        output = {
          role = delta.role,
          content = delta.content,
        },
      }
    end,
  },
}

parse_chat must return a table with status and output. Return nil for anything it can't parse, such as [DONE] or an error, and leave errors to on_exit.

To show reasoning, add output.reasoning = { content = "..." }.

parse_meta ​

Some OpenAI-compatible providers, such as DeepSeek and OpenRouter, add non-standard fields to the message or delta object. The OpenAI-based adapters return these in an extra table from parse_chat, and parse_meta is called whenever extra is present.

DeepSeek streams its reasoning in delta.reasoning_content. To move it into the reasoning output:

lua
handlers = {
  response = {
    parse_meta = function(self, args)
      local data = args.data
      if data.extra.reasoning_content then
        data.output.reasoning = { content = data.extra.reasoning_content }
        -- An empty string is treated as a normal response
        if data.output.content == "" then
          data.output.content = nil
        end
      end
      return data
    end,
  },
}

Always return data. Setting output.content to nil is only needed when streaming and can cause problems without it.

parse_inline ​

The inline interaction doesn't stream, so args.data is the whole response, with the JSON in args.data.body. Return the text to write into the buffer:

lua
handlers = {
  response = {
    parse_inline = function(self, args)
      if not args.data or args.data == "" then
        return nil
      end

      local ok, json = pcall(vim.json.decode, args.data.body, { luanil = { object = true } })
      if not ok then
        return { status = "error", output = json }
      end

      return { status = "success", output = json.choices[1].message.content }
    end,
  },
}

args.context holds details of the buffer that started the request.

on_exit ​

Errors from a streaming endpoint are hard to parse, as they arrive across several data: lines. on_exit receives the final response instead:

lua
{
  body = '{\n    "error": {\n        "message": "Incorrect API key provided: sk-F18b****XdwS.",\n        "type": "invalid_request_error",\n        "param": null,\n        "code": "invalid_api_key"\n    }\n}',
  exit = 0,
  headers = { "date: Thu, 03 Oct 2024 08:05:32 GMT" },
  status = 401,
}

That's easier to work with:

lua
handlers = {
  lifecycle = {
    on_exit = function(self, args)
      if args.data and args.data.status >= 400 then
        log:error("Error: %s", args.data.body)
      end
    end,
  },
}

log:error writes to the log file and notifies the user.

setup and teardown ​

setup runs before environment variables are resolved, which is why the Copilot adapter uses it to fetch a token. It runs on a copy of the adapter made for each request, so changes don't carry over to the next one:

lua
handlers = {
  lifecycle = {
    setup = function(self)
      if self.opts.stream then
        self.parameters.stream = true
      end
      return true
    end,
    teardown = function(self) end,
  },
}

Utilities ​

Many "OpenAI compatible" endpoints have quirks:

  • System messages must come first (anthropic, deepseek)
  • System messages must be a single message (anthropic, deepseek)
  • Messages must alternate between user and LLM (deepseek)

The adapter utilities, such as merge_system_messages and merge_messages, handle these. The built-in adapters are the best reference for how to use them.

Tools ​

To support function calling, set opts.tools = true and have parse_chat write tool calls into args.tools. Then add:

  • build_tools - converts CodeCompanion's tool schemas into the LLM's format
  • format_calls - formats the LLM's tool calls for the next request
  • format_response - formats a tool's output as a message for the chat buffer's message history
lua
handlers = {
  request = {
    build_tools = function(self, args)
      if not self.opts.tools or not args.tools then
        return nil
      end

      local transformed = {}
      for _, tool in pairs(args.tools) do
        for _, schema in pairs(tool) do
          table.insert(transformed, schema)
        end
      end
      return { tools = transformed }
    end,
  },
  tools = {
    format_calls = function(self, args)
      return args.tools
    end,
    format_response = function(self, args)
      return {
        role = self.roles.tool or "tool",
        tools = {
          call_id = adapter_utils.pairing_id(args.tool_call),
          name = args.tool_call["function"].name,
        },
        content = args.output,
        opts = { visible = false },
      }
    end,
  },
}

Many LLMs claim to follow OpenAI's function calling standard but still need adjustments to work.

Schema ​

The schema table describes the LLM's settings. With display.chat.show_settings = true, they appear at the top of the chat buffer for the user to edit.

From the openai_legacy adapter:

lua
schema = {
  model = {
    order = 1,
    mapping = "parameters",
    type = "enum",
    desc = "ID of the model to use. See the model endpoint compatibility table for details on which models work with the Chat API.",
    default = "gpt-4.1",
    choices = {
      ["gpt-4.1"] = {
        formatted_name = "GPT 4.1",
        meta = { context_window = 1047576 },
        opts = { has_vision = true, can_form_structured_outputs = true },
      },
      ["o3-mini-2025-01-31"] = {
        formatted_name = "o3 Mini",
        opts = { can_reason = true, can_form_structured_outputs = true },
      },
      "gpt-4",
      "gpt-3.5-turbo",
    },
  },
},
KeyDescription
orderPosition in the chat buffer's settings
mappingWhere in the adapter the value goes, such as parameters
typeOne of string, number, integer, boolean, enum, list or map
descShown as virtual text when the cursor is on the setting
defaultThe default value, or a function that receives the adapter
choicesThe allowed values for an enum, or a function that returns them
optionalAllow the value to be nil
enabledA function that receives the adapter and returns whether the setting applies
validateA function that returns whether the value is valid, and an error message if not

A choice can be a plain string, or a table with a formatted_name, meta and opts for your handlers to read at runtime.

A setting can depend on the model:

lua
temperature = {
  order = 2,
  mapping = "parameters",
  type = "number",
  default = 0,
  enabled = function(self)
    local model = require("codecompanion.adapters.utils").resolve_model(self)
    return model ~= nil and not vim.startswith(model, "o1")
  end,
  validate = function(n)
    return n >= 0 and n <= 2, "Must be between 0 and 2"
  end,
  desc = "What sampling temperature to use, between 0 and 2.",
},

o1 models don't accept a temperature, so enabled leaves it out of their requests.

IMPORTANT

schema.model.default can be a function. Use resolve_model from codecompanion.adapters.utils to read it as a string

Some APIs, such as OpenAI's Responses API, nest parameters:

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "o3-mini",
    "input": "How much wood would a woodchuck chuck?",
    "reasoning": {
      "effort": "high"
    }
  }'

Use dot notation in the key to nest the value:

lua
["reasoning.effort"] = {
  mapping = "parameters",
  type = "string",
  -- ...
},

Legacy Handlers ​

Adapters written with the older flat handlers still work, and their handlers still take positional arguments. CodeCompanion detects the format and maps the old names to the new ones. From v20.0.0, the new handlers take a single args table.

OldNew
setup, on_exit, teardownlifecycle.setup, lifecycle.on_exit, lifecycle.teardown
form_parameters, form_messages, form_toolsrequest.build_parameters, request.build_messages, request.build_tools
form_structured_output, form_reasoning, set_bodyrequest.build_structured_output, request.build_reasoning, request.build_body
chat_output, inline_output, tokensresponse.parse_chat, response.parse_inline, response.parse_tokens
parse_message_metaresponse.parse_meta
tools.format_tool_calls, tools.output_responsetools.format_calls, tools.format_response

An adapter is treated as the new format if it has a lifecycle, request or response table, so don't mix the two.

To migrate, move each handler into its group and read its arguments from args:

lua
handlers = {
  setup = function(self) end,
  form_parameters = function(self, params, messages) end,
  form_messages = function(self, messages) end,
  chat_output = function(self, data, tools) end,
  on_exit = function(self, data) end,
  teardown = function(self) end,
  tools = {
    format_tool_calls = function(self, tools) end,
    output_response = function(self, tool_call, output) end,
  },
}
lua
handlers = {
  lifecycle = {
    setup = function(self) end,
    on_exit = function(self, args) end,
    teardown = function(self) end,
  },
  request = {
    build_parameters = function(self, args) end,
    build_messages = function(self, args) end,
  },
  response = {
    parse_chat = function(self, args) end,
  },
  tools = {
    format_calls = function(self, args) end,
    format_response = function(self, args) end,
  },
}

Released under the Apache-2.0 License.