Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 3 additions & 4 deletions .github/workflows/pullfrog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,22 +34,21 @@ jobs:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY:
${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}

# for Amazon Bedrock (https://docs.pullfrog.com/bedrock)
# AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.AWS_BEARER_TOKEN_BEDROCK }}
# AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
# AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# AWS_REGION: us-east-1
# BEDROCK_MODEL_ID: <bedrock-model-id>

# for Google Vertex AI (https://docs.pullfrog.com/vertex)
# VERTEX_SERVICE_ACCOUNT_JSON: ${{ secrets.VERTEX_SERVICE_ACCOUNT_JSON }}
# GOOGLE_CLOUD_PROJECT: my-project
Expand Down
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>?,
}
```

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
23 changes: 20 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,6 +626,9 @@ 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,
Expand All @@ -635,10 +638,24 @@ M.copilot = {
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 +731,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