Download src/services/api/copilotClient.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 37.2 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/copilotClient.ts
- Command line
-
hf download hf://chenbhao/codev/src/services/api/copilotClient.ts
-
curl -L -o copilotClient.ts https://huggingface.co/chenbhao/codev/resolve/main/src/services/api/copilotClient.ts
37.2 kB
| import { getGlobalConfig, saveGlobalConfig, type ConnectedProviderInfo } from '../../utils/config.js' | |
| const COPILOT_API_BASE = 'https://api.githubcopilot.com' | |
| const MODELS_DEV_URL = 'https://models.dev/api.json' | |
| export type CopilotModelInfo = { | |
| id: string | |
| label: string | |
| description: string | |
| supportedEndpoints?: string[] | |
| } | |
| type CopilotOutputTokenParam = 'max_tokens' | 'max_completion_tokens' | |
| type CopilotCompatibilityInfo = { | |
| outputTokenParam?: CopilotOutputTokenParam | |
| modelSupported?: boolean | |
| chatCompletionsSupported?: boolean | |
| updatedAt: number | |
| } | |
| const COPILOT_COMPATIBILITY_CACHE_TTL_MS = 24 * 60 * 60 * 1000 | |
| const COPILOT_CHAT_COMPLETIONS_ENDPOINT = '/chat/completions' | |
| export function isCopilotModel(model: string): boolean { | |
| return model.startsWith('copilot:') | |
| } | |
| export function getCopilotModelId(model: string): string { | |
| return model.replace(/^copilot:/, '') | |
| } | |
| export function getCopilotProvider(): ConnectedProviderInfo | undefined { | |
| const config = getGlobalConfig() | |
| return config.connectedProviders?.['github-copilot'] | |
| } | |
| export function isCopilotConnected(): boolean { | |
| return !!getCopilotProvider()?.oauthToken | |
| } | |
| const FALLBACK_COPILOT_MODELS: CopilotModelInfo[] = [ | |
| { id: 'copilot:claude-sonnet-4.6', label: 'Claude Sonnet 4.6', description: 'Claude Sonnet 4.6 via Copilot' }, | |
| { id: 'copilot:claude-sonnet-4.5', label: 'Claude Sonnet 4.5', description: 'Claude Sonnet 4.5 via Copilot' }, | |
| { id: 'copilot:claude-sonnet-4', label: 'Claude Sonnet 4', description: 'Claude Sonnet 4 via Copilot' }, | |
| { id: 'copilot:claude-opus-4.6', label: 'Claude Opus 4.6', description: 'Claude Opus 4.6 via Copilot' }, | |
| { id: 'copilot:claude-opus-4.5', label: 'Claude Opus 4.5', description: 'Claude Opus 4.5 via Copilot' }, | |
| { id: 'copilot:claude-opus-41', label: 'Claude Opus 4.1', description: 'Claude Opus 4.1 via Copilot' }, | |
| { id: 'copilot:claude-haiku-4.5', label: 'Claude Haiku 4.5', description: 'Claude Haiku 4.5 via Copilot' }, | |
| { id: 'copilot:gpt-5.4', label: 'GPT-5.4', description: 'OpenAI GPT-5.4 via Copilot' }, | |
| { id: 'copilot:gpt-5.4-mini', label: 'GPT-5.4 mini', description: 'OpenAI GPT-5.4 mini via Copilot' }, | |
| { id: 'copilot:gpt-5.3-codex', label: 'GPT-5.3-Codex', description: 'OpenAI GPT-5.3-Codex via Copilot' }, | |
| { id: 'copilot:gpt-5.2-codex', label: 'GPT-5.2-Codex', description: 'OpenAI GPT-5.2-Codex via Copilot' }, | |
| { id: 'copilot:gpt-5.2', label: 'GPT-5.2', description: 'OpenAI GPT-5.2 via Copilot' }, | |
| { id: 'copilot:gpt-5.1', label: 'GPT-5.1', description: 'OpenAI GPT-5.1 via Copilot' }, | |
| { id: 'copilot:gpt-5.1-codex', label: 'GPT-5.1-Codex', description: 'OpenAI GPT-5.1-Codex via Copilot' }, | |
| { id: 'copilot:gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-mini', description: 'OpenAI GPT-5.1-Codex-mini via Copilot' }, | |
| { id: 'copilot:gpt-5.1-codex-max', label: 'GPT-5.1-Codex-max', description: 'OpenAI GPT-5.1-Codex-max via Copilot' }, | |
| { id: 'copilot:gpt-5', label: 'GPT-5', description: 'OpenAI GPT-5 via Copilot' }, | |
| { id: 'copilot:gpt-5-mini', label: 'GPT-5-mini', description: 'OpenAI GPT-5-mini via Copilot' }, | |
| { id: 'copilot:gpt-4.1', label: 'GPT-4.1', description: 'OpenAI GPT-4.1 via Copilot' }, | |
| { id: 'copilot:gpt-4o', label: 'GPT-4o', description: 'OpenAI GPT-4o via Copilot' }, | |
| { id: 'copilot:gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', description: 'Google Gemini 3.1 Pro via Copilot' }, | |
| { id: 'copilot:gemini-3-pro-preview', label: 'Gemini 3 Pro Preview', description: 'Google Gemini 3 Pro via Copilot' }, | |
| { id: 'copilot:gemini-3-flash-preview', label: 'Gemini 3 Flash', description: 'Google Gemini 3 Flash via Copilot' }, | |
| { id: 'copilot:gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Google Gemini 2.5 Pro via Copilot' }, | |
| { id: 'copilot:grok-code-fast-1', label: 'Grok Code Fast 1', description: 'xAI Grok Code Fast 1 via Copilot' }, | |
| ] | |
| let cachedModels: CopilotModelInfo[] | null = null | |
| let copilotModelsRefreshPromise: Promise<void> | null = null | |
| type CopilotApiModel = { | |
| id: string | |
| name?: string | |
| model_picker_enabled?: boolean | |
| policy?: { state?: string } | |
| supported_endpoints?: string[] | |
| } | |
| function supportsCopilotChatCompletions(model: CopilotModelInfo): boolean { | |
| return ( | |
| !model.supportedEndpoints || | |
| model.supportedEndpoints.includes(COPILOT_CHAT_COMPLETIONS_ENDPOINT) | |
| ) | |
| } | |
| export async function fetchCopilotModelsFromApi(): Promise<CopilotModelInfo[] | null> { | |
| const provider = getCopilotProvider() | |
| if (!provider?.oauthToken) return null | |
| const response = await fetch(`${COPILOT_API_BASE}/models`, { | |
| headers: { | |
| Authorization: `Bearer ${provider.oauthToken}`, | |
| 'User-Agent': 'claude-code/2.1.88', | |
| 'Openai-Intent': 'conversation-edits', | |
| 'x-initiator': 'user', | |
| }, | |
| signal: AbortSignal.timeout(10_000), | |
| }) | |
| if (!response.ok) { | |
| throw new Error(`Copilot models API failed (${response.status})`) | |
| } | |
| const data = await response.json() as { | |
| data?: CopilotApiModel[] | |
| } | |
| if (!Array.isArray(data.data) || data.data.length === 0) { | |
| return null | |
| } | |
| const models = data.data | |
| .filter(model => model.model_picker_enabled !== false) | |
| .filter(model => model.policy?.state !== 'disabled') | |
| .map( | |
| (model): CopilotModelInfo => ({ | |
| id: `copilot:${model.id}`, | |
| label: model.name || model.id, | |
| description: `${model.name || model.id} via Copilot`, | |
| supportedEndpoints: model.supported_endpoints, | |
| }), | |
| ) | |
| return models.length > 0 ? models : null | |
| } | |
| export async function fetchCopilotModelsFromModelsDev(): Promise<CopilotModelInfo[]> { | |
| try { | |
| const response = await fetch(MODELS_DEV_URL, { | |
| signal: AbortSignal.timeout(10_000), | |
| }) | |
| if (!response.ok) return FALLBACK_COPILOT_MODELS | |
| const data = await response.json() as Record<string, { models?: Record<string, { name?: string }> }> | |
| const copilotProvider = data['github-copilot'] | |
| if (!copilotProvider?.models) return FALLBACK_COPILOT_MODELS | |
| const models: CopilotModelInfo[] = Object.entries(copilotProvider.models).map( | |
| ([modelId, info]) => ({ | |
| id: `copilot:${modelId}`, | |
| label: info.name || modelId, | |
| description: `${info.name || modelId} via Copilot`, | |
| }), | |
| ) | |
| return models.length > 0 ? models : FALLBACK_COPILOT_MODELS | |
| } catch { | |
| return FALLBACK_COPILOT_MODELS | |
| } | |
| } | |
| export async function fetchCopilotModels(): Promise<CopilotModelInfo[]> { | |
| try { | |
| const apiModels = await fetchCopilotModelsFromApi() | |
| if (apiModels && apiModels.length > 0) { | |
| return apiModels | |
| } | |
| } catch {} | |
| return fetchCopilotModelsFromModelsDev() | |
| } | |
| function hasCopilotEndpointMetadata(models: CopilotModelInfo[]): boolean { | |
| return models.some(model => Array.isArray(model.supportedEndpoints)) | |
| } | |
| function shouldUseCachedCopilotModels(cache: { | |
| models: CopilotModelInfo[] | |
| fetchedAt: number | |
| } | null | undefined): boolean { | |
| if (!cache || cache.models.length === 0) return false | |
| if (!hasCopilotEndpointMetadata(cache.models)) return false | |
| return Date.now() - cache.fetchedAt < 3600_000 | |
| } | |
| function refreshCopilotModelsCacheInBackground(): void { | |
| if (copilotModelsRefreshPromise || !isCopilotConnected()) { | |
| return | |
| } | |
| copilotModelsRefreshPromise = (async () => { | |
| const models = await fetchCopilotModels() | |
| cachedModels = models | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| copilotModelsCache: { models, fetchedAt: Date.now() }, | |
| })) | |
| })() | |
| .catch(() => {}) | |
| .finally(() => { | |
| copilotModelsRefreshPromise = null | |
| }) | |
| } | |
| export async function getCopilotModels(): Promise<CopilotModelInfo[]> { | |
| if (cachedModels) return filterUnavailableCopilotModels(cachedModels) | |
| const config = getGlobalConfig() | |
| const cached = config.copilotModelsCache | |
| if (shouldUseCachedCopilotModels(cached)) { | |
| cachedModels = cached.models | |
| return filterUnavailableCopilotModels(cachedModels) | |
| } | |
| const models = await fetchCopilotModels() | |
| cachedModels = models | |
| saveGlobalConfig(current => ({ | |
| ...current, | |
| copilotModelsCache: { models, fetchedAt: Date.now() }, | |
| })) | |
| return filterUnavailableCopilotModels(models) | |
| } | |
| export function getCopilotModelsCached(): CopilotModelInfo[] { | |
| if (cachedModels) { | |
| if (!hasCopilotEndpointMetadata(cachedModels)) { | |
| refreshCopilotModelsCacheInBackground() | |
| } | |
| return filterUnavailableCopilotModels(cachedModels) | |
| } | |
| const config = getGlobalConfig() | |
| const cached = config.copilotModelsCache | |
| if (cached && cached.models.length > 0) { | |
| cachedModels = cached.models | |
| if (!shouldUseCachedCopilotModels(cached)) { | |
| refreshCopilotModelsCacheInBackground() | |
| } | |
| return filterUnavailableCopilotModels(cachedModels) | |
| } | |
| refreshCopilotModelsCacheInBackground() | |
| return filterUnavailableCopilotModels(FALLBACK_COPILOT_MODELS) | |
| } | |
| export { FALLBACK_COPILOT_MODELS as COPILOT_MODELS } | |
| function getCachedCopilotCompatibility( | |
| modelId: string, | |
| ): CopilotCompatibilityInfo | undefined { | |
| const compatibility = getGlobalConfig().copilotCompatibilityCache?.[modelId] | |
| if (!compatibility) return undefined | |
| if (Date.now() - compatibility.updatedAt > COPILOT_COMPATIBILITY_CACHE_TTL_MS) { | |
| return undefined | |
| } | |
| return compatibility | |
| } | |
| function filterUnavailableCopilotModels( | |
| models: CopilotModelInfo[], | |
| ): CopilotModelInfo[] { | |
| return models.filter(model => { | |
| if (!supportsCopilotChatCompletions(model)) { | |
| return false | |
| } | |
| const compatibility = getCachedCopilotCompatibility( | |
| getCopilotModelId(model.id), | |
| ) | |
| return ( | |
| compatibility?.modelSupported !== false && | |
| compatibility?.chatCompletionsSupported !== false | |
| ) | |
| }) | |
| } | |
| function saveCopilotCompatibility( | |
| modelId: string, | |
| updates: Partial<CopilotCompatibilityInfo>, | |
| ): void { | |
| saveGlobalConfig(current => { | |
| const existing = current.copilotCompatibilityCache?.[modelId] | |
| const next = { | |
| ...existing, | |
| ...updates, | |
| updatedAt: Date.now(), | |
| } satisfies CopilotCompatibilityInfo | |
| if ( | |
| existing?.outputTokenParam === next.outputTokenParam && | |
| existing?.modelSupported === next.modelSupported && | |
| existing?.chatCompletionsSupported === next.chatCompletionsSupported | |
| ) { | |
| return current | |
| } | |
| return { | |
| ...current, | |
| copilotCompatibilityCache: { | |
| ...current.copilotCompatibilityCache, | |
| [modelId]: next, | |
| }, | |
| } | |
| }) | |
| } | |
| function getPreferredCopilotOutputTokenParam( | |
| modelId: string, | |
| ): CopilotOutputTokenParam { | |
| return ( | |
| getCachedCopilotCompatibility(modelId)?.outputTokenParam ?? | |
| 'max_tokens' | |
| ) | |
| } | |
| function savePreferredCopilotOutputTokenParam( | |
| modelId: string, | |
| outputTokenParam: CopilotOutputTokenParam, | |
| ): void { | |
| saveCopilotCompatibility(modelId, { outputTokenParam }) | |
| } | |
| function buildCopilotChatRequestBody(params: { | |
| modelId: string | |
| messages: OpenAIMessage[] | |
| isStreaming: boolean | |
| maxTokens?: number | |
| tools?: OpenAITool[] | |
| outputTokenParam: CopilotOutputTokenParam | |
| }): Record<string, unknown> { | |
| const requestBody: Record<string, unknown> = { | |
| model: params.modelId, | |
| messages: params.messages, | |
| stream: params.isStreaming, | |
| } | |
| if (params.maxTokens) { | |
| requestBody[params.outputTokenParam] = params.maxTokens | |
| } | |
| if (params.tools && params.tools.length > 0) { | |
| requestBody.tools = params.tools | |
| requestBody.tool_choice = 'auto' | |
| } | |
| return requestBody | |
| } | |
| async function createCopilotErrorInfo(response: Response): Promise<{ | |
| message?: string | |
| code?: string | |
| }> { | |
| const text = await response | |
| .clone() | |
| .text() | |
| .catch(() => '') | |
| if (!text) return {} | |
| try { | |
| const parsed = JSON.parse(text) as { | |
| error?: { message?: string; code?: string } | |
| } | |
| return { | |
| message: parsed.error?.message, | |
| code: parsed.error?.code, | |
| } | |
| } catch { | |
| return {} | |
| } | |
| } | |
| function createCachedCopilotErrorResponse( | |
| message: string, | |
| code: string, | |
| ): Response { | |
| return new Response(JSON.stringify({ error: { message, code } }), { | |
| status: 400, | |
| headers: { 'Content-Type': 'application/json' }, | |
| }) | |
| } | |
| function getSuggestedCopilotOutputTokenParam( | |
| currentParam: CopilotOutputTokenParam, | |
| errorMessage: string | undefined, | |
| ): CopilotOutputTokenParam | undefined { | |
| if (!errorMessage) return undefined | |
| if ( | |
| currentParam === 'max_tokens' && | |
| errorMessage.includes("Use 'max_completion_tokens' instead") | |
| ) { | |
| return 'max_completion_tokens' | |
| } | |
| if ( | |
| currentParam === 'max_completion_tokens' && | |
| errorMessage.includes("Use 'max_tokens' instead") | |
| ) { | |
| return 'max_tokens' | |
| } | |
| return undefined | |
| } | |
| async function postCopilotChatCompletion(params: { | |
| oauthToken: string | |
| requestBody: Record<string, unknown> | |
| signal?: AbortSignal | |
| }): Promise<Response> { | |
| return fetch(`${COPILOT_API_BASE}/chat/completions`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${params.oauthToken}`, | |
| 'User-Agent': 'claude-code/2.1.88', | |
| 'Openai-Intent': 'conversation-edits', | |
| 'x-initiator': 'user', | |
| }, | |
| body: JSON.stringify(params.requestBody), | |
| signal: params.signal, | |
| }) | |
| } | |
| function saveCopilotCompatibilityFromError( | |
| modelId: string, | |
| errorInfo: { code?: string }, | |
| ): void { | |
| if (errorInfo.code === 'model_not_supported') { | |
| saveCopilotCompatibility(modelId, { | |
| modelSupported: false, | |
| chatCompletionsSupported: false, | |
| }) | |
| } else if (errorInfo.code === 'unsupported_api_for_model') { | |
| saveCopilotCompatibility(modelId, { | |
| chatCompletionsSupported: false, | |
| }) | |
| } | |
| } | |
| async function sendCopilotChatCompletion(params: { | |
| oauthToken: string | |
| modelId: string | |
| messages: OpenAIMessage[] | |
| isStreaming: boolean | |
| maxTokens?: number | |
| tools?: OpenAITool[] | |
| signal?: AbortSignal | |
| }): Promise<Response> { | |
| const compatibility = getCachedCopilotCompatibility(params.modelId) | |
| if (compatibility?.modelSupported === false) { | |
| return createCachedCopilotErrorResponse( | |
| `Copilot model "${params.modelId}" was previously rejected as unsupported.`, | |
| 'model_not_supported', | |
| ) | |
| } | |
| if (compatibility?.chatCompletionsSupported === false) { | |
| return createCachedCopilotErrorResponse( | |
| `Copilot model "${params.modelId}" does not support the /chat/completions endpoint.`, | |
| 'unsupported_api_for_model', | |
| ) | |
| } | |
| let outputTokenParam = getPreferredCopilotOutputTokenParam(params.modelId) | |
| let requestBody = buildCopilotChatRequestBody({ | |
| modelId: params.modelId, | |
| messages: params.messages, | |
| isStreaming: params.isStreaming, | |
| maxTokens: params.maxTokens, | |
| tools: params.tools, | |
| outputTokenParam, | |
| }) | |
| let response = await postCopilotChatCompletion({ | |
| oauthToken: params.oauthToken, | |
| requestBody, | |
| signal: params.signal, | |
| }) | |
| if (response.ok) { | |
| saveCopilotCompatibility(params.modelId, { | |
| modelSupported: true, | |
| chatCompletionsSupported: true, | |
| outputTokenParam, | |
| }) | |
| return response | |
| } | |
| const errorInfo = await createCopilotErrorInfo(response) | |
| if (!params.maxTokens) { | |
| saveCopilotCompatibilityFromError(params.modelId, errorInfo) | |
| return response | |
| } | |
| const suggestedParam = getSuggestedCopilotOutputTokenParam( | |
| outputTokenParam, | |
| errorInfo.message, | |
| ) | |
| if (!suggestedParam) { | |
| saveCopilotCompatibilityFromError(params.modelId, errorInfo) | |
| return response | |
| } | |
| savePreferredCopilotOutputTokenParam(params.modelId, suggestedParam) | |
| outputTokenParam = suggestedParam | |
| requestBody = buildCopilotChatRequestBody({ | |
| modelId: params.modelId, | |
| messages: params.messages, | |
| isStreaming: params.isStreaming, | |
| maxTokens: params.maxTokens, | |
| tools: params.tools, | |
| outputTokenParam, | |
| }) | |
| response = await postCopilotChatCompletion({ | |
| oauthToken: params.oauthToken, | |
| requestBody, | |
| signal: params.signal, | |
| }) | |
| if (response.ok) { | |
| saveCopilotCompatibility(params.modelId, { | |
| modelSupported: true, | |
| chatCompletionsSupported: true, | |
| outputTokenParam, | |
| }) | |
| return response | |
| } | |
| saveCopilotCompatibilityFromError( | |
| params.modelId, | |
| await createCopilotErrorInfo(response), | |
| ) | |
| return response | |
| } | |
| type OpenAIMessage = { | |
| role: 'system' | 'user' | 'assistant' | 'tool' | |
| content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> | |
| tool_calls?: Array<{ | |
| id: string | |
| type: 'function' | |
| function: { name: string; arguments: string } | |
| }> | |
| tool_call_id?: string | |
| } | |
| type OpenAITool = { | |
| type: 'function' | |
| function: { | |
| name: string | |
| description: string | |
| parameters: Record<string, unknown> | |
| } | |
| } | |
| export type AnthropicContentBlock = | |
| | { type: 'text'; text: string } | |
| | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } | |
| | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> } | |
| | { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> } | |
| | { type: 'thinking'; thinking: string } | |
| | Record<string, unknown> | |
| export type AnthropicMessage = { | |
| role: 'user' | 'assistant' | |
| content: string | AnthropicContentBlock[] | |
| } | |
| export function convertAnthropicMessagesToOpenAI( | |
| messages: AnthropicMessage[], | |
| systemPrompt?: string, | |
| ): OpenAIMessage[] { | |
| const result: OpenAIMessage[] = [] | |
| if (systemPrompt) { | |
| result.push({ role: 'system', content: systemPrompt }) | |
| } | |
| for (const msg of messages) { | |
| if (typeof msg.content === 'string') { | |
| result.push({ role: msg.role, content: msg.content }) | |
| continue | |
| } | |
| if (msg.role === 'user') { | |
| const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = [] | |
| const toolResults: OpenAIMessage[] = [] | |
| for (const block of msg.content) { | |
| if (block.type === 'text') { | |
| parts.push({ type: 'text', text: (block as { type: 'text'; text: string }).text }) | |
| } else if (block.type === 'image') { | |
| const imgBlock = block as { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } | |
| parts.push({ | |
| type: 'image_url', | |
| image_url: { url: `data:${imgBlock.source.media_type};base64,${imgBlock.source.data}` }, | |
| }) | |
| } else if (block.type === 'tool_result') { | |
| const trBlock = block as { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> } | |
| let content = '' | |
| if (typeof trBlock.content === 'string') { | |
| content = trBlock.content | |
| } else if (Array.isArray(trBlock.content)) { | |
| content = trBlock.content | |
| .filter(c => c.type === 'text') | |
| .map(c => c.text || '') | |
| .join('\n') | |
| } | |
| toolResults.push({ | |
| role: 'tool', | |
| content, | |
| tool_call_id: trBlock.tool_use_id, | |
| }) | |
| } | |
| } | |
| if (toolResults.length > 0) { | |
| result.push(...toolResults) | |
| if (parts.length > 0) { | |
| result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts }) | |
| } | |
| } else if (parts.length > 0) { | |
| result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts }) | |
| } | |
| } else if (msg.role === 'assistant') { | |
| const textParts: string[] = [] | |
| const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = [] | |
| for (const block of msg.content) { | |
| if (block.type === 'text') { | |
| textParts.push((block as { type: 'text'; text: string }).text) | |
| } else if (block.type === 'tool_use') { | |
| const tuBlock = block as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> } | |
| toolCalls.push({ | |
| id: tuBlock.id, | |
| type: 'function', | |
| function: { | |
| name: tuBlock.name, | |
| arguments: JSON.stringify(tuBlock.input), | |
| }, | |
| }) | |
| } | |
| } | |
| const assistantMsg: OpenAIMessage = { | |
| role: 'assistant', | |
| content: textParts.join('\n') || '', | |
| } | |
| if (toolCalls.length > 0) { | |
| assistantMsg.tool_calls = toolCalls | |
| } | |
| result.push(assistantMsg) | |
| } | |
| } | |
| return result | |
| } | |
| export function convertAnthropicToolsToOpenAI( | |
| tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>, | |
| ): OpenAITool[] { | |
| return tools.map(tool => ({ | |
| type: 'function' as const, | |
| function: { | |
| name: tool.name, | |
| description: tool.description || '', | |
| parameters: tool.input_schema || { type: 'object', properties: {} }, | |
| }, | |
| })) | |
| } | |
| export type CopilotStreamEvent = | |
| | { type: 'message_start'; message: { id: string; type: 'message'; role: 'assistant'; model: string; content: []; usage: { input_tokens: number; output_tokens: number } } } | |
| | { type: 'content_block_start'; index: number; content_block: { type: 'text'; text: string } } | |
| | { type: 'content_block_delta'; index: number; delta: { type: 'text_delta'; text: string } } | |
| | { type: 'content_block_stop'; index: number } | |
| | { type: 'message_delta'; delta: { stop_reason: string }; usage: { output_tokens: number } } | |
| | { type: 'message_stop' } | |
| export async function* streamCopilotRequest( | |
| model: string, | |
| messages: AnthropicMessage[], | |
| systemPrompt: string | undefined, | |
| tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>, | |
| signal: AbortSignal, | |
| ): AsyncGenerator<CopilotStreamEvent> { | |
| const provider = getCopilotProvider() | |
| if (!provider?.oauthToken) { | |
| throw new Error('GitHub Copilot is not connected. Use /connect to authenticate.') | |
| } | |
| const copilotModelId = getCopilotModelId(model) | |
| const openaiMessages = convertAnthropicMessagesToOpenAI(messages, systemPrompt) | |
| const openaiTools = tools.length > 0 ? convertAnthropicToolsToOpenAI(tools) : undefined | |
| const response = await sendCopilotChatCompletion({ | |
| oauthToken: provider.oauthToken, | |
| modelId: copilotModelId, | |
| messages: openaiMessages, | |
| isStreaming: true, | |
| maxTokens: 16384, | |
| tools: openaiTools, | |
| signal, | |
| }) | |
| if (!response.ok) { | |
| const errorText = await response.text().catch(() => 'unknown error') | |
| throw new Error(`Copilot API error (${response.status}): ${errorText}`) | |
| } | |
| if (!response.body) { | |
| throw new Error('No response body from Copilot API') | |
| } | |
| const messageId = `msg_copilot_${Date.now()}` | |
| let contentIndex = 0 | |
| let hasStartedContent = false | |
| let currentToolCallIndex = -1 | |
| const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map() | |
| let totalOutputTokens = 0 | |
| yield { | |
| type: 'message_start', | |
| message: { | |
| id: messageId, | |
| type: 'message', | |
| role: 'assistant', | |
| model: copilotModelId, | |
| content: [], | |
| usage: { input_tokens: 0, output_tokens: 0 }, | |
| }, | |
| } | |
| const reader = response.body.getReader() | |
| const decoder = new TextDecoder() | |
| let buffer = '' | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| buffer += decoder.decode(value, { stream: true }) | |
| const lines = buffer.split('\n') | |
| buffer = lines.pop() || '' | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue | |
| const data = line.slice(6).trim() | |
| if (data === '[DONE]') { | |
| if (hasStartedContent) { | |
| yield { type: 'content_block_stop', index: contentIndex - 1 } | |
| } | |
| for (const [idx, tc] of toolCalls) { | |
| yield { | |
| type: 'content_block_stop', | |
| index: contentIndex + idx, | |
| } | |
| } | |
| yield { | |
| type: 'message_delta', | |
| delta: { stop_reason: toolCalls.size > 0 ? 'tool_use' : 'end_turn' }, | |
| usage: { output_tokens: totalOutputTokens }, | |
| } | |
| yield { type: 'message_stop' } | |
| return | |
| } | |
| let chunk: { | |
| choices?: Array<{ | |
| delta?: { | |
| content?: string | null | |
| tool_calls?: Array<{ | |
| index: number | |
| id?: string | |
| function?: { name?: string; arguments?: string } | |
| }> | |
| role?: string | |
| } | |
| finish_reason?: string | null | |
| }> | |
| usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number } | |
| } | |
| try { | |
| chunk = JSON.parse(data) | |
| } catch { | |
| continue | |
| } | |
| if (chunk.usage?.completion_tokens) { | |
| totalOutputTokens = chunk.usage.completion_tokens | |
| } | |
| const choice = chunk.choices?.[0] | |
| if (!choice?.delta) continue | |
| const delta = choice.delta | |
| if (delta.content != null && delta.content !== '') { | |
| if (!hasStartedContent) { | |
| hasStartedContent = true | |
| yield { | |
| type: 'content_block_start', | |
| index: contentIndex, | |
| content_block: { type: 'text', text: '' }, | |
| } | |
| } | |
| yield { | |
| type: 'content_block_delta', | |
| index: contentIndex, | |
| delta: { type: 'text_delta', text: delta.content }, | |
| } | |
| } | |
| if (delta.tool_calls) { | |
| for (const tc of delta.tool_calls) { | |
| if (tc.id) { | |
| if (hasStartedContent && currentToolCallIndex === -1) { | |
| yield { type: 'content_block_stop', index: contentIndex } | |
| contentIndex++ | |
| hasStartedContent = false | |
| } | |
| currentToolCallIndex = tc.index | |
| toolCalls.set(tc.index, { | |
| id: tc.id, | |
| name: tc.function?.name || '', | |
| arguments: tc.function?.arguments || '', | |
| }) | |
| const toolBlockIndex = hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index | |
| yield { | |
| type: 'content_block_start' as const, | |
| index: toolBlockIndex, | |
| content_block: { | |
| type: 'text', | |
| text: '', | |
| } as any, | |
| } | |
| } else if (tc.function?.arguments) { | |
| const existing = toolCalls.get(tc.index) | |
| if (existing) { | |
| existing.arguments += tc.function.arguments | |
| } | |
| } | |
| } | |
| } | |
| if (choice.finish_reason) { | |
| if (hasStartedContent) { | |
| yield { type: 'content_block_stop', index: contentIndex } | |
| } | |
| yield { | |
| type: 'message_delta', | |
| delta: { stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn' }, | |
| usage: { output_tokens: totalOutputTokens }, | |
| } | |
| yield { type: 'message_stop' } | |
| return | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock() | |
| } | |
| yield { | |
| type: 'message_delta', | |
| delta: { stop_reason: 'end_turn' }, | |
| usage: { output_tokens: totalOutputTokens }, | |
| } | |
| yield { type: 'message_stop' } | |
| } | |
| export function createCopilotFetchOverride( | |
| model: string, | |
| ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> { | |
| const provider = getCopilotProvider() | |
| if (!provider?.oauthToken) { | |
| throw new Error('GitHub Copilot is not connected') | |
| } | |
| const copilotModelId = getCopilotModelId(model) | |
| return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { | |
| const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url | |
| if (!url.includes('/messages') && !url.includes('/v1/')) { | |
| return fetch(input, init) | |
| } | |
| if (url.includes('/count_tokens') || url.includes('/models')) { | |
| return new Response(JSON.stringify({ input_tokens: 0 }), { | |
| status: 200, | |
| headers: { 'Content-Type': 'application/json' }, | |
| }) | |
| } | |
| let anthropicBody: Record<string, unknown> = {} | |
| if (init?.body) { | |
| try { | |
| anthropicBody = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer)) | |
| } catch { | |
| return fetch(input, init) | |
| } | |
| } | |
| const systemBlocks = anthropicBody.system as Array<{ type: string; text: string }> | string | undefined | |
| let systemPrompt = '' | |
| if (typeof systemBlocks === 'string') { | |
| systemPrompt = systemBlocks | |
| } else if (Array.isArray(systemBlocks)) { | |
| systemPrompt = systemBlocks | |
| .filter(b => b.type === 'text') | |
| .map(b => b.text) | |
| .join('\n\n') | |
| } | |
| const anthropicMessages = (anthropicBody.messages || []) as AnthropicMessage[] | |
| const openaiMessages = convertAnthropicMessagesToOpenAI(anthropicMessages, systemPrompt) | |
| const anthropicTools = (anthropicBody.tools || []) as Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }> | |
| const openaiTools = anthropicTools.length > 0 ? convertAnthropicToolsToOpenAI(anthropicTools) : undefined | |
| const isStreaming = anthropicBody.stream === true | |
| const maxTokens = | |
| typeof anthropicBody.max_tokens === 'number' | |
| ? anthropicBody.max_tokens | |
| : undefined | |
| const copilotResponse = await sendCopilotChatCompletion({ | |
| oauthToken: provider.oauthToken, | |
| modelId: copilotModelId, | |
| messages: openaiMessages, | |
| isStreaming, | |
| maxTokens, | |
| tools: openaiTools, | |
| signal: init?.signal, | |
| }) | |
| if (!copilotResponse.ok) { | |
| return copilotResponse | |
| } | |
| if (!isStreaming) { | |
| const data = await copilotResponse.json() as { | |
| id: string | |
| choices: Array<{ | |
| message: { | |
| role: string | |
| content: string | null | |
| tool_calls?: Array<{ | |
| id: string | |
| function: { name: string; arguments: string } | |
| }> | |
| } | |
| finish_reason: string | |
| }> | |
| usage?: { prompt_tokens: number; completion_tokens: number } | |
| } | |
| const choice = data.choices[0] | |
| const anthropicContent: Array<{ | |
| type: string | |
| text?: string | |
| id?: string | |
| name?: string | |
| input?: unknown | |
| }> = [] | |
| if (choice?.message?.content) { | |
| anthropicContent.push({ type: 'text', text: choice.message.content }) | |
| } | |
| if (choice?.message?.tool_calls) { | |
| for (const tc of choice.message.tool_calls) { | |
| anthropicContent.push({ | |
| type: 'tool_use', | |
| id: tc.id, | |
| name: tc.function.name, | |
| input: JSON.parse(tc.function.arguments || '{}'), | |
| }) | |
| } | |
| } | |
| const anthropicResponse = { | |
| id: data.id || `msg_copilot_${Date.now()}`, | |
| type: 'message', | |
| role: 'assistant', | |
| content: anthropicContent, | |
| model: copilotModelId, | |
| stop_reason: choice?.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn', | |
| usage: { | |
| input_tokens: data.usage?.prompt_tokens || 0, | |
| output_tokens: data.usage?.completion_tokens || 0, | |
| }, | |
| } | |
| return new Response(JSON.stringify(anthropicResponse), { | |
| status: 200, | |
| headers: { 'Content-Type': 'application/json' }, | |
| }) | |
| } | |
| const transformStream = convertOpenAIStreamToAnthropic(copilotResponse.body!, copilotModelId) | |
| return new Response(transformStream, { | |
| status: 200, | |
| headers: { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| Connection: 'keep-alive', | |
| }, | |
| }) | |
| } | |
| } | |
| export function convertOpenAIStreamToAnthropic( | |
| openaiStream: ReadableStream, | |
| model: string, | |
| ): ReadableStream<Uint8Array> { | |
| const encoder = new TextEncoder() | |
| const decoder = new TextDecoder() | |
| let messageId = `msg_${Date.now()}` | |
| let contentIndex = 0 | |
| let hasStartedContent = false | |
| let currentToolCallIndex = -1 | |
| const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map() | |
| let totalOutputTokens = 0 | |
| return new ReadableStream({ | |
| async start(controller) { | |
| const reader = openaiStream.getReader() | |
| let buffer = '' | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read() | |
| if (done) break | |
| buffer += decoder.decode(value, { stream: true }) | |
| const lines = buffer.split('\n') | |
| buffer = lines.pop() || '' | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue | |
| const data = line.slice(6).trim() | |
| if (data === '[DONE]') { | |
| if (hasStartedContent) { | |
| controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`)) | |
| } | |
| for (const [idx, tc] of toolCalls) { | |
| controller.enqueue( | |
| encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`), | |
| ) | |
| } | |
| controller.enqueue( | |
| encoder.encode( | |
| `event: message_delta\ndata: {"delta":{"stop_reason":"${toolCalls.size > 0 ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`, | |
| ), | |
| ) | |
| controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n')) | |
| return | |
| } | |
| let chunk: { | |
| choices?: Array<{ | |
| delta?: { | |
| content?: string | null | |
| tool_calls?: Array<{ | |
| index: number | |
| id?: string | |
| function?: { name?: string; arguments?: string } | |
| }> | |
| role?: string | |
| } | |
| finish_reason?: string | null | |
| }> | |
| usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number } | |
| } | |
| try { | |
| chunk = JSON.parse(data) | |
| } catch { | |
| continue | |
| } | |
| if (chunk.usage?.completion_tokens) { | |
| totalOutputTokens = chunk.usage.completion_tokens | |
| } | |
| const choice = chunk.choices?.[0] | |
| if (!choice?.delta) continue | |
| const delta = choice.delta | |
| if (delta.content != null && delta.content !== '') { | |
| if (!hasStartedContent) { | |
| hasStartedContent = true | |
| controller.enqueue( | |
| encoder.encode( | |
| `event: content_block_start\ndata: {"index":${contentIndex},"content_block":{"type":"text","text":""}}\n\n`, | |
| ), | |
| ) | |
| } | |
| controller.enqueue( | |
| encoder.encode( | |
| `event: content_block_delta\ndata: {"index":${contentIndex},"delta":{"type":"text_delta","text":"${JSON.stringify(delta.content).slice(1, -1)}"}}\n\n`, | |
| ), | |
| ) | |
| } | |
| if (delta.tool_calls) { | |
| for (const tc of delta.tool_calls) { | |
| if (tc.id) { | |
| if (hasStartedContent && currentToolCallIndex === -1) { | |
| controller.enqueue( | |
| encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`), | |
| ) | |
| contentIndex++ | |
| hasStartedContent = false | |
| } | |
| currentToolCallIndex = tc.index | |
| toolCalls.set(tc.index, { | |
| id: tc.id, | |
| name: tc.function?.name || '', | |
| arguments: tc.function?.arguments || '', | |
| }) | |
| const toolBlockIndex = | |
| hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index | |
| controller.enqueue( | |
| encoder.encode( | |
| `event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"text","text":""}}\n\n`, | |
| ), | |
| ) | |
| } else if (tc.function?.arguments) { | |
| const existing = toolCalls.get(tc.index) | |
| if (existing) { | |
| existing.arguments += tc.function.arguments | |
| } | |
| } | |
| } | |
| } | |
| if (choice.finish_reason) { | |
| if (hasStartedContent) { | |
| controller.enqueue( | |
| encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`), | |
| ) | |
| } | |
| controller.enqueue( | |
| encoder.encode( | |
| `event: message_delta\ndata: {"delta":{"stop_reason":"${choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`, | |
| ), | |
| ) | |
| controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n')) | |
| return | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock() | |
| controller.close() | |
| } | |
| }, | |
| }) | |
| } |