mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-07-05 21:50:46 +08:00
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:
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user