Skip to content

Instantly share code, notes, and snippets.

@ernie
Last active August 30, 2025 13:00
Show Gist options
  • Save ernie/e8f3a4bb2a01d3f449ec000605631eb8 to your computer and use it in GitHub Desktop.
Save ernie/e8f3a4bb2a01d3f449ec000605631eb8 to your computer and use it in GitHub Desktop.
CodeCompanion OpenRouter adapter with reasoning output
local openai = require('codecompanion.adapters.http.openai')
local log = require('codecompanion.utils.log')
local utils = require('codecompanion.utils.adapters')
local Curl = require('plenary.curl')
local config = require('codecompanion.config')
local _cache_expires
local _cache_file = vim.fn.tempname()
local _cached_models
---Remove any keys from the message that are not allowed by the API
---@param message table The message to filter
---@return table The filtered message
local function filter_message(message)
local allowed = {
'content',
'role',
'reasoning_details',
'tool_calls',
'tool_call_id',
}
for key, _ in pairs(message) do
if not vim.tbl_contains(allowed, key) then
message[key] = nil
end
end
return message
end
---Return a list as a set
---@param tbl table
---@return table
local function as_set(tbl)
local set = {}
for _, val in ipairs(tbl) do
set[val] = true
end
return set
end
---Return the cached models
---@return any
local function models()
return _cached_models
end
---Get a list of available OpenRouter models
---@param self CodeCompanion.HTTPAdapter
---@return any
local function get_models(self)
if _cached_models and _cache_expires and _cache_expires > os.time() then
return models()
end
_cached_models = {}
local adapter = require('codecompanion.adapters').resolve(self)
if not adapter then
log:error('Could not resolve OpenRouter adapter in the `get_models` function')
return {}
end
utils.get_env_vars(adapter)
local url = adapter.env_replaced.url
local models_endpoint = adapter.env_replaced.models_endpoint
local headers = {
['content-type'] = 'application/json',
}
if adapter.env_replaced.api_key then
headers['Authorization'] = 'Bearer ' .. adapter.env_replaced.api_key
end
local ok, response, json
ok, response = pcall(function()
return Curl.get(url .. models_endpoint, {
sync = true,
headers = headers,
insecure = config.adapters.http.opts.allow_insecure,
proxy = config.adapters.http.opts.proxy,
})
end)
if not ok then
log:error('Could not get the OpenAI compatible models from ' .. url .. models_endpoint .. '.\nError: %s', response)
return {}
end
ok, json = pcall(vim.json.decode, response.body)
if not ok then
log:error('Could not parse the response from ' .. url .. models_endpoint)
return {}
end
for _, model in ipairs(json.data) do
local params = as_set(model.supported_parameters or {})
local inputs = as_set((model.architecture or {}).input_modalities or {})
if params.tools then
_cached_models[model.id] =
{ opts = { stream = true, has_tools = true, has_vision = inputs.image, can_reason = params.reasoning } }
end
end
_cache_expires = utils.refresh_cache(_cache_file, config.adapters.http.opts.cache_models_for)
return models()
end
---@class CodeCompanion.HTTPAdapter.OpenRouter: CodeCompanion.HTTPAdapter
return {
name = 'openrouter',
formatted_name = 'OpenRouter',
roles = {
llm = 'assistant',
user = 'user',
tool = 'tool',
},
opts = {
stream = true,
tools = true,
vision = true,
},
features = {
text = true,
tokens = true,
},
url = '${url}${chat_url}',
env = {
api_key = 'OPENROUTER_API_KEY',
url = 'https://openrouter.ai/api',
chat_url = '/v1/chat/completions',
models_endpoint = '/v1/models',
},
headers = {
['Content-Type'] = 'application/json',
Authorization = 'Bearer ${api_key}',
},
handlers = {
---@param self CodeCompanion.HTTPAdapter
---@return boolean
setup = function(self)
return openai.handlers.setup(self)
end,
---Set the parameters
---@param self CodeCompanion.HTTPAdapter
---@param params table
---@param messages table
---@return table
form_parameters = function(self, params, messages)
return openai.handlers.form_parameters(self, params, messages)
end,
---Set the format of the role and content for the messages from the chat buffer
---@param self CodeCompanion.HTTPAdapter
---@param messages table Format is: { { role = "user", content = "Your prompt here" } }
---@return table
form_messages = function(self, messages)
local model = self.schema.model.default
if type(model) == 'function' then
model = model(self)
end
messages = vim.tbl_map(function(m)
if vim.startswith(model, 'o1') and m.role == 'system' then
m.role = self.roles.user
end
-- Ensure tool_calls are clean
if m.tool_calls then
m.tool_calls = vim
.iter(m.tool_calls)
:map(function(tool_call)
return {
id = tool_call.id,
['function'] = tool_call['function'],
type = tool_call.type,
}
end)
:totable()
end
-- Process any images
if m.opts and m.opts.tag == 'image' and m.opts.mimetype then
if self.opts and self.opts.vision then
m.content = {
{
type = 'image_url',
image_url = {
url = string.format('data:%s;base64,%s', m.opts.mimetype, m.content),
},
},
}
else
-- Remove the message if vision is not supported
return nil
end
end
-- Pull reasoning back out to a top-level message key
-- https://openrouter.ai/docs/use-cases/reasoning-tokens#example-preserving-reasoning-blocks-with-openrouter-and-claude
if m.reasoning and m.reasoning.details then
m.reasoning_details = m.reasoning.details
end
m = filter_message(m)
return m
end, messages)
return { messages = messages }
end,
---Form the reasoning output that is stored in the chat buffer
---@param self CodeCompanion.HTTPAdapter
---@param data table The reasoning output from the LLM
---@return nil|{ content: string, details: table }
form_reasoning = function(self, data)
local reasoning_details = {}
local content = vim
.iter(data)
:map(function(item)
if item.details and #item.details > 0 then
for _, rd in ipairs(item.details) do
local details = reasoning_details[rd.index + 1]
or {
id = rd.id,
index = rd.index,
format = rd.format,
type = rd.type,
}
if rd.text and rd.text ~= '' then
details.text = (details.text or '') .. rd.text
end
if rd.summary and rd.summary ~= '' then
details.summary = (details.summary or '') .. rd.summary
end
if rd.data and rd.data ~= '' then
details.data = (details.data or '') .. rd.data
end
reasoning_details[rd.index + 1] = details
end
end
return item.content
end)
:filter(function(content)
return content ~= nil
end)
:join('')
return {
content = content,
details = reasoning_details,
}
end,
---Provides the schemas of the tools that are available to the LLM to call
---@param self CodeCompanion.HTTPAdapter
---@param tools table<string, table>
---@return table|nil
form_tools = function(self, tools)
return openai.handlers.form_tools(self, tools)
end,
---Returns the number of tokens generated from the LLM
---@param self CodeCompanion.HTTPAdapter
---@param data table The data from the LLM
---@return number|nil
tokens = function(self, data)
return openai.handlers.tokens(self, data)
end,
---Output the data from the API ready for insertion into the chat buffer
---@param self CodeCompanion.HTTPAdapter
---@param data table The streamed JSON data from the API, also formatted by the format_data handler
---@param tools? table The table to write any tool output to
---@return table|nil [status: string, output: table]
chat_output = function(self, data, tools)
if not data or data == '' then
return nil
end
-- Handle both streamed data and structured response
local data_mod = type(data) == 'table' and data.body or utils.clean_streamed_data(data)
local ok, json = pcall(vim.json.decode, data_mod, { luanil = { object = true } })
if not ok or not json.choices or #json.choices == 0 then
return nil
end
-- Process tool calls from all choices
if self.opts.tools and tools then
for _, choice in ipairs(json.choices) do
local delta = self.opts.stream and choice.delta or choice.message
if delta and delta.tool_calls and #delta.tool_calls > 0 then
for i, tool in ipairs(delta.tool_calls) do
local tool_index = tool.index and tonumber(tool.index) or i
-- Some endpoints like Gemini do not set this (why?!)
local id = tool.id
if not id or id == '' then
id = string.format('call_%s_%s', json.created, i)
end
if self.opts.stream then
local found = false
for _, existing_tool in ipairs(tools) do
if existing_tool._index == tool_index then
-- Append to arguments if this is a continuation of a stream
if tool['function'] and tool['function']['arguments'] then
existing_tool['function']['arguments'] = (existing_tool['function']['arguments'] or '')
.. tool['function']['arguments']
end
found = true
break
end
end
if not found then
table.insert(tools, {
_index = tool_index,
id = id,
type = tool.type,
['function'] = {
name = tool['function']['name'],
arguments = tool['function']['arguments'] or '',
},
})
end
else
table.insert(tools, {
_index = i,
id = id,
type = tool.type,
['function'] = {
name = tool['function']['name'],
arguments = tool['function']['arguments'],
},
})
end
end
end
end
end
-- Process message content from the first choice
local choice = json.choices[1]
local delta = self.opts.stream and choice.delta or choice.message
if not delta then
return nil
end
local result = { status = 'success', output = { role = delta.role } }
if delta.content and delta.content ~= '' then
result.output.content = delta.content
end
if delta.reasoning and delta.reasoning ~= '' then
result.output.reasoning = { content = delta.reasoning }
end
if delta.reasoning_details and #delta.reasoning_details > 0 then
-- We have to stash these here because Chat:add_message doesn't
-- care about a toplevel reasoning_details key but it does carry
-- over what's in reasoning. We put this in its rightful place
-- in form_messages.
result.output.reasoning = (result.output.reasoning or {})
result.output.reasoning.details = delta.reasoning_details
end
return result
end,
---Output the data from the API ready for inlining into the current buffer
---@param self CodeCompanion.HTTPAdapter
---@param data string|table The streamed JSON data from the API, also formatted by the format_data handler
---@param context? table Useful context about the buffer to inline to
---@return {status: string, output: table}|nil
inline_output = function(self, data, context)
return openai.handlers.inline_output(self, data, context)
end,
tools = {
---Format the LLM's tool calls for inclusion back in the request
---@param self CodeCompanion.HTTPAdapter
---@param tools table The raw tools collected by chat_output
---@return table
format_tool_calls = function(self, tools)
return openai.handlers.tools.format_tool_calls(self, tools)
end,
---Output the LLM's tool call so we can include it in the messages
---@param self CodeCompanion.HTTPAdapter
---@param tool_call {id: string, function: table, name: string}
---@param output string
---@return table
output_response = function(self, tool_call, output)
return openai.handlers.tools.output_response(self, tool_call, output)
end,
},
---Function to run when the request has completed. Useful to catch errors
---@param self CodeCompanion.HTTPAdapter
---@param data? table
---@return nil
on_exit = function(self, data)
return openai.handlers.on_exit(self, data)
end,
},
schema = {
---@type CodeCompanion.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.',
---@type string|fun(arg: CodeCompanion.HTTPAdapter): string
default = 'x-ai/grok-code-fast-1',
---@type string|fun(arg: CodeCompanion.HTTPAdapter): table
choices = function(self)
return get_models(self)
end,
},
reasoning_effort = {
order = 2,
mapping = 'parameters',
type = 'string',
optional = true,
condition = function(self)
local model = self.schema.model.default
if type(model) == 'function' then
model = model()
end
local choices = self.schema.model.choices
if type(choices) == 'function' then
choices = choices(self)
end
if choices and choices[model] and choices[model].opts and choices[model].opts.can_reason then
return true
end
return false
end,
default = 'medium',
desc = 'Constrains effort on reasoning for reasoning models. Reducing reasoning effort can result in faster responses and fewer tokens used on reasoning in a response.',
choices = {
'high',
'medium',
'low',
},
},
temperature = {
order = 3,
mapping = 'parameters',
type = 'number',
optional = true,
default = 1,
desc = 'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or top_p but not both.',
validate = function(n)
return n >= 0 and n <= 2, 'Must be between 0 and 2'
end,
},
top_p = {
order = 4,
mapping = 'parameters',
type = 'number',
optional = true,
default = 1,
desc = 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.',
validate = function(n)
return n >= 0 and n <= 1, 'Must be between 0 and 1'
end,
},
stop = {
order = 5,
mapping = 'parameters',
type = 'list',
optional = true,
default = nil,
subtype = {
type = 'string',
},
desc = 'Up to 4 sequences where the API will stop generating further tokens.',
validate = function(l)
return #l >= 1 and #l <= 4, 'Must have between 1 and 4 elements'
end,
},
max_tokens = {
order = 6,
mapping = 'parameters',
type = 'integer',
optional = true,
default = nil,
desc = "The maximum number of tokens to generate in the chat completion. The total length of input tokens and generated tokens is limited by the model's context length.",
validate = function(n)
return n > 0, 'Must be greater than 0'
end,
},
presence_penalty = {
order = 7,
mapping = 'parameters',
type = 'number',
optional = true,
default = 0,
desc = "Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics.",
validate = function(n)
return n >= -2 and n <= 2, 'Must be between -2 and 2'
end,
},
frequency_penalty = {
order = 8,
mapping = 'parameters',
type = 'number',
optional = true,
default = 0,
desc = "Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim.",
validate = function(n)
return n >= -2 and n <= 2, 'Must be between -2 and 2'
end,
},
logit_bias = {
order = 9,
mapping = 'parameters',
type = 'map',
optional = true,
default = nil,
desc = 'Modify the likelihood of specified tokens appearing in the completion. Maps tokens (specified by their token ID) to an associated bias value from -100 to 100. Use https://platform.openai.com/tokenizer to find token IDs.',
subtype_key = {
type = 'integer',
},
subtype = {
type = 'integer',
validate = function(n)
return n >= -100 and n <= 100, 'Must be between -100 and 100'
end,
},
},
user = {
order = 10,
mapping = 'parameters',
type = 'string',
optional = true,
default = nil,
desc = 'A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. Learn more.',
validate = function(u)
return u:len() < 100, 'Cannot be longer than 100 characters'
end,
},
},
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment