Skip to content

Commit 6c332a7

Browse files
committed
feat(llm): add github copilot models support
1 parent 564fc35 commit 6c332a7

File tree

6 files changed

+481
-104
lines changed

6 files changed

+481
-104
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,5 @@ src/main/gen
146146
**/.DS_Store
147147
.intellijPlatform
148148
**/bin/**
149-
.kotlin
149+
.kotlin
150+
/core/src/test/kotlin/cc/unitmesh/devti/llm2/copilot.http
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package cc.unitmesh.devti.llm2
2+
3+
import cc.unitmesh.devti.llm2.model.CopilotModelsResponse
4+
import cc.unitmesh.devti.llms.custom.CustomRequest
5+
import cc.unitmesh.devti.llms.custom.Message
6+
import cc.unitmesh.devti.llms.custom.updateCustomFormat
7+
import com.intellij.openapi.diagnostic.Logger
8+
import com.intellij.openapi.project.Project
9+
import com.jayway.jsonpath.JsonPath
10+
import kotlinx.coroutines.channels.awaitClose
11+
import kotlinx.coroutines.flow.Flow
12+
import kotlinx.coroutines.flow.callbackFlow
13+
import kotlinx.datetime.Clock
14+
import kotlinx.datetime.Instant
15+
import kotlinx.serialization.json.Json
16+
import okhttp3.MediaType.Companion.toMediaTypeOrNull
17+
import okhttp3.OkHttpClient
18+
import okhttp3.Request
19+
import okhttp3.RequestBody.Companion.toRequestBody
20+
import java.io.File
21+
22+
23+
private data class ApiToken(
24+
val apiKey: String,
25+
val expiresAt: Instant, // 使用某种 DateTime 类型
26+
) {
27+
// 计算令牌的剩余有效时间(秒)
28+
fun remainingSeconds(): Long {
29+
val now = Clock.System.now()
30+
return expiresAt.epochSeconds - now.epochSeconds
31+
}
32+
}
33+
34+
private object GithubOAuthProvider {
35+
private val logger = Logger.getInstance(GithubOAuthProvider::class.java)
36+
37+
private var oauthToken: String? = null
38+
private var apiToken: ApiToken? = null
39+
private var supportedModels: CopilotModelsResponse? = null
40+
41+
private fun extractOauthToken(): String? {
42+
val configDir = getConfigDir()
43+
val appsFile = configDir.resolve("apps.json")
44+
45+
if (!appsFile.exists()) {
46+
return null
47+
}
48+
49+
return appsFile.readText().let {
50+
val arr: List<String>? = JsonPath.parse(it)?.read("$..oauth_token")
51+
arr?.lastOrNull()
52+
}
53+
}
54+
55+
/**
56+
* 获取GitHub Copilot支持的模型列表
57+
*
58+
* @param client HTTP客户端
59+
* @param forceRefresh 是否强制刷新缓存的模型列表
60+
* @return 支持的模型列表,如果获取失败则返回null
61+
*/
62+
fun getSupportedModels(client: OkHttpClient, forceRefresh: Boolean = false): CopilotModelsResponse? {
63+
val currentApiToken = apiToken ?: requestApiToken(client) ?: return null
64+
65+
// 如果已经有缓存的模型列表且不需要强制刷新,则直接返回缓存的结果
66+
if (!forceRefresh && supportedModels != null) {
67+
return supportedModels
68+
}
69+
70+
val request = Request.Builder()
71+
.url("https://api.githubcopilot.com/models")
72+
.addHeader("Authorization", "Bearer ${currentApiToken.apiKey}")
73+
.addHeader("Editor-Version", "Neovim/0.6.1")
74+
.addHeader("Content-Type", "application/json")
75+
.addHeader("Copilot-Integration-Id", "vscode-chat")
76+
.build()
77+
78+
return try {
79+
val response = client.newCall(request).execute()
80+
if (response.isSuccessful) {
81+
val responseBody = response.body?.string() ?: throw IllegalStateException("响应体为空")
82+
// 使用kotlinx.serialization解析JSON响应
83+
val json = Json { ignoreUnknownKeys = true }
84+
json.decodeFromString<CopilotModelsResponse>(responseBody).also {
85+
supportedModels = it
86+
}
87+
} else {
88+
logger.warn("获取支持的模型列表失败: ${response.code}")
89+
null
90+
}
91+
} catch (e: Exception) {
92+
logger.warn("获取支持的模型列表时发生异常", e)
93+
null
94+
}
95+
}
96+
97+
fun requestApiToken(client: OkHttpClient): ApiToken? {
98+
if (oauthToken == null) {
99+
oauthToken = extractOauthToken()
100+
}
101+
if (oauthToken == null) {
102+
logger.warn("oauthToken not exists")
103+
return null
104+
}
105+
106+
val currentApiToken = apiToken
107+
if (currentApiToken != null && currentApiToken.remainingSeconds() >= 5 * 60) {
108+
return currentApiToken
109+
}
110+
111+
val request = Request.Builder()
112+
.url("https://api.github.com/copilot_internal/v2/token")
113+
.addHeader("Authorization", "token $oauthToken")
114+
.addHeader("Accept", "application/json")
115+
.build()
116+
117+
val response = client.newCall(request).execute()
118+
if (response.isSuccessful) {
119+
val responseBody = response.body?.string() ?: throw IllegalStateException("响应体为空")
120+
val tokenResponse = JsonPath.parse(responseBody) ?: throw IllegalStateException("解析响应失败")
121+
val apiKey: String = tokenResponse.read("$.token") ?: throw IllegalStateException("解析 token 失败")
122+
val expiresAt: Int =
123+
tokenResponse.read("$.expires_at") ?: throw IllegalStateException("解析 expiresAt 失败")
124+
return ApiToken(
125+
apiKey = apiKey,
126+
expiresAt = Instant.fromEpochSeconds(expiresAt.toLong())
127+
).also {
128+
apiToken = it
129+
}
130+
} else {
131+
val errorBody = response.body?.string() ?: throw IllegalStateException("响应体为空")
132+
throw IllegalStateException("获取 API 令牌失败: $errorBody")
133+
}
134+
}
135+
136+
private fun getConfigDir(): File {
137+
// mac only TODO: windows
138+
139+
// 获取用户的 home 目录
140+
val homeDir = System.getProperty("user.home")
141+
return File("${homeDir}/.config/github-copilot/")
142+
}
143+
}
144+
145+
class GithubCopilotProvider(
146+
requestCustomize: String,
147+
responseResolver: String,
148+
project: Project? = null,
149+
) : LLMProvider2(project, requestCustomize = requestCustomize, responseResolver = responseResolver) {
150+
151+
override fun textComplete(
152+
session: ChatSession<Message>,
153+
stream: Boolean,
154+
): Flow<SessionMessageItem<Message>> = callbackFlow {
155+
156+
val apiToken = GithubOAuthProvider.requestApiToken(client) ?: throw IllegalStateException("获取 API 令牌失败")
157+
val customRequest = CustomRequest(session.chatHistory.map {
158+
val cm = it.chatMessage
159+
Message(cm.role, cm.content)
160+
})
161+
val requestBodyText = customRequest.updateCustomFormat(requestCustomize)
162+
val content = requestBodyText.toByteArray()
163+
val requestBody = content.toRequestBody("application/json".toMediaTypeOrNull(), 0, content.size)
164+
val request = Request.Builder()
165+
.url("https://api.githubcopilot.com/chat/completions")
166+
.addHeader("Authorization", "Bearer ${apiToken.apiKey}")
167+
.addHeader("Content-Type", "application/json")
168+
.addHeader("Editor-Version", "Zed/Unknow")
169+
.addHeader("Copilot-Integration-Id", "vscode-chat")
170+
.post(requestBody)
171+
.build()
172+
173+
if (stream) {
174+
sseStream(
175+
client,
176+
request,
177+
onFailure = {
178+
close(it)
179+
},
180+
onClosed = {
181+
close()
182+
},
183+
onEvent = {
184+
trySend(it)
185+
}
186+
)
187+
} else {
188+
kotlin.runCatching {
189+
val result = directResult(client, request)
190+
trySend(result)
191+
close()
192+
}.onFailure {
193+
close(it)
194+
}
195+
}
196+
awaitClose()
197+
}
198+
199+
200+
private val client: OkHttpClient = OkHttpClient.Builder()
201+
.build()
202+
203+
// private suspend fun monitorConfigFiles() {
204+
// val configDir = getConfigDir() // ~/.config/github-copilot 或类似路径
205+
// val configFiles = setOf(
206+
// configDir.resolve("apps.json")
207+
// )
208+
//
209+
// // 监听配置目录变更
210+
// val changes = watchConfigDir(configDir, configFiles)
211+
//
212+
// // 处理变更事件
213+
// changes.collect { _ ->
214+
// val token = extractOauthToken()
215+
// oauthToken = token
216+
// // TODO: 这里可以添加逻辑来处理令牌的更新,例如通知其他组件
217+
//// notifyListeners()
218+
// }
219+
// }
220+
// 监听 [dir] 变化,返回一个发出文件内容变更的Flow
221+
// private fun watchConfigDir(dir: File, files: Set<File>): Flow<Map<File, String>> = flow {
222+
// val messageBus = project?.messageBus ?: return@flow
223+
//
224+
// // 确保目录存在
225+
// if (!dir.exists()) {
226+
// dir.mkdirs()
227+
// }
228+
//
229+
// val connection = messageBus.connect()
230+
// try {
231+
// // 创建一个MutableStateFlow用于存储当前文件内容
232+
// val contentsFlow = MutableStateFlow(readInitialContents(files))
233+
//
234+
// // 监听文件系统变化
235+
// connection.subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
236+
// override fun after(events: List<VFileEvent>) {
237+
// val changedFiles = events
238+
// .filter { event ->
239+
// val file = event.file
240+
// file != null && files.any { it.path == file.path }
241+
// }
242+
// .mapNotNull { it.file }
243+
//
244+
// if (changedFiles.isNotEmpty()) {
245+
// val updatedContents = contentsFlow.value.toMutableMap()
246+
// changedFiles.forEach { vFile ->
247+
// val correspondingFile = files.find { it.path == vFile.path }
248+
// if (correspondingFile != null) {
249+
// try {
250+
// updatedContents[correspondingFile] =
251+
// vFile.contentsToByteArray().toString(Charsets.UTF_8)
252+
// } catch (e: Exception) {
253+
// Logger.getInstance(GithubCopilotProvider::class.java)
254+
// .warn("读取文件内容失败: ${vFile.path}", e)
255+
// }
256+
// }
257+
// }
258+
// contentsFlow.value = updatedContents
259+
// }
260+
// }
261+
// })
262+
//
263+
// // 收集内容变更并发出
264+
// contentsFlow.collect { emit(it) }
265+
// } finally {
266+
// connection.disconnect()
267+
// }
268+
// }
269+
//
270+
// // 读取初始文件内容
271+
// private fun readInitialContents(files: Set<File>): Map<File, String> {
272+
// return files.associateWith { file ->
273+
// try {
274+
// if (file.exists()) {
275+
// file.readText()
276+
// } else {
277+
// ""
278+
// }
279+
// } catch (e: Exception) {
280+
// Logger.getInstance(GithubCopilotProvider::class.java)
281+
// .warn("读取文件内容失败: ${file.path}", e)
282+
// ""
283+
// }
284+
// }
285+
// }
286+
}
287+

0 commit comments

Comments
 (0)