Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,7 @@ Add custom AI providers:
get_models?(headers: table): table<CopilotChat.Provider.model>,

-- Optional: Resolve a user-facing model id to a provider model id
resolve_model?(headers: table, model: string): string,
resolve_model?(headers: table, model: string): string, table<string, string>,
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
}
```

Expand Down
8 changes: 7 additions & 1 deletion lua/CopilotChat/client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -319,9 +319,11 @@ function Client:ask(opts)
error('Provider not found: ' .. provider_name)
end

local resolved_headers = nil
if provider.resolve_model then
local headers = self:authenticate(provider_name)
local resolved_model = provider.resolve_model(headers, opts.model)
local resolved_model, extra_headers = provider.resolve_model(headers, opts.model)
resolved_headers = extra_headers
opts.model = resolved_model
model_config = models[opts.model]
if not model_config then
Expand Down Expand Up @@ -538,6 +540,10 @@ function Client:ask(opts)

local headers = self:authenticate(provider_name)

if resolved_headers then
headers = vim.tbl_extend('force', headers, resolved_headers)
end

local request, extra_headers =
provider.prepare_input(generate_ask_request(opts.system_prompt, history, generated_messages), options)

Expand Down
2 changes: 1 addition & 1 deletion lua/CopilotChat/completion.lua
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function M.complete(without_input)
local row, col = unpack(vim.api.nvim_win_get_cursor(win))

local prefix, cmp_start = unpack(vim.fn.matchstrpos(line:sub(1, col), info.pattern))
if not prefix then
if cmp_start == -1 then
return
end

Expand Down
24 changes: 21 additions & 3 deletions lua/CopilotChat/config/providers.lua
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ end
---@field get_headers nil|fun():table<string, string>,number?
---@field get_info nil|fun(headers:table):string[]
---@field get_models nil|fun(headers:table):table<CopilotChat.client.Model>
---@field resolve_model nil|fun(headers:table, model: string):string
---@field resolve_model nil|fun(headers:table, model: string):string, table<string, string>?
---@field prepare_input nil|fun(inputs:CopilotChat.client.Message[], opts:CopilotChat.config.providers.Options):table,table?
---@field prepare_output nil|fun(output:table, opts:CopilotChat.config.providers.Options):CopilotChat.config.providers.Output
---@field get_url nil|fun(opts:CopilotChat.config.providers.Options):string
Expand Down Expand Up @@ -626,19 +626,37 @@ M.copilot = {
-- Build request headers without our internal routing header.
local request_headers = vim.tbl_extend('force', headers, { ['x-copilot-base-url'] = nil })

local auth = headers.Authorization or headers.authorization or ''
local sku = auth:match('sku=([^;]+)') or 'free'
Comment on lines +629 to +630

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Parsing sku=... from the Authorization header string is fragile. The copilot provider builds the header as 'Bearer ' .. response.body.token, which is not semicolon-delimited, so this match will almost always return nil and default the SKU to 'free'. On Business/Enterprise/Pro accounts that silently filters out paid-tier models.

Technical details
### ⚠️ SKU extraction from Authorization header is fragile

## Affected sites
- `lua/CopilotChat/config/providers.lua:629-630`
- `lua/CopilotChat/config/providers.lua:642-654`
- `lua/CopilotChat/config/providers.lua:659`

## Required outcome
- The user's actual plan SKU is used for `restricted_to` filtering, not a hard-coded `'free'` fallback.

## Suggested approach
- Capture the SKU from the `/copilot_internal/v2/token` response body (e.g. `response.body.sku`, if available) and ferry it through an internal header such as `x-copilot-sku`, then read that header in `get_models`.
- If the token response doesn't expose the SKU, use a dedicated endpoint like `/copilot_internal/user` or its existing `get_info` path rather than regex-parsing the Bearer token.

## Open questions for the human
- Does the `/copilot_internal/v2/token` response already contain a `sku` field the code can use?

Comment thread
IqbalHossainEmon marked this conversation as resolved.

local response, err = curl.get(base_url .. '/models', {
json_response = true,
headers = request_headers,
})

if err then
print(debug.traceback('get_models error: ' .. vim.inspect(err)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The unconditional print flashes a traceback to the user's message area and bypasses the plugin's logging/notifications. Remove it or route through log.error / notify.publish.

error(err)
end

local function sku_matches_restricted_to(sku, restricted_to)
if not restricted_to or vim.tbl_isempty(restricted_to) then
return true
end

for _, tier in ipairs(restricted_to) do
if sku:find(tier, 1, true) then
return true
end
end

return false
end

local models = vim
.iter(response.body.data)
:filter(function(model)
return model.capabilities.type == 'chat' and model.model_picker_enabled
return model.capabilities.type == 'chat' and sku_matches_restricted_to(sku, model.restricted_to)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Dropping the model_picker_enabled filter changes model-list semantics for every tier. Unless restricted_to fully replaces the picker gate, this can expose internal/non-selectable models or regress paid-tier users.

Technical details
### ⚠️ Removal of model_picker_enabled may surface non-selectable models

## Affected sites
- `lua/CopilotChat/config/providers.lua:659`

## Required outcome
- The model list contains exactly the models users should be able to select, and doesn't expose models the API explicitly marks as non-selectable.

## Suggested approach
- Verify whether `restricted_to` is authoritative for picker eligibility. If it is, keep the change but add a comment explaining why `model_picker_enabled` is no longer needed.
- If the two fields are independent, combine them: only include chat models that are both picker-enabled *or* explicitly available to the current SKU.

Comment thread
IqbalHossainEmon marked this conversation as resolved.
end)
:map(function(model)
local supported_endpoints = model.supported_endpoints or {}
Expand Down Expand Up @@ -714,7 +732,7 @@ M.copilot = {
error(err)
end

return response.body.selected_model
return response.body.selected_model, { ['Copilot-Session-Token'] = response.body.session_token }
end,

prepare_input = function(inputs, opts)
Expand Down
4 changes: 3 additions & 1 deletion lua/CopilotChat/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,7 @@ function M.ask(prompt, config)
end

local response = ask_response.message
local effective_model = response.model or selected_model
local token_count = ask_response.token_count
local token_max_count = ask_response.token_max_count

Expand Down Expand Up @@ -632,7 +633,7 @@ function M.ask(prompt, config)
resources = resolved_resources,
tools = selected_tools,
system_prompt = system_prompt,
model = selected_model,
model = effective_model,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ On tool-call continuations, model = effective_model is the resolved concrete model id, so resolve_model returns early and produces no Copilot-Session-Token. Later turns may fail or route inconsistently if the backend expects the same session token across the conversation.

Technical details
### ⚠️ Session token is not reused across continuation turns

## Affected sites
- `lua/CopilotChat/init.lua:560`
- `lua/CopilotChat/init.lua:636`
- `lua/CopilotChat/init.lua:647`
- `lua/CopilotChat/client.lua:325-326`
- `lua/CopilotChat/client.lua:543-545`

## Required outcome
- The `Copilot-Session-Token` returned for `auto` is attached to every request in the same chat turn/conversation, not just the first request.

## Suggested approach
- Cache the session token at the chat level (e.g. alongside the chat state or config) and merge it into each `client:ask` for that conversation, rather than relying solely on `resolve_model` returning it every time.
- Alternatively, special-case `model == 'auto'` so `effective_model` continues to be `'auto'` while a separate resolved id is tracked for the request body.

## Open questions for the human
- Does the `/models/session` token need to be sent with every turn, or only with the first request?

Comment thread
IqbalHossainEmon marked this conversation as resolved.
temperature = config.temperature,
on_progress = vim.schedule_wrap(function(message)
if not config.headless then
Expand All @@ -643,6 +644,7 @@ function M.ask(prompt, config)

if continue_response then
local continue_message = continue_response.message
effective_model = continue_message.model or effective_model
continue_message.content = vim.trim(continue_message.content)
if utils.empty(continue_message.content) then
continue_message.content = ''
Expand Down