fix: resolve auto model routing failures for GitHub Copilot Free/Student plans#1578
fix: resolve auto model routing failures for GitHub Copilot Free/Student plans#1578IqbalHossainEmon wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Important
The model-list filter change and extra-header forwarding look like the right shape, but the SKU extraction from the Authorization header is almost certainly wrong for standard Bearer <token> values, and the Copilot-Session-Token returned for auto is lost on continued tool-call turns. Both can break chat for non-Free tiers or multi-turn flows.
Reviewed changes — PR #1578 routes auto model selection through /models/session, forwards the returned session token to the completions request, adjusts get_models filtering for Free/Student accounts, tracks the resolved model across tool-call continuations, and fixes the completion trigger guard.
lua/CopilotChat/config/providers.lua—get_modelsfilters by SKU/restricted_toinstead ofmodel_picker_enabled;resolve_modelreturnsCopilot-Session-Token.lua/CopilotChat/client.lua— merges extra headers returned byresolve_modelinto the request.lua/CopilotChat/init.lua— continues tool-call turns witheffective_model.lua/CopilotChat/completion.lua— fixes thematchstrposno-match guard.README.md— documents the newresolve_modelreturn value.
⚠️ PR description claims the policy-enable loop was removed, but it is still present
The PR body says it removed the background POST /models/{id}/policy loop, yet lua/CopilotChat/config/providers.lua:694-702 still silently calls it for every model that lacks an enabled policy on each model-list fetch. Either the description should be corrected, or the removal is missing from the commit.
Technical details
### ⚠️ PR description claims the policy-enable loop was removed, but it is still present
## Affected sites
- `lua/CopilotChat/config/providers.lua:694-702` — `pcall(curl.post, base_url .. '/models/' .. model.id .. '/policy', ...)` still flips policy state on every fetch.
## Required outcome
- The PR description and the committed code agree: remove the loop if that was intentional, or update the description if the loop was deliberately kept.
## Open questions for the human
- Was the policy loop intended to be deleted in this commit?Kimi K2 (free via Pullfrog for OSS) | 𝕏
| local auth = headers.Authorization or headers.authorization or '' | ||
| local sku = auth:match('sku=([^;]+)') or 'free' |
There was a problem hiding this comment.
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?| .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) |
There was a problem hiding this comment.
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.| tools = selected_tools, | ||
| system_prompt = system_prompt, | ||
| model = selected_model, | ||
| model = effective_model, |
There was a problem hiding this comment.
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?| }) | ||
|
|
||
| if err then | ||
| print(debug.traceback('get_models error: ' .. vim.inspect(err))) |
There was a problem hiding this comment.
ℹ️ 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.
There was a problem hiding this comment.
Caution
This PR introduces fixes intended to unblock Free/Student auto model routing, but the latest commit (64e1e03) does not address the substantive issues flagged in the prior review. Three of the four open Pullfrog threads remain unaddressed, and the SKU fallback logic is still broken in a way that can regress paid-tier users.
Reviewed changes — Since the last review, the author pushed 64e1e03 chore: apply review feedback, which only updates the README.md type annotation for resolve_model and makes whitespace/secret comments changes in .github/workflows/pullfrog.yml. The core implementation in providers.lua, client.lua, and init.lua is unchanged, so the prior correctness concerns still apply.
README.md— correctedresolve_modelreturn type tostring, table<string, string>?..github/workflows/pullfrog.yml— formatting/comment-only changes and duplicate/expanded secret placeholders.
🚨 SKU extraction from Authorization header still defaults all users to "free"
The auth:match('sku=([^;]+)') pattern matches a semicolon-delimited string that the Copilot provider never produces. The provider builds Authorization as 'Bearer ' .. response.body.token (lua/CopilotChat/config/providers.lua:559). For every standard token this match returns nil and sku falls back to 'free', which filters paid-tier models from the picker.
Technical details
### SKU extraction from Authorization header is still fragile
## Affected sites
- `lua/CopilotChat/config/providers.lua:629-630`
- `lua/CopilotChat/config/providers.lua:642-654`
- `lua/CopilotChat/config/providers.lua:658`
## 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 `/copilot_internal/user` or the 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?🚨 Session token still lost on tool-call continuation turns
init.lua now tracks effective_model and reuses it for continuation turns, which is good. However, on continuations model == effective_model is the concrete resolved model id, so resolve_model returns early and produces no Copilot-Session-Token. If the backend expects the same session token across the conversation, later turns will fail.
Technical details
### Session token is still 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 and merge it into each `client:ask` for that conversation, or special-case `model == 'auto'` so `effective_model` resolves while the request identity stays `'auto'`.
## Open questions for the human
- Does the `/models/session` token need to be sent with every turn, or only with the first request?⚠️ PR description/policy loop mismatch is still unresolved
The PR body still claims the background POST /models/{id}/policy loop was removed, but lua/CopilotChat/config/providers.lua:693-701 still silently calls it on every model-list fetch. The description and committed code need to agree.
⚠️ model_picker_enabled filter removal still changes picker semantics
Dropping the model_picker_enabled check changes model-list semantics for every tier. Unless restricted_to is authoritative for picker eligibility, this can expose internal/non-selectable models or regress paid-tier users. The author should either verify this is safe or combine the conditions.
Note: 1 inline comment(s) dropped because they did not anchor to lines inside the PR diff:
lua/CopilotChat/config/providers.lua:695(RIGHT) — line 695 (RIGHT) is not inside a diff hunk
Kimi K2 (free via Pullfrog for OSS) | 𝕏

Problem
Since GitHub's June 24, 2026 policy change restricting Free/Student plan accounts to
automodel selection only, CopilotChat.nvim chat requests fail in two different waysdepending on how far a request gets:
Resolved model not found: <model>— thrown before any request is even sent,because
get_models()filters strictly onmodel_picker_enabled, which isfalsefor every real model on these restricted accounts. Only the synthetic
autoplaceholder entry survives the filter.
Failed to get response: 400/"model \"gpt-5.4-mini\" is not accessible via the /chat/completions endpoint"—even when a resolved model is accepted, the placeholder-derived model config is
missing fields like
use_responses, so requests are routed to the wrong endpointfor models that require the Responses API instead of Chat Completions.
Related: #1576, #1575, #1577
Credit
Builds on the approach from #1575 (session token forwarding), with additional
changes to the model list filtering and the completion trigger bug found while
testing.
Testing
Tested manually on a Student pack plan account over several days of regular use — chat,
@-tool calls, and$/@/#//completions all work as expected for me. Notexhaustively tested against every account tier or every model in the
autopool.