@x-otto/plugin-github-copilot 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/otto-plugin.json +35 -0
- package/package.json +25 -0
- package/plugin-dist/meta.json +1 -0
- package/plugin-dist/plugin.cjs +53002 -0
- package/plugin-dist/plugin.js +11181 -0
- package/plugin.ts +35 -0
- package/src/provider.test.ts +273 -0
- package/src/provider.ts +150 -0
package/plugin.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugin.ts —— GitHub Copilot 代码式 provider 工厂。
|
|
3
|
+
*
|
|
4
|
+
* GitHub Copilot 走不了声明式 `contributes.providers` 轴(`wireApi` schema 只覆盖
|
|
5
|
+
* openai-completions/openai-responses/anthropic-messages 三种通用协议)——Copilot 协议是
|
|
6
|
+
* 定制的:vision 场景动态请求头(`Copilot-Vision-Request`)、initiator 判断(`X-Initiator`
|
|
7
|
+
* 依据最后一条消息角色)、`copilot-integration-id`/`editor-version` 等专属请求头。
|
|
8
|
+
*
|
|
9
|
+
* RFC-140 架构下沉:具体的 Copilot 协议实现(`createCopilotProvider`)已物理迁移至本插件
|
|
10
|
+
* 目录的 `src/provider.ts`(不再驻留在 `@x-otto/provider` 通用契约包——它是 copilot 一家
|
|
11
|
+
* 厂商的专属实现,唯一消费方是本插件)。复用 `@x-otto/provider` 导出的 `streamChatCompletions`
|
|
12
|
+
* 协议基座(chat-completions SSE 解析循环单一实现,RFC-074 R-AICHAT),不复用整个 provider。
|
|
13
|
+
*
|
|
14
|
+
* OAuth 走声明式 `contributes.oauth`(`kind: 'device-flow'`,见 `otto-plugin.json`)——
|
|
15
|
+
* device-flow 流程本身与厂商协议无关,通用 `DeviceFlowOAuthTemplate` 已覆盖,不需要代码。
|
|
16
|
+
*
|
|
17
|
+
* 凭据解析:`ctx.resolveProviderCredential('github-copilot')` 由宿主装载器注入,内部转发
|
|
18
|
+
* `ProviderRegistry.resolveAccessKey` → `AuthStore.getCredentialKey`,每次请求时动态取
|
|
19
|
+
* 当前有效 token(而非构造时固定死一份,因为 OAuth token 会刷新轮换)。
|
|
20
|
+
*
|
|
21
|
+
* `codeProviderIds: ["github-copilot"]`(manifest 声明)让本工厂注册为裸 `github-copilot`
|
|
22
|
+
* api id(而非 `plugin-github-copilot:github-copilot`),与系统里既有的凭据槽/模型数据/
|
|
23
|
+
* `apiCredentialId()` 等硬编码裸 id 引用点保持兼容——现有用户凭据/模型缓存零迁移成本。
|
|
24
|
+
*/
|
|
25
|
+
import { definePlugin } from '@x-otto/plugin'
|
|
26
|
+
import { createCopilotProvider } from './src/provider'
|
|
27
|
+
|
|
28
|
+
export default definePlugin((ctx) => ({
|
|
29
|
+
providerFactories: {
|
|
30
|
+
'github-copilot': () =>
|
|
31
|
+
createCopilotProvider({
|
|
32
|
+
resolveOAuthAccessKey: () => ctx.resolveProviderCredential('github-copilot').then((k) => k ?? undefined),
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
}))
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createCopilotProvider,
|
|
5
|
+
inferCopilotInitiator,
|
|
6
|
+
hasCopilotVisionInput,
|
|
7
|
+
buildCopilotDynamicHeaders,
|
|
8
|
+
} from './provider'
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AssistantMessage,
|
|
12
|
+
Message,
|
|
13
|
+
Model,
|
|
14
|
+
ToolResultMessage,
|
|
15
|
+
UserMessage,
|
|
16
|
+
} from '@x-otto/provider'
|
|
17
|
+
|
|
18
|
+
function makeModel(): Model {
|
|
19
|
+
return {
|
|
20
|
+
id: 'github-copilot/gpt-4.1',
|
|
21
|
+
name: 'gpt-4.1',
|
|
22
|
+
api: 'github-copilot',
|
|
23
|
+
provider: 'github-copilot',
|
|
24
|
+
baseUrl: 'https://api.githubcopilot.com',
|
|
25
|
+
reasoning: false,
|
|
26
|
+
input: ['text'],
|
|
27
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
28
|
+
contextWindow: 128000,
|
|
29
|
+
maxOutputTokens: 4096,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeUserMessage(content: UserMessage['content']): UserMessage {
|
|
34
|
+
return {
|
|
35
|
+
role: 'user',
|
|
36
|
+
content,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeAssistantMessage(content: AssistantMessage['content']): AssistantMessage {
|
|
42
|
+
return {
|
|
43
|
+
role: 'assistant',
|
|
44
|
+
content,
|
|
45
|
+
api: 'github-copilot',
|
|
46
|
+
provider: 'github-copilot',
|
|
47
|
+
model: 'github-copilot/gpt-4.1',
|
|
48
|
+
usage: {
|
|
49
|
+
inputTokens: 1,
|
|
50
|
+
outputTokens: 1,
|
|
51
|
+
cacheReadTokens: 0,
|
|
52
|
+
cacheWriteTokens: 0,
|
|
53
|
+
},
|
|
54
|
+
stopReason: 'end_turn',
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeToolResultMessage(content: ToolResultMessage['content']): ToolResultMessage {
|
|
59
|
+
return {
|
|
60
|
+
role: 'tool_result',
|
|
61
|
+
toolCallId: 'tc1',
|
|
62
|
+
toolName: 'read_file',
|
|
63
|
+
content,
|
|
64
|
+
details: null,
|
|
65
|
+
isError: false,
|
|
66
|
+
timestamp: Date.now(),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe('GitHub Copilot Provider', () => {
|
|
71
|
+
// RFC-140 D5:resolveKey 统一为接口方法 resolveAuth(model): Promise<ResolvedAuth>。
|
|
72
|
+
it('resolveAuth uses resolveOAuthAccessKey when provided, returns oauth mode', async () => {
|
|
73
|
+
const provider = createCopilotProvider({
|
|
74
|
+
resolveOAuthAccessKey: async () => 'oauth-token',
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const auth = await provider.resolveAuth?.(makeModel())
|
|
78
|
+
expect(auth).toEqual({ token: 'oauth-token', mode: 'oauth' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('exposes provider identity and converse function', () => {
|
|
82
|
+
const provider = createCopilotProvider()
|
|
83
|
+
expect(provider.id).toBe('github-copilot')
|
|
84
|
+
expect(provider.displayName).toBe('GitHub Copilot')
|
|
85
|
+
expect(typeof provider.converse).toBe('function')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('resolveAuth is available', async () => {
|
|
89
|
+
const provider = createCopilotProvider()
|
|
90
|
+
expect(typeof provider.resolveAuth).toBe('function')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('resolveAuth throws when resolver returns undefined', async () => {
|
|
94
|
+
const provider = createCopilotProvider({
|
|
95
|
+
resolveOAuthAccessKey: async () => undefined,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await expect(provider.resolveAuth?.(makeModel())).rejects.toThrow('Missing GitHub Copilot token')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('resolveAuth throws when env and oauth are both unavailable', async () => {
|
|
102
|
+
const provider = createCopilotProvider()
|
|
103
|
+
await expect(provider.resolveAuth?.(makeModel())).rejects.toThrow('Missing GitHub Copilot token')
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('inferCopilotInitiator', () => {
|
|
108
|
+
it('returns "user" when last message is from user', () => {
|
|
109
|
+
const messages: Message[] = [makeUserMessage('hello')]
|
|
110
|
+
expect(inferCopilotInitiator(messages)).toBe('user')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('returns "agent" when last message is from assistant', () => {
|
|
114
|
+
const messages: Message[] = [
|
|
115
|
+
makeUserMessage('hello'),
|
|
116
|
+
makeAssistantMessage([{ type: 'text', text: 'hi' }]),
|
|
117
|
+
]
|
|
118
|
+
expect(inferCopilotInitiator(messages)).toBe('agent')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('returns "agent" when last message is tool_result', () => {
|
|
122
|
+
const messages: Message[] = [makeToolResultMessage([{ type: 'text', text: 'result' }])]
|
|
123
|
+
expect(inferCopilotInitiator(messages)).toBe('agent')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('returns "user" for empty messages', () => {
|
|
127
|
+
expect(inferCopilotInitiator([])).toBe('user')
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('hasCopilotVisionInput', () => {
|
|
132
|
+
it('returns false when no images', () => {
|
|
133
|
+
const messages: Message[] = [makeUserMessage('hello')]
|
|
134
|
+
expect(hasCopilotVisionInput(messages)).toBe(false)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns true when user message has image content', () => {
|
|
138
|
+
const messages: Message[] = [
|
|
139
|
+
makeUserMessage([
|
|
140
|
+
{ type: 'text', text: 'describe this' },
|
|
141
|
+
{ type: 'image', source: 'base64data', mime: 'image/png' },
|
|
142
|
+
]),
|
|
143
|
+
]
|
|
144
|
+
expect(hasCopilotVisionInput(messages)).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns true when tool_result has image content', () => {
|
|
148
|
+
const messages: Message[] = [
|
|
149
|
+
makeToolResultMessage([{ type: 'image', source: 'base64data', mime: 'image/png' }]),
|
|
150
|
+
]
|
|
151
|
+
expect(hasCopilotVisionInput(messages)).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('buildCopilotDynamicHeaders', () => {
|
|
156
|
+
it('sets X-Initiator and Openai-Intent', () => {
|
|
157
|
+
const messages: Message[] = [makeUserMessage('hello')]
|
|
158
|
+
const headers = buildCopilotDynamicHeaders({ messages, hasImages: false })
|
|
159
|
+
expect(headers['X-Initiator']).toBe('user')
|
|
160
|
+
expect(headers['Openai-Intent']).toBe('conversation-edits')
|
|
161
|
+
expect(headers['Copilot-Vision-Request']).toBeUndefined()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('includes Copilot-Vision-Request when hasImages is true', () => {
|
|
165
|
+
const messages: Message[] = [
|
|
166
|
+
makeUserMessage([{ type: 'image', source: 'base64data', mime: 'image/png' }]),
|
|
167
|
+
]
|
|
168
|
+
const headers = buildCopilotDynamicHeaders({ messages, hasImages: true })
|
|
169
|
+
expect(headers['Copilot-Vision-Request']).toBe('true')
|
|
170
|
+
expect(headers['X-Initiator']).toBe('user')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not include Copilot-Vision-Request when hasImages is false even with image content', () => {
|
|
174
|
+
const messages: Message[] = [
|
|
175
|
+
makeUserMessage([{ type: 'image', source: 'base64data', mime: 'image/png' }]),
|
|
176
|
+
]
|
|
177
|
+
const headers = buildCopilotDynamicHeaders({ messages, hasImages: false })
|
|
178
|
+
expect(headers['Copilot-Vision-Request']).toBeUndefined()
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('converse error contract (M12-09)', () => {
|
|
183
|
+
const encoder = new TextEncoder()
|
|
184
|
+
|
|
185
|
+
function sseResponse(body: ReadableStream<Uint8Array>): Response {
|
|
186
|
+
return new Response(body, {
|
|
187
|
+
status: 200,
|
|
188
|
+
headers: { 'content-type': 'text/event-stream' },
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function makeProvider() {
|
|
193
|
+
return createCopilotProvider({ resolveOAuthAccessKey: async () => 'test-token' })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function collect(response: Response) {
|
|
197
|
+
const original = globalThis.fetch
|
|
198
|
+
globalThis.fetch = (async () => response) as typeof fetch
|
|
199
|
+
try {
|
|
200
|
+
const provider = makeProvider()
|
|
201
|
+
const events: Array<{ type: string }> = []
|
|
202
|
+
for await (const event of provider.converse(
|
|
203
|
+
makeModel(),
|
|
204
|
+
{ model: makeModel(), systemPrompt: '', messages: [makeUserMessage('hi')] },
|
|
205
|
+
{},
|
|
206
|
+
new AbortController().signal,
|
|
207
|
+
)) {
|
|
208
|
+
events.push(event)
|
|
209
|
+
}
|
|
210
|
+
return events
|
|
211
|
+
} finally {
|
|
212
|
+
globalThis.fetch = original
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it('yields {type:"error"} instead of throwing when stream fails mid-flight', async () => {
|
|
217
|
+
let pulls = 0
|
|
218
|
+
const body = new ReadableStream<Uint8Array>({
|
|
219
|
+
pull(controller) {
|
|
220
|
+
if (pulls++ === 0) {
|
|
221
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"hel"}}]}\n\n'))
|
|
222
|
+
} else {
|
|
223
|
+
controller.error(new Error('socket reset'))
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const events = await collect(sseResponse(body))
|
|
229
|
+
const types = events.map((e) => e.type)
|
|
230
|
+
expect(types).toContain('text_delta')
|
|
231
|
+
expect(types[types.length - 1]).toBe('error')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('completes normally with done event when stream ends cleanly', async () => {
|
|
235
|
+
const body = new ReadableStream<Uint8Array>({
|
|
236
|
+
start(controller) {
|
|
237
|
+
controller.enqueue(
|
|
238
|
+
encoder.encode('data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\n'),
|
|
239
|
+
)
|
|
240
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
241
|
+
controller.close()
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
const events = await collect(sseResponse(body))
|
|
246
|
+
const types = events.map((e) => e.type)
|
|
247
|
+
expect(types[types.length - 1]).toBe('done')
|
|
248
|
+
expect(types).not.toContain('error')
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// RFC-074 B5:OpenAI 兼容后端把终止块发成无 delta 的 {finish_reason:'tool_calls'}。
|
|
252
|
+
// 修复前 `!delta continue` 在 finish_reason 之前 → stopReason 停 'end_turn',有 tool_calls 也停轮不执行。
|
|
253
|
+
it('captures finish_reason from a delta-less terminal chunk (tool_calls → tool_use)', async () => {
|
|
254
|
+
const body = new ReadableStream<Uint8Array>({
|
|
255
|
+
start(controller) {
|
|
256
|
+
// tool_call 分块先到(带 delta),终止块无 delta、只有 finish_reason。
|
|
257
|
+
controller.enqueue(
|
|
258
|
+
encoder.encode(
|
|
259
|
+
'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"read_file","arguments":"{}"}}]}}]}\n\n',
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
controller.enqueue(encoder.encode('data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n'))
|
|
263
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
264
|
+
controller.close()
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const events = (await collect(sseResponse(body))) as Array<{ type: string; reason?: string }>
|
|
269
|
+
const done = events.find((e) => e.type === 'done')
|
|
270
|
+
expect(done?.reason).toBe('tool_use')
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* provider.ts —— GitHub Copilot 定制协议 provider 实现(RFC-140 架构下沉,从 @x-otto/provider
|
|
3
|
+
* 物理迁移至本插件目录)。
|
|
4
|
+
*
|
|
5
|
+
* Copilot 走不了声明式 `contributes.providers` 轴(`wireApi` schema 只覆盖
|
|
6
|
+
* openai-completions/openai-responses/anthropic-messages 三种通用协议)——协议本质是
|
|
7
|
+
* chat-completions 兼容形,但有厂商专属头(vision 动态请求头/initiator 判断/
|
|
8
|
+
* `copilot-integration-id` 等),复用 `@x-otto/provider` 导出的 `buildChatCompletionsBody`/
|
|
9
|
+
* `streamChatCompletions` 协议基座,只在 url/headers/body 上做定制。
|
|
10
|
+
*
|
|
11
|
+
* 此实现此前物理存在于 `@x-otto/provider`(通用契约包),但它是 copilot 一家厂商的专属
|
|
12
|
+
* 实现、唯一消费方是本插件——架构 review 判定不该驻留在"多厂商共享的通用插入点"里,
|
|
13
|
+
* 下沉到这里更贴合"各 provider 在插件实现,框架提供插入点"的终局方向。
|
|
14
|
+
*/
|
|
15
|
+
import { ProviderError } from '@x-otto/shared'
|
|
16
|
+
import {
|
|
17
|
+
buildChatCompletionsBody,
|
|
18
|
+
streamChatCompletions,
|
|
19
|
+
registeredToolNames,
|
|
20
|
+
type Message,
|
|
21
|
+
type Model,
|
|
22
|
+
type ResolvedAuth,
|
|
23
|
+
type StreamContext,
|
|
24
|
+
type StreamEvent,
|
|
25
|
+
type StreamOptions,
|
|
26
|
+
type ProviderStream,
|
|
27
|
+
type WireProtocolLogger,
|
|
28
|
+
} from '@x-otto/provider'
|
|
29
|
+
|
|
30
|
+
/** RFC-185 D3:日志走宿主注入端口,缺省 no-op——插件源码禁 createLogger(防第二单例直写 stderr)。 */
|
|
31
|
+
const noopLogger: WireProtocolLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }
|
|
32
|
+
|
|
33
|
+
const PROVIDER_VERSION = '1.0.0'
|
|
34
|
+
const DEFAULT_COPILOT_INTEGRATION_ID = 'vscode-chat'
|
|
35
|
+
|
|
36
|
+
function buildCopilotHeaders(token: string): Record<string, string> {
|
|
37
|
+
const integrationId = process.env['OTTO_COPILOT_INTEGRATION_ID'] || DEFAULT_COPILOT_INTEGRATION_ID
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
authorization: `Bearer ${token}`,
|
|
41
|
+
'copilot-integration-id': integrationId,
|
|
42
|
+
'editor-version': `otto-coding/${PROVIDER_VERSION}`,
|
|
43
|
+
'editor-plugin-version': `otto-coding/${PROVIDER_VERSION}`,
|
|
44
|
+
'openai-intent': 'conversation-panel',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inferCopilotInitiator(messages: Message[]): 'user' | 'agent' {
|
|
49
|
+
const last = messages[messages.length - 1]
|
|
50
|
+
return last && last.role !== 'user' ? 'agent' : 'user'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hasCopilotVisionInput(messages: Message[]): boolean {
|
|
54
|
+
return messages.some((msg) => {
|
|
55
|
+
if (msg.role === 'user' && Array.isArray(msg.content)) {
|
|
56
|
+
return msg.content.some((c) => c.type === 'image')
|
|
57
|
+
}
|
|
58
|
+
if (msg.role === 'tool_result' && Array.isArray(msg.content)) {
|
|
59
|
+
return msg.content.some((c) => c.type === 'image')
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildCopilotDynamicHeaders(params: {
|
|
66
|
+
messages: Message[]
|
|
67
|
+
hasImages: boolean
|
|
68
|
+
}): Record<string, string> {
|
|
69
|
+
const headers: Record<string, string> = {
|
|
70
|
+
'X-Initiator': inferCopilotInitiator(params.messages),
|
|
71
|
+
'Openai-Intent': 'conversation-edits',
|
|
72
|
+
}
|
|
73
|
+
if (params.hasImages) {
|
|
74
|
+
headers['Copilot-Vision-Request'] = 'true'
|
|
75
|
+
}
|
|
76
|
+
return headers
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildRequestBody(model: Model, context: StreamContext): Record<string, unknown> {
|
|
80
|
+
const modelId = model.id.includes('/') ? model.id.split('/')[1] : model.id
|
|
81
|
+
return buildChatCompletionsBody(model, context, { modelName: modelId })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type CopilotCredentialResolver = () => Promise<string | undefined>
|
|
85
|
+
|
|
86
|
+
class GitHubCopilotStream implements ProviderStream {
|
|
87
|
+
readonly id = 'github-copilot'
|
|
88
|
+
readonly displayName = 'GitHub Copilot'
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly resolveOAuthAccessKey?: CopilotCredentialResolver,
|
|
92
|
+
private readonly logger: WireProtocolLogger = noopLogger,
|
|
93
|
+
) {}
|
|
94
|
+
|
|
95
|
+
/** 统一鉴权解析(RFC-140 D5,`ProviderStream.resolveAuth`)——Copilot 用户令牌走 oauth 模式。 */
|
|
96
|
+
async resolveAuth(): Promise<ResolvedAuth> {
|
|
97
|
+
if (this.resolveOAuthAccessKey) {
|
|
98
|
+
const key = await this.resolveOAuthAccessKey()
|
|
99
|
+
if (key) {
|
|
100
|
+
return { token: key, mode: 'oauth' }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new ProviderError('Missing GitHub Copilot token.', {
|
|
105
|
+
code: 'PROVIDER_AUTH_MISSING',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async *converse(
|
|
110
|
+
model: Model,
|
|
111
|
+
context: StreamContext,
|
|
112
|
+
options: StreamOptions,
|
|
113
|
+
signal: AbortSignal,
|
|
114
|
+
): AsyncIterable<StreamEvent> {
|
|
115
|
+
const { token: key } = await this.resolveAuth()
|
|
116
|
+
|
|
117
|
+
const baseUrl = model.baseUrl ?? 'https://api.githubcopilot.com'
|
|
118
|
+
const body = buildRequestBody(model, context)
|
|
119
|
+
|
|
120
|
+
this.logger.debug({ model: model.id }, 'Starting Copilot stream with body: %o', body)
|
|
121
|
+
|
|
122
|
+
// SSE 解析统一走 streamChatCompletions(RFC-074 R-AICHAT);copilot 仅特殊在 url/headers/body。
|
|
123
|
+
yield* streamChatCompletions({
|
|
124
|
+
url: `${baseUrl}/chat/completions`,
|
|
125
|
+
headers: {
|
|
126
|
+
...buildCopilotHeaders(key),
|
|
127
|
+
...buildCopilotDynamicHeaders({
|
|
128
|
+
messages: context.messages,
|
|
129
|
+
hasImages: hasCopilotVisionInput(context.messages),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
body,
|
|
133
|
+
identity: { api: 'github-copilot', provider: 'github-copilot', model: model.id },
|
|
134
|
+
signal,
|
|
135
|
+
timeout: options.timeout,
|
|
136
|
+
// RFC-232(终局修正):跨 wire 协议救回违约响应,见 @x-otto/provider stream-state.ts。
|
|
137
|
+
registeredToolNames: registeredToolNames(context.tools),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface CopilotProviderOptions {
|
|
143
|
+
resolveOAuthAccessKey?: CopilotCredentialResolver
|
|
144
|
+
/** RFC-185 D3:宿主注入的日志端口,缺省 no-op。 */
|
|
145
|
+
logger?: WireProtocolLogger
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createCopilotProvider(options: CopilotProviderOptions = {}): ProviderStream {
|
|
149
|
+
return new GitHubCopilotStream(options.resolveOAuthAccessKey, options.logger)
|
|
150
|
+
}
|