diff --git a/package.json b/package.json index bd1ccd9d8f..71a6b06982 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", + "@opeoginni/github-copilot-openai-compatible": "0.1.18", "@playwright/test": "^1.52.0", "@radix-ui/react-context-menu": "^2.2.16", "@reduxjs/toolkit": "^2.2.5", diff --git a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts index 95c2cdda2c..fd68da27d2 100644 --- a/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts +++ b/packages/aiCore/src/core/plugins/built-in/webSearchPlugin/helper.ts @@ -1,7 +1,7 @@ import { anthropic } from '@ai-sdk/anthropic' import { google } from '@ai-sdk/google' import { openai } from '@ai-sdk/openai' -import { InferToolInput, InferToolOutput } from 'ai' +import { InferToolInput, InferToolOutput, type Tool } from 'ai' import { ProviderOptionsMap } from '../../../options/types' import { OpenRouterSearchConfig } from './openrouter' @@ -15,6 +15,13 @@ export type AnthropicSearchConfig = NonNullable[0]> export type XAISearchConfig = NonNullable +type NormalizeTool = T extends Tool ? Tool : Tool + +type AnthropicWebSearchTool = NormalizeTool> +type OpenAIWebSearchTool = NormalizeTool> +type OpenAIChatWebSearchTool = NormalizeTool> +type GoogleWebSearchTool = NormalizeTool> + /** * 插件初始化时接收的完整配置对象 * @@ -59,7 +66,7 @@ export const DEFAULT_WEB_SEARCH_CONFIG: WebSearchPluginConfig = { export type WebSearchToolOutputSchema = { // Anthropic 工具 - 手动定义 - anthropic: InferToolOutput> + anthropic: InferToolOutput // OpenAI 工具 - 基于实际输出 // TODO: 上游定义不规范,是unknown @@ -82,8 +89,8 @@ export type WebSearchToolOutputSchema = { } export type WebSearchToolInputSchema = { - anthropic: InferToolInput> - openai: InferToolInput> - google: InferToolInput> - 'openai-chat': InferToolInput> + anthropic: InferToolInput + openai: InferToolInput + google: InferToolInput + 'openai-chat': InferToolInput } diff --git a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts index 430b032749..79762e9474 100644 --- a/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts +++ b/src/renderer/src/aiCore/legacy/clients/openai/OpenAIBaseClient.ts @@ -166,9 +166,7 @@ export abstract class OpenAIBaseClient< baseURL: this.getBaseURL(), defaultHeaders: { ...this.defaultHeaders(), - ...this.provider.extra_headers, - ...(this.provider.id === 'copilot' ? { 'editor-version': 'vscode/1.97.2' } : {}), - ...(this.provider.id === 'copilot' ? { 'copilot-vision-request': 'true' } : {}) + ...this.provider.extra_headers } }) as TSdkInstance } diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts new file mode 100644 index 0000000000..eb6e73c8ae --- /dev/null +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@renderer/services/LoggerService', () => ({ + loggerService: { + withContext: () => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getProviderByModel: vi.fn() +})) + +vi.mock('@renderer/store', () => ({ + default: { + getState: () => ({ copilot: { defaultHeaders: {} } }) + } +})) + +import type { Model, Provider } from '@renderer/types' + +import { COPILOT_DEFAULT_HEADERS, COPILOT_EDITOR_VERSION, isCopilotResponsesModel } from '../constants' +import { providerToAiSdkConfig } from '../providerConfig' + +const createWindowKeyv = () => { + const store = new Map() + return { + get: (key: string) => store.get(key), + set: (key: string, value: string) => { + store.set(key, value) + } + } +} + +const createCopilotProvider = (): Provider => ({ + id: 'copilot', + type: 'openai', + name: 'GitHub Copilot', + apiKey: 'test-key', + apiHost: 'https://api.githubcopilot.com', + models: [], + isSystem: true +}) + +const createModel = (id: string, name = id): Model => ({ + id, + name, + provider: 'copilot', + group: 'copilot' +}) + +describe('Copilot responses routing', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + }) + + it('detects official GPT-5 Codex identifiers case-insensitively', () => { + expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'gpt-5-codex'))).toBe(true) + expect(isCopilotResponsesModel(createModel('GPT-5-CODEX', 'GPT-5-CODEX'))).toBe(true) + expect(isCopilotResponsesModel(createModel('gpt-5-codex', 'custom-name'))).toBe(true) + expect(isCopilotResponsesModel(createModel('custom-id', 'custom-name'))).toBe(false) + }) + + it('configures gpt-5-codex with the Copilot provider', () => { + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-5-codex', 'GPT-5-CODEX')) + + expect(config.providerId).toBe('github-copilot-openai-compatible') + expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_EDITOR_VERSION) + expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id']) + expect(config.options.headers?.['copilot-vision-request']).toBe('true') + }) + + it('uses the Copilot provider for other models and keeps headers', () => { + const provider = createCopilotProvider() + const config = providerToAiSdkConfig(provider, createModel('gpt-4')) + + expect(config.providerId).toBe('github-copilot-openai-compatible') + expect(config.options.headers?.['Editor-Version']).toBe(COPILOT_DEFAULT_HEADERS['Editor-Version']) + expect(config.options.headers?.['Copilot-Integration-Id']).toBe(COPILOT_DEFAULT_HEADERS['Copilot-Integration-Id']) + }) +}) diff --git a/src/renderer/src/aiCore/provider/constants.ts b/src/renderer/src/aiCore/provider/constants.ts new file mode 100644 index 0000000000..c7cd90bd93 --- /dev/null +++ b/src/renderer/src/aiCore/provider/constants.ts @@ -0,0 +1,25 @@ +import type { Model } from '@renderer/types' + +export const COPILOT_EDITOR_VERSION = 'vscode/1.104.1' +export const COPILOT_PLUGIN_VERSION = 'copilot-chat/0.26.7' +export const COPILOT_INTEGRATION_ID = 'vscode-chat' +export const COPILOT_USER_AGENT = 'GitHubCopilotChat/0.26.7' + +export const COPILOT_DEFAULT_HEADERS = { + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + 'User-Agent': COPILOT_USER_AGENT, + 'Editor-Version': COPILOT_EDITOR_VERSION, + 'Editor-Plugin-Version': COPILOT_PLUGIN_VERSION, + 'editor-version': COPILOT_EDITOR_VERSION, + 'editor-plugin-version': COPILOT_PLUGIN_VERSION, + 'copilot-vision-request': 'true' +} as const + +// Models that require the OpenAI Responses endpoint when routed through GitHub Copilot (#10560) +const COPILOT_RESPONSES_MODEL_IDS = ['gpt-5-codex'] + +export function isCopilotResponsesModel(model: Model): boolean { + const normalizedId = model.id?.trim().toLowerCase() + const normalizedName = model.name?.trim().toLowerCase() + return COPILOT_RESPONSES_MODEL_IDS.some((target) => normalizedId === target || normalizedName === target) +} diff --git a/src/renderer/src/aiCore/provider/factory.ts b/src/renderer/src/aiCore/provider/factory.ts index bfcd3da383..62211100fe 100644 --- a/src/renderer/src/aiCore/provider/factory.ts +++ b/src/renderer/src/aiCore/provider/factory.ts @@ -28,7 +28,8 @@ const STATIC_PROVIDER_MAPPING: Record = { gemini: 'google', // Google Gemini -> google 'azure-openai': 'azure', // Azure OpenAI -> azure 'openai-response': 'openai', // OpenAI Responses -> openai - grok: 'xai' // Grok -> xai + grok: 'xai', // Grok -> xai + copilot: 'github-copilot-openai-compatible' } /** diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index fb463cfad9..1fa34e5a1a 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -21,6 +21,7 @@ import { formatApiHost } from '@renderer/utils/api' import { cloneDeep, trim } from 'lodash' import { aihubmixProviderCreator, newApiResolverCreator, vertexAnthropicProviderCreator } from './config' +import { COPILOT_DEFAULT_HEADERS } from './constants' import { getAiSdkProviderId } from './factory' const logger = loggerService.withContext('ProviderConfigProcessor') @@ -109,6 +110,9 @@ function formatProviderApiHost(provider: Provider): Provider { if (!formatted.anthropicApiHost) { formatted.anthropicApiHost = formatted.apiHost } + } else if (formatted.id === 'copilot') { + const trimmed = trim(formatted.apiHost) + formatted.apiHost = trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed } else if (formatted.type === 'gemini') { formatted.apiHost = formatApiHost(formatted.apiHost, 'v1beta') } else { @@ -151,6 +155,26 @@ export function providerToAiSdkConfig( baseURL: trim(actualProvider.apiHost), apiKey: getRotatedApiKey(actualProvider) } + + const isCopilotProvider = actualProvider.id === 'copilot' + if (isCopilotProvider) { + const storedHeaders = store.getState().copilot.defaultHeaders ?? {} + const options = ProviderConfigFactory.fromProvider('github-copilot-openai-compatible', baseConfig, { + headers: { + ...COPILOT_DEFAULT_HEADERS, + ...storedHeaders, + ...actualProvider.extra_headers + }, + name: actualProvider.id, + includeUsage: true + }) + + return { + providerId: 'github-copilot-openai-compatible', + options + } + } + // 处理OpenAI模式 const extraOptions: any = {} if (actualProvider.type === 'openai-response' && !isOpenAIChatCompletionOnlyModel(model)) { @@ -172,15 +196,6 @@ export function providerToAiSdkConfig( } } } - - // copilot - if (actualProvider.id === 'copilot') { - extraOptions.headers = { - ...extraOptions.headers, - 'editor-version': 'vscode/1.97.2', - 'copilot-vision-request': 'true' - } - } // azure if (aiSdkProviderId === 'azure' || actualProvider.type === 'azure-openai') { extraOptions.apiVersion = actualProvider.apiVersion @@ -229,7 +244,6 @@ export function providerToAiSdkConfig( } } - // 如果AI SDK支持该provider,使用原生配置 if (hasProviderConfig(aiSdkProviderId) && aiSdkProviderId !== 'openai-compatible') { const options = ProviderConfigFactory.fromProvider(aiSdkProviderId, baseConfig, extraOptions) return { @@ -277,9 +291,17 @@ export async function prepareSpecialProviderConfig( ) { switch (provider.id) { case 'copilot': { - const defaultHeaders = store.getState().copilot.defaultHeaders - const { token } = await window.api.copilot.getToken(defaultHeaders) + const defaultHeaders = store.getState().copilot.defaultHeaders ?? {} + const headers = { + ...COPILOT_DEFAULT_HEADERS, + ...defaultHeaders + } + const { token } = await window.api.copilot.getToken(headers) config.options.apiKey = token + config.options.headers = { + ...headers, + ...config.options.headers + } break } case 'cherryai': { diff --git a/src/renderer/src/aiCore/provider/providerInitialization.ts b/src/renderer/src/aiCore/provider/providerInitialization.ts index 3c188313b9..2a19729d7e 100644 --- a/src/renderer/src/aiCore/provider/providerInitialization.ts +++ b/src/renderer/src/aiCore/provider/providerInitialization.ts @@ -32,6 +32,14 @@ export const NEW_PROVIDER_CONFIGS: ProviderConfig[] = [ supportsImageGeneration: true, aliases: ['vertexai-anthropic'] }, + { + id: 'github-copilot-openai-compatible', + name: 'GitHub Copilot OpenAI Compatible', + import: () => import('@opeoginni/github-copilot-openai-compatible'), + creatorFunctionName: 'createGitHubCopilotOpenAICompatible', + supportsImageGeneration: false, + aliases: ['copilot', 'github-copilot'] + }, { id: 'bedrock', name: 'Amazon Bedrock', diff --git a/yarn.lock b/yarn.lock index c78a39930b..8279b69dda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -191,7 +191,7 @@ __metadata: languageName: node linkType: hard -"@ai-sdk/openai@npm:2.0.42, @ai-sdk/openai@npm:^2.0.42": +"@ai-sdk/openai@npm:2.0.42": version: 2.0.42 resolution: "@ai-sdk/openai@npm:2.0.42" dependencies: @@ -203,6 +203,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/openai@npm:^2.0.42": + version: 2.0.47 + resolution: "@ai-sdk/openai@npm:2.0.47" + dependencies: + "@ai-sdk/provider": "npm:2.0.0" + "@ai-sdk/provider-utils": "npm:3.0.11" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/7fabcdda707134971bcc2b285705d4595f8bf419285dbdd9266b3b0858ea11b6ac200e63dd2eeb1822f99571910093d64d4a76154a365331cf184f56452933c6 + languageName: node + linkType: hard + "@ai-sdk/perplexity@npm:^2.0.11": version: 2.0.11 resolution: "@ai-sdk/perplexity@npm:2.0.11" @@ -228,6 +240,19 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/provider-utils@npm:3.0.11": + version: 3.0.11 + resolution: "@ai-sdk/provider-utils@npm:3.0.11" + dependencies: + "@ai-sdk/provider": "npm:2.0.0" + "@standard-schema/spec": "npm:^1.0.0" + eventsource-parser: "npm:^3.0.5" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/31081b127b48f3eefb448eaca59574b4631da9577aa0778622d28669c71bbde0361c9b37962c5edbb1d0c163ed1479755fc889da9251a03e906b1e27d0d2eb24 + languageName: node + linkType: hard + "@ai-sdk/provider@npm:2.0.0, @ai-sdk/provider@npm:^2.0.0": version: 2.0.0 resolution: "@ai-sdk/provider@npm:2.0.0" @@ -237,6 +262,15 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/provider@npm:^2.1.0-beta.4": + version: 2.1.0-beta.5 + resolution: "@ai-sdk/provider@npm:2.1.0-beta.5" + dependencies: + json-schema: "npm:^0.4.0" + checksum: 10c0/4f51813285a8e92be18ef14645b0505bb0c9d2daa0d9290cac8a3c1f87d8e2e8507b1edf1818ae2305a90723e8cd44477f55f0631407dee35912ab8fdded52ba + languageName: node + linkType: hard + "@ai-sdk/xai@npm:^2.0.23": version: 2.0.23 resolution: "@ai-sdk/xai@npm:2.0.23" @@ -7673,6 +7707,18 @@ __metadata: languageName: node linkType: hard +"@opeoginni/github-copilot-openai-compatible@npm:0.1.18": + version: 0.1.18 + resolution: "@opeoginni/github-copilot-openai-compatible@npm:0.1.18" + dependencies: + "@ai-sdk/openai": "npm:^2.0.42" + "@ai-sdk/openai-compatible": "npm:^1.0.19" + "@ai-sdk/provider": "npm:^2.1.0-beta.4" + "@ai-sdk/provider-utils": "npm:^3.0.10" + checksum: 10c0/31b87ed150883bbdd33a0203e45831859560fdf174f0285384fdcb1d01fc4a56ca15f31d648e8d6d3a2d4d5c6e327ddecbf422543eeefaa7e8fdd7dc2f2a3b08 + languageName: node + linkType: hard + "@oxc-project/runtime@npm:0.71.0": version: 0.71.0 resolution: "@oxc-project/runtime@npm:0.71.0" @@ -14238,6 +14284,7 @@ __metadata: "@opentelemetry/sdk-trace-base": "npm:^2.0.0" "@opentelemetry/sdk-trace-node": "npm:^2.0.0" "@opentelemetry/sdk-trace-web": "npm:^2.0.0" + "@opeoginni/github-copilot-openai-compatible": "npm:0.1.18" "@playwright/test": "npm:^1.52.0" "@radix-ui/react-context-menu": "npm:^2.2.16" "@reduxjs/toolkit": "npm:^2.2.5"