Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions src/agent/infra/llm/providers/github-copilot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {createOpenAICompatible} from '@ai-sdk/openai-compatible'

import type {GeneratorFactoryConfig, ProviderModule} from './types.js'

import {COPILOT_API_BASE_URL, COPILOT_REQUEST_HEADERS} from '../../../../shared/constants/copilot.js'
import {AiSdkContentGenerator} from '../generators/ai-sdk-content-generator.js'

export function isCopilotClaudeModel(model: string): boolean {
return model.startsWith('claude-')
}

export const githubCopilotProvider: ProviderModule = {
authType: 'api-key',
category: 'popular',
createGenerator(config: GeneratorFactoryConfig) {
const baseUrl = config.baseUrl || COPILOT_API_BASE_URL
const apiKey = config.apiKey || ''
const headers = {
...config.headers,
...COPILOT_REQUEST_HEADERS,
}

const provider = createOpenAICompatible({
apiKey,
baseURL: baseUrl,
headers,
name: 'github-copilot',
})

return new AiSdkContentGenerator({
charsPerToken: isCopilotClaudeModel(config.model) ? 3.5 : undefined,
model: provider.chatModel(config.model),
})
},
defaultModel: 'claude-sonnet-4.6',
description: 'All models via GitHub Copilot subscription',
envVars: [],
id: 'github-copilot',
name: 'GitHub Copilot',
priority: 8,
providerType: 'openai',
}
2 changes: 2 additions & 0 deletions src/agent/infra/llm/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {byteroverProvider} from './byterover.js'
import {cerebrasProvider} from './cerebras.js'
import {cohereProvider} from './cohere.js'
import {deepinfraProvider} from './deepinfra.js'
import {githubCopilotProvider} from './github-copilot.js'
import {glmProvider} from './glm.js'
import {googleProvider} from './google.js'
import {groqProvider} from './groq.js'
Expand All @@ -38,6 +39,7 @@ const PROVIDER_MODULES: Readonly<Record<string, ProviderModule>> = {
cerebras: cerebrasProvider,
cohere: cohereProvider,
deepinfra: deepinfraProvider,
'github-copilot': githubCopilotProvider,
glm: glmProvider,
google: googleProvider,
groq: groqProvider,
Expand Down
21 changes: 18 additions & 3 deletions src/oclif/commands/providers/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export default class ProviderConnect extends Command {
throw new Error(`Provider "${providerId}" does not support OAuth. Use --api-key instead.`)
}

// --code is only valid for code-paste providers (e.g., Anthropic).
// Browser-callback providers like OpenAI handle the code exchange automatically.
if (code && provider.oauthCallbackMode !== 'code-paste') {
throw new Error(
`Provider "${providerId}" uses browser-based OAuth and does not accept --code.\n` +
Expand All @@ -185,10 +187,22 @@ export default class ProviderConnect extends Command {
throw new Error(startResponse.error ?? 'Failed to start OAuth flow')
}

onProgress?.(`\nOpen this URL to authenticate:\n ${startResponse.authUrl}\n`)
// Always print auth URL (user's machine may not support browser launch)
if (startResponse.callbackMode === 'device') {
onProgress?.(`\nOpen this URL and enter the code below:`)
onProgress?.(` ${startResponse.verificationUri ?? startResponse.authUrl}`)
onProgress?.(`\n Code: ${startResponse.userCode}\n`)
onProgress?.('Waiting for authorization...')
} else {
onProgress?.(`\nOpen this URL to authenticate:\n ${startResponse.authUrl}\n`)
}

// 3. Handle based on callback mode
if (startResponse.callbackMode === 'auto' || startResponse.callbackMode === 'device') {
if (startResponse.callbackMode === 'auto') {
onProgress?.('Waiting for authentication in browser...')
}

if (startResponse.callbackMode === 'auto') {
onProgress?.('Waiting for authentication in browser...')
const awaitResponse = await client.requestWithAck<ProviderAwaitOAuthCallbackResponse>(
ProviderEvents.AWAIT_OAUTH_CALLBACK,
{providerId},
Expand All @@ -201,6 +215,7 @@ export default class ProviderConnect extends Command {
return {providerName: provider.name, showInstructions: false}
}

// code-paste mode: print instructions and exit
onProgress?.('Copy the authorization code from the browser and run:')
onProgress?.(` brv providers connect ${providerId} --oauth --code <code>`)
return {providerName: provider.name, showInstructions: true}
Expand Down
26 changes: 23 additions & 3 deletions src/server/core/domain/entities/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ export interface OAuthModeConfig {
* OAuth configuration for a provider.
*/
export interface ProviderOAuthConfig {
/** How the callback is received: local server ('auto') or user pastes code ('code-paste') */
readonly callbackMode: 'auto' | 'code-paste'
/** How the callback is received: local server ('auto'), user pastes code ('code-paste'), or device flow polling ('device') */
readonly callbackMode: 'auto' | 'code-paste' | 'device'
/** Port for local callback server (auto mode only) */
readonly callbackPort?: number
/** OAuth client ID */
Expand Down Expand Up @@ -144,6 +144,26 @@ export const PROVIDER_REGISTRY: Readonly<Record<string, ProviderDefinition>> = {
name: 'DeepInfra',
priority: 10,
},
'github-copilot': {
baseUrl: 'https://api.githubcopilot.com',
category: 'popular',
defaultModel: 'claude-sonnet-4.6',
description: 'All models via GitHub Copilot subscription',
headers: {},
id: 'github-copilot',
modelsEndpoint: '/models',
name: 'GitHub Copilot',
oauth: {
callbackMode: 'device',
clientId: 'Iv1.b507a08c87ecfe98',
modes: [{authUrl: 'https://github.com/login/device', id: 'default', label: 'Sign in with GitHub'}],
redirectUri: '',
scopes: 'read:user',
tokenContentType: 'json',
tokenUrl: '',
},
priority: 8,
},
glm: {
apiKeyUrl: 'https://open.z.ai',
baseUrl: 'https://api.z.ai/api/paas/v4',
Expand Down Expand Up @@ -375,7 +395,7 @@ export function providerRequiresApiKey(id: string, authMethod?: 'api-key' | 'oau
if (!provider) return false
// Internal providers (byterover) don't need API keys.
// OpenAI Compatible has optional API key (handled in provider-command).
if (id === 'byterover' || id === 'openai-compatible') return false
if (id === 'byterover' || id === 'openai-compatible' || id === 'github-copilot') return false

return true
}
7 changes: 7 additions & 0 deletions src/server/infra/http/provider-model-fetcher-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {FileProviderConfigStore} from '../storage/file-provider-config-store.js'
import {
AnthropicModelFetcher,
ChatBasedModelFetcher,
CopilotModelFetcher,
GoogleModelFetcher,
OpenAICompatibleModelFetcher,
OpenAIModelFetcher,
Expand Down Expand Up @@ -75,6 +76,12 @@ export async function getModelFetcher(providerId: string): Promise<IProviderMode
break
}

case 'github-copilot': {
fetcher = new CopilotModelFetcher()

break
}

case 'glm': {
fetcher = new ChatBasedModelFetcher(
'https://api.z.ai/api/paas/v4',
Expand Down
87 changes: 87 additions & 0 deletions src/server/infra/http/provider-model-fetchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
ProviderModelInfo,
} from '../../core/interfaces/i-provider-model-fetcher.js'

import {COPILOT_API_BASE_URL, COPILOT_REQUEST_HEADERS} from '../../../shared/constants/copilot.js'
import {getModelsDevClient as getModelsDevClientDefault, type ModelsDevClient} from './models-dev-client.js'
import {ProxyConfig} from './proxy-config.js'

Expand Down Expand Up @@ -544,6 +545,92 @@ export class ChatBasedModelFetcher implements IProviderModelFetcher {
}
}

// ============================================================================
// Copilot Model Fetcher
// ============================================================================

/**
* Model fetcher for GitHub Copilot.
* Queries the Copilot API for available models using the session token.
*/
export class CopilotModelFetcher implements IProviderModelFetcher {
private cache: ModelCache | undefined
private readonly cacheTtlMs: number

constructor(cacheTtlMs = DEFAULT_CACHE_TTL) {
this.cacheTtlMs = cacheTtlMs
}

async fetchModels(apiKey: string, options?: FetchModelsOptions): Promise<ProviderModelInfo[]> {
const forceRefresh = options?.forceRefresh ?? false
if (!forceRefresh && this.cache && Date.now() - this.cache.timestamp < this.cacheTtlMs) {
return this.cache.models
}

const response = await axios.get(`${COPILOT_API_BASE_URL}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
...COPILOT_REQUEST_HEADERS,
},
httpAgent: ProxyConfig.getProxyAgent(),
httpsAgent: ProxyConfig.getProxyAgent(),
proxy: false,
timeout: 30_000,
})

const responseData = response.data
const modelList: Array<Record<string, unknown>> = Array.isArray(responseData)
? responseData
: (responseData.data ?? responseData.models ?? [])

const models: ProviderModelInfo[] = modelList.map((model) => {
const id = String(model.id ?? model.name ?? '')
return {
contextLength: typeof model.context_length === 'number' ? model.context_length : 200_000,
id,
isFree: false,
name: typeof model.name === 'string' ? model.name : id,
pricing: {inputPerM: 0, outputPerM: 0},
provider: 'GitHub Copilot',
}
})

models.sort((a, b) => a.id.localeCompare(b.id))
this.cache = {models, timestamp: Date.now()}
return models
}

async validateApiKey(apiKey: string): Promise<{error?: string; isValid: boolean}> {
try {
await axios.get(`${COPILOT_API_BASE_URL}/models`, {
headers: {
Authorization: `Bearer ${apiKey}`,
...COPILOT_REQUEST_HEADERS,
},
httpAgent: ProxyConfig.getProxyAgent(),
httpsAgent: ProxyConfig.getProxyAgent(),
proxy: false,
timeout: 15_000,
})
return {isValid: true}
} catch (error) {
if (isAxiosError(error)) {
if (error.response?.status === 401) {
return {error: 'Invalid or expired Copilot token', isValid: false}
}

if (error.response?.status === 403) {
return {error: 'Copilot token does not have required permissions', isValid: false}
}

return {error: `API error: ${error.response?.statusText ?? error.message}`, isValid: false}
}

return {error: error instanceof Error ? error.message : 'Unknown error', isValid: false}
}
}
}

// ============================================================================
// OpenRouter Model Fetcher (wraps existing client)
// ============================================================================
Expand Down
Loading
Loading