codev / src /services /api /copilotClient.ts
chenbhao's picture
fix: tui can recevie message and auto load gateway
2d01c57
Raw
History Blame Contribute Delete
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()
}
},
})
}