hotfix(copilot): github copilot model fetch (#14566)

<!-- Template from
https://github.com/kubevirt/kubevirt/blob/main/.github/PULL_REQUEST_TEMPLATE.md?-->
<!--  Thanks for sending a pull request!  Here are some tips for you:
1. Consider creating this PR as draft:
https://github.com/CherryHQ/cherry-studio/blob/main/CONTRIBUTING.md
-->

<!--

🚨 Branch Strategy Change (Effective April 3, 2026) 🚨

The `main` branch is now under CODE FREEZE.

- main branch: Only accepts critical bug fixes via `hotfix/*` branches.
Fix PRs must be minimal in scope and must not include any refactoring
code.
- v2 branch: All new features, refactoring, and optimizations should be
submitted to the `v2` branch.

If you are submitting a bug fix to main, please ensure your PR is from a
`hotfix/*` branch.

-->

### What this PR does

Before this PR:

GitHub Copilot model discovery was routed through the generic
OpenAI-compatible /models flow. On affected versions, model sync could
fail and leave users with only the fallback model, instead of a usable
model list.

After this PR:

Cherry Studio uses a Copilot-specific model fetcher for /models,
including Copilot headers and dynamic token exchange. The fetched
results now filter disabled entries, internal accounts/.../routers/...
IDs, and unsupported tts/whisper/speech models before presenting them to
users. This branch also updates the built-in Copilot default model to
gpt-5-mini.

<!-- (optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)`
format, will close the issue(s) when PR gets merged)*: -->

Fixes #14544

### Why we need it and why it was done in this way

This issue is a user-visible regression introduced after v1.8.4. The
goal of this hotfix is to restore GitHub Copilot model listing on main
with the smallest possible change set and without touching unrelated
provider flows.

The following tradeoffs were made:

A provider-specific Copilot fetcher was added in the renderer model
listing layer instead of introducing a broader abstraction. This keeps
the hotfix narrow and avoids changing the existing inference path for
other providers.

The following alternatives were considered:

Reusing the generic OpenAI-compatible model fetcher with small header
tweaks was considered, but rejected because Copilot also requires
dynamic token exchange and provider-specific response filtering.

Reusing older legacy filtering helpers was considered, but rejected
because it would widen the change surface and add unnecessary coupling
for a main-branch hotfix.

Links to places where the discussion took place:
https://github.com/CherryHQ/cherry-studio/issues/14544

### Breaking changes

None.

If this PR introduces breaking changes, please describe the changes and
the impact on users.

N/A

### Special notes for your reviewer

Focused validation was run with the renderer-side model listing test
file and the Copilot regression case passes.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [x] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code (e.g., via
[`/gh-pr-review`](/.claude/skills/gh-pr-review/SKILL.md), `gh pr diff`,
or GitHub UI) before requesting review from others

### Release note

<!--  Write your release note:
1. Enter your extended release note in the below block. If the PR
requires additional action from users switching to the new release,
include the string "action required".
2. If no release note is required, just write "NONE".
3. Only include user-facing changes (new features, bug fixes visible to
users, UI changes, behavior changes). For CI, maintenance, internal
refactoring, build tooling, or other non-user-facing work, write "NONE".
-->

```release-note
Fixed GitHub Copilot model synchronization
This commit is contained in:
Asurada
2026-04-24 23:06:10 +08:00
committed by GitHub
parent e17d573755
commit 774aa674d2
3 changed files with 98 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ import type { Provider } from '@renderer/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetFromApi = vi.fn()
const mockCopilotGetToken = vi.fn()
vi.mock('@ai-sdk/provider-utils', () => ({
createJsonResponseHandler: vi.fn(() => 'json-handler'),
createJsonErrorResponseHandler: vi.fn(() => 'error-handler'),
@@ -29,6 +30,16 @@ vi.mock('@shared/utils', () => ({
defaultAppHeaders: () => ({ 'X-App': 'CherryStudio' })
}))
vi.mock('@renderer/store', () => ({
default: {
getState: () => ({
copilot: {
defaultHeaders: {}
}
})
}
}))
const { listModels } = await import('../listModels')
const { OllamaTagsResponseSchema } = await import('../schemas')
@@ -232,14 +243,61 @@ function assertValidModels(models: { id: string; name: string; provider: string;
}
}
const COPILOT_PROVIDER = makeProvider({
id: 'copilot',
apiHost: 'https://api.githubcopilot.com/'
})
const COPILOT_MODELS_RESPONSE = {
value: {
data: [
{ id: 'accounts/msft/routers/f185i3v4' },
{ id: 'tts-1', object: 'model' },
{ id: 'gpt-4o-mini', owned_by: 'github' },
{ id: 'claude-sonnet-4.5', policy: { state: 'disabled' } },
{ id: 'gpt-4o-mini', owned_by: 'github' }
]
}
}
// === Tests ===
beforeEach(() => {
mockGetFromApi.mockReset()
vi.stubGlobal('window', { ...globalThis.window, keyv: { get: vi.fn(), set: vi.fn() } })
mockCopilotGetToken.mockReset()
mockCopilotGetToken.mockResolvedValue({ token: 'copilot-dynamic-token' })
vi.stubGlobal('window', {
...globalThis.window,
keyv: { get: vi.fn(), set: vi.fn() },
api: {
copilot: {
getToken: mockCopilotGetToken
}
}
})
})
describe('listModels', () => {
describe('Copilot', () => {
it('should use Copilot-specific token and filter unsupported Copilot entries', async () => {
mockGetFromApi.mockResolvedValue(COPILOT_MODELS_RESPONSE)
const models = await listModels(COPILOT_PROVIDER)
expect(mockGetFromApi).toHaveBeenCalledTimes(1)
const [request] = mockGetFromApi.mock.calls[0]
expect(mockCopilotGetToken).toHaveBeenCalledTimes(1)
expect(request).toMatchObject({
url: 'https://api.githubcopilot.com/models',
headers: {
Authorization: 'Bearer copilot-dynamic-token',
'Copilot-Integration-Id': 'vscode-chat'
}
})
expect(models.map((model) => model.id)).toEqual(['gpt-4o-mini'])
})
})
describe('OpenAI-compatible (DeepSeek)', () => {
it('should convert real DeepSeek response', async () => {
mockGetFromApi.mockResolvedValue({ value: REAL_DEEPSEEK })

View File

@@ -10,6 +10,8 @@ import {
zodSchema
} from '@ai-sdk/provider-utils'
import { loggerService } from '@logger'
import { COPILOT_DEFAULT_HEADERS } from '@renderer/aiCore/provider/constants'
import store from '@renderer/store'
import type { EndpointType, Model, Provider } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { formatApiHost, withoutTrailingSlash } from '@renderer/utils'
@@ -211,6 +213,39 @@ const githubFetcher: ModelFetcher = {
}
}
const copilotFetcher: ModelFetcher = {
match: (p) => p.id === SystemProviderIds.copilot,
fetch: async (provider, signal) => {
const headers = {
...COPILOT_DEFAULT_HEADERS,
...store.getState().copilot.defaultHeaders,
...provider.extra_headers
}
const { token } = await window.api.copilot.getToken(headers)
const response = await getFromApi({
url: `${withoutTrailingSlash(provider.apiHost)}/models`,
headers: {
...headers,
Authorization: `Bearer ${token}`
},
responseSchema: OpenAIModelsResponseSchema,
abortSignal: signal
})
const filtered = response.data.filter((m) => {
const modelId = m.id.toLowerCase()
const policyState = (m as { policy?: { state?: string } }).policy?.state
return (
policyState !== 'disabled' &&
!/^accounts\/[^/]+\/routers\//.test(modelId) &&
!/^(tts|whisper|speech)/.test(modelId.split('/').pop() || '')
)
})
return dedup(filtered, (m) => m.id).map((m) => toModel(m.id, provider, { owned_by: m.owned_by }))
}
}
const ovmsFetcher: ModelFetcher = {
match: (p) => p.id === SystemProviderIds.ovms,
fetch: async (provider, signal) => {
@@ -358,6 +393,7 @@ const fetchers: ModelFetcher[] = [
ollamaFetcher,
geminiFetcher,
githubFetcher,
copilotFetcher,
ovmsFetcher,
togetherFetcher,
newApiFetcher,

View File

@@ -606,10 +606,10 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
],
copilot: [
{
id: 'gpt-4o-mini',
id: 'gpt-5-mini',
provider: 'copilot',
name: 'OpenAI GPT-4o-mini',
group: 'OpenAI'
name: 'gpt-5-mini',
group: 'copilot'
}
],
yi: [