Skip to content

fix: resolve auto model routing failures for GitHub Copilot Free/Student plans#1578

Closed
IqbalHossainEmon wants to merge 2 commits into
CopilotC-Nvim:mainfrom
IqbalHossainEmon:main
Closed

fix: resolve auto model routing failures for GitHub Copilot Free/Student plans#1578
IqbalHossainEmon wants to merge 2 commits into
CopilotC-Nvim:mainfrom
IqbalHossainEmon:main

Conversation

@IqbalHossainEmon

@IqbalHossainEmon IqbalHossainEmon commented Jul 4, 2026

Copy link
Copy Markdown

Problem

Since GitHub's June 24, 2026 policy change restricting Free/Student plan accounts to
auto model selection only, CopilotChat.nvim chat requests fail in two different ways
depending on how far a request gets:

  1. Resolved model not found: <model> — thrown before any request is even sent,
    because get_models() filters strictly on model_picker_enabled, which is false
    for every real model on these restricted accounts. Only the synthetic auto
    placeholder entry survives the filter.
  2. 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 endpoint
    for 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. Not
exhaustively tested against every account tier or every model in the auto pool.

@pullfrog pullfrog Bot left a comment

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.

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.luaget_models filters by SKU/restricted_to instead of model_picker_enabled; resolve_model returns Copilot-Session-Token.
  • lua/CopilotChat/client.lua — merges extra headers returned by resolve_model into the request.
  • lua/CopilotChat/init.lua — continues tool-call turns with effective_model.
  • lua/CopilotChat/completion.lua — fixes the matchstrpos no-match guard.
  • README.md — documents the new resolve_model return 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?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

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

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?

.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 lua/CopilotChat/init.lua
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 lua/CopilotChat/config/providers.lua Outdated
})

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.

Comment thread README.md Outdated

@pullfrog pullfrog Bot left a comment

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.

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 — corrected resolve_model return type to string, 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

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

Comment thread lua/CopilotChat/config/providers.lua
Comment thread lua/CopilotChat/init.lua
Comment thread lua/CopilotChat/config/providers.lua
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant