@ai-ide-bridge/copilot 1.0.3
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/.turbo/turbo-build.log +4 -0
- package/README.md +52 -0
- package/dist/auth.d.ts +7 -0
- package/dist/auth.js +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/plugin.d.ts +8 -0
- package/dist/plugin.js +29 -0
- package/dist/session.d.ts +8 -0
- package/dist/session.js +115 -0
- package/dist/tools.d.ts +10 -0
- package/dist/tools.js +10 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.js +27 -0
- package/package.json +21 -0
- package/src/auth.ts +32 -0
- package/src/index.ts +5 -0
- package/src/plugin.ts +31 -0
- package/src/session.ts +130 -0
- package/src/tools.ts +21 -0
- package/src/types.ts +43 -0
- package/test/auth.test.ts +55 -0
- package/test/plugin.test.ts +70 -0
- package/test/session.test.ts +89 -0
- package/test/tools.test.ts +34 -0
- package/test/types.test.ts +29 -0
- package/tsconfig.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @ai-ide-bridge/copilot
|
|
2
|
+
|
|
3
|
+
The GitHub Copilot API plugin for **AI IDE Bridge**.
|
|
4
|
+
|
|
5
|
+
AI IDE Bridge is a local HTTP server that translates OpenAI-compatible API requests into provider-specific calls, enabling any OpenAI-format client to use any AI IDE's model catalog.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
`@ai-ide-bridge/copilot` provides seamless integration with GitHub Copilot's cloud AI models. It acts as a plugin for `@ai-ide-bridge/core` to authenticate, list models, and stream chat completions.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @ai-ide-bridge/copilot
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
The plugin requires a valid `GITHUB_TOKEN` with Copilot access permissions.
|
|
20
|
+
|
|
21
|
+
Set the credential via environment variable:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export GITHUB_TOKEN="ghp_your_github_token"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or configure it in `~/.config/llm-bridge/config.json`:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"plugins": {
|
|
32
|
+
"copilot": {
|
|
33
|
+
"GITHUB_TOKEN": "ghp_your_github_token"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Supported Models
|
|
40
|
+
|
|
41
|
+
Models are accessible via the `copilot/` prefix:
|
|
42
|
+
|
|
43
|
+
- `copilot/gpt-4o-copilot` — GPT-4o (GitHub Copilot)
|
|
44
|
+
- `copilot/claude-3.5-sonnet-copilot` — Claude 3.5 Sonnet (GitHub Copilot)
|
|
45
|
+
|
|
46
|
+
## Documentation
|
|
47
|
+
|
|
48
|
+
For full documentation and setup instructions, please visit the main repository: [https://github.com/aeswibon/llm-bridge](https://github.com/aeswibon/llm-bridge).
|
|
49
|
+
|
|
50
|
+
## License
|
|
51
|
+
|
|
52
|
+
MIT
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CopilotConfig } from './types.js';
|
|
2
|
+
export declare function validateToken(token: string): Promise<boolean>;
|
|
3
|
+
export declare function getToken(config: CopilotConfig): string | null;
|
|
4
|
+
export declare function refreshOAuthToken(_refreshToken: string, _clientId: string, _clientSecret: string): Promise<{
|
|
5
|
+
accessToken: string;
|
|
6
|
+
refreshToken: string;
|
|
7
|
+
} | null>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const COPILOT_API_BASE = 'https://api.github.com';
|
|
2
|
+
export async function validateToken(token) {
|
|
3
|
+
try {
|
|
4
|
+
const response = await fetch(`${COPILOT_API_BASE}/copilot_internal/v2/token`, {
|
|
5
|
+
method: 'GET',
|
|
6
|
+
headers: {
|
|
7
|
+
Authorization: `Bearer ${token}`,
|
|
8
|
+
Accept: 'application/json',
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
return response.ok;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function getToken(config) {
|
|
18
|
+
return config.COPILOT_TOKEN ?? config.COPILOT_OAUTH_TOKEN ?? null;
|
|
19
|
+
}
|
|
20
|
+
// OAuth placeholder — wired up when OAuth Phase 2 is implemented
|
|
21
|
+
export async function refreshOAuthToken(_refreshToken, _clientId, _clientSecret) {
|
|
22
|
+
// TODO: Implement GitHub OAuth with PKCE flow
|
|
23
|
+
return null;
|
|
24
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CopilotBridgePlugin } from './plugin.js';
|
|
2
|
+
export { CopilotBridgeSession } from './session.js';
|
|
3
|
+
export type { CopilotModel, CopilotConfig } from './types.js';
|
|
4
|
+
export { COPILOT_MODELS } from './types.js';
|
|
5
|
+
export { validateToken, getToken } from './auth.js';
|
package/dist/index.js
ADDED
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BridgePlugin, BridgeSession, ModelInfo } from '@ai-ide-bridge/core';
|
|
2
|
+
export declare class CopilotBridgePlugin implements BridgePlugin {
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
authenticate(config: Record<string, string>): Promise<boolean>;
|
|
6
|
+
listModels(config: Record<string, string>): Promise<ModelInfo[]>;
|
|
7
|
+
createSession(config: Record<string, string>, model: string): Promise<BridgeSession>;
|
|
8
|
+
}
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CopilotBridgeSession } from './session.js';
|
|
2
|
+
import { COPILOT_MODELS } from './types.js';
|
|
3
|
+
import { validateToken, getToken } from './auth.js';
|
|
4
|
+
export class CopilotBridgePlugin {
|
|
5
|
+
name = 'copilot';
|
|
6
|
+
version = '2.0.0';
|
|
7
|
+
async authenticate(config) {
|
|
8
|
+
const token = getToken(config);
|
|
9
|
+
if (!token)
|
|
10
|
+
return false;
|
|
11
|
+
return validateToken(token);
|
|
12
|
+
}
|
|
13
|
+
async listModels(config) {
|
|
14
|
+
const token = getToken(config);
|
|
15
|
+
if (!token)
|
|
16
|
+
throw new Error('Missing COPILOT_TOKEN');
|
|
17
|
+
return COPILOT_MODELS.map((m) => ({
|
|
18
|
+
id: m.id,
|
|
19
|
+
name: m.name,
|
|
20
|
+
capabilities: m.capabilities,
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
async createSession(config, model) {
|
|
24
|
+
const token = getToken(config);
|
|
25
|
+
if (!token)
|
|
26
|
+
throw new Error('Missing COPILOT_TOKEN');
|
|
27
|
+
return new CopilotBridgeSession(token, model);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { BridgeSession, Message, ToolDefinition, StreamChunk } from '@ai-ide-bridge/core';
|
|
2
|
+
export declare class CopilotBridgeSession implements BridgeSession {
|
|
3
|
+
private token;
|
|
4
|
+
private modelId;
|
|
5
|
+
constructor(token: string, modelId: string);
|
|
6
|
+
send(messages: Message[], tools?: ToolDefinition[]): AsyncIterable<StreamChunk>;
|
|
7
|
+
dispose(): Promise<void>;
|
|
8
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { translateTools } from './tools.js';
|
|
2
|
+
const COPILOT_API_BASE = 'https://api.github.com';
|
|
3
|
+
export class CopilotBridgeSession {
|
|
4
|
+
token;
|
|
5
|
+
modelId;
|
|
6
|
+
constructor(token, modelId) {
|
|
7
|
+
this.token = token;
|
|
8
|
+
this.modelId = modelId;
|
|
9
|
+
}
|
|
10
|
+
async *send(messages, tools) {
|
|
11
|
+
const body = {
|
|
12
|
+
model: this.modelId,
|
|
13
|
+
messages: messages.map((m) => ({
|
|
14
|
+
role: m.role,
|
|
15
|
+
content: m.content ?? '',
|
|
16
|
+
...(m.tool_calls && { tool_calls: m.tool_calls }),
|
|
17
|
+
...(m.tool_call_id && { tool_call_id: m.tool_call_id }),
|
|
18
|
+
})),
|
|
19
|
+
...(tools && { tools: translateTools(tools) }),
|
|
20
|
+
stream: true,
|
|
21
|
+
};
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetch(`${COPILOT_API_BASE}/copilot_internal/chat/completions`, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${this.token}`,
|
|
27
|
+
'Content-Type': 'application/json',
|
|
28
|
+
Accept: 'text/event-stream',
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const errorText = await response.text();
|
|
34
|
+
yield {
|
|
35
|
+
type: 'error',
|
|
36
|
+
content: `Copilot API error: ${response.status} ${errorText}`,
|
|
37
|
+
finishReason: 'error',
|
|
38
|
+
};
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const reader = response.body?.getReader();
|
|
42
|
+
if (!reader) {
|
|
43
|
+
yield { type: 'error', content: 'No response body', finishReason: 'error' };
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const decoder = new TextDecoder();
|
|
47
|
+
let buffer = '';
|
|
48
|
+
let finished = false;
|
|
49
|
+
while (true) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done)
|
|
52
|
+
break;
|
|
53
|
+
buffer += decoder.decode(value, { stream: true });
|
|
54
|
+
const lines = buffer.split(/\r?\n/);
|
|
55
|
+
buffer = lines.pop() ?? '';
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed || !trimmed.startsWith('data:'))
|
|
59
|
+
continue;
|
|
60
|
+
const data = trimmed.slice(5).trim();
|
|
61
|
+
if (data === '[DONE]') {
|
|
62
|
+
if (!finished) {
|
|
63
|
+
yield { type: 'done', finishReason: 'stop' };
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const parsed = JSON.parse(data);
|
|
69
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
70
|
+
if (delta?.content) {
|
|
71
|
+
yield { type: 'text', content: delta.content };
|
|
72
|
+
}
|
|
73
|
+
if (delta?.tool_calls) {
|
|
74
|
+
for (const tc of delta.tool_calls) {
|
|
75
|
+
yield {
|
|
76
|
+
type: 'tool_call',
|
|
77
|
+
toolCall: {
|
|
78
|
+
id: tc.id ?? '',
|
|
79
|
+
name: tc.function?.name ?? '',
|
|
80
|
+
arguments: tc.function?.arguments ?? '',
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (parsed.choices?.[0]?.finish_reason && !finished) {
|
|
86
|
+
const reason = parsed.choices[0].finish_reason;
|
|
87
|
+
const finishReason = reason === 'stop'
|
|
88
|
+
? 'stop'
|
|
89
|
+
: reason === 'tool_calls'
|
|
90
|
+
? 'tool_calls'
|
|
91
|
+
: reason === 'length'
|
|
92
|
+
? 'length'
|
|
93
|
+
: 'error';
|
|
94
|
+
yield {
|
|
95
|
+
type: 'done',
|
|
96
|
+
finishReason,
|
|
97
|
+
};
|
|
98
|
+
finished = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Skip malformed SSE data
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
109
|
+
yield { type: 'error', content: msg, finishReason: 'error' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async dispose() {
|
|
113
|
+
// No persistent connections to dispose
|
|
114
|
+
}
|
|
115
|
+
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ToolDefinition } from '@ai-ide-bridge/core';
|
|
2
|
+
export interface CopilotTool {
|
|
3
|
+
type: 'function';
|
|
4
|
+
function: {
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
parameters: Record<string, unknown>;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare function translateTools(tools: ToolDefinition[]): CopilotTool[];
|
package/dist/tools.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface CopilotModel {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
capabilities: {
|
|
5
|
+
streaming: boolean;
|
|
6
|
+
tools: boolean;
|
|
7
|
+
vision?: boolean;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export declare const COPILOT_MODELS: CopilotModel[];
|
|
11
|
+
export interface CopilotConfig {
|
|
12
|
+
COPILOT_TOKEN?: string;
|
|
13
|
+
COPILOT_OAUTH_TOKEN?: string;
|
|
14
|
+
COPILOT_OAUTH_REFRESH_TOKEN?: string;
|
|
15
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const COPILOT_MODELS = [
|
|
2
|
+
{
|
|
3
|
+
id: 'gpt-4-copilot',
|
|
4
|
+
name: 'GPT-4 (Copilot)',
|
|
5
|
+
capabilities: { streaming: true, tools: true },
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
id: 'gpt-4o-copilot',
|
|
9
|
+
name: 'GPT-4o (Copilot)',
|
|
10
|
+
capabilities: { streaming: true, tools: true },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
id: 'claude-3.5-sonnet-copilot',
|
|
14
|
+
name: 'Claude 3.5 Sonnet (Copilot)',
|
|
15
|
+
capabilities: { streaming: true, tools: true },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'o1-copilot',
|
|
19
|
+
name: 'o1 (Copilot)',
|
|
20
|
+
capabilities: { streaming: true, tools: true },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'o1-mini-copilot',
|
|
24
|
+
name: 'o1-mini (Copilot)',
|
|
25
|
+
capabilities: { streaming: true, tools: false },
|
|
26
|
+
},
|
|
27
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ai-ide-bridge/copilot",
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"lint": "tsc --noEmit",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"dev": "tsc --watch"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@ai-ide-bridge/core": "workspace:*"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.15.0",
|
|
19
|
+
"vitest": "^2.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CopilotConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
const COPILOT_API_BASE = 'https://api.github.com';
|
|
4
|
+
|
|
5
|
+
export async function validateToken(token: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
const response = await fetch(`${COPILOT_API_BASE}/copilot_internal/v2/token`, {
|
|
8
|
+
method: 'GET',
|
|
9
|
+
headers: {
|
|
10
|
+
Authorization: `Bearer ${token}`,
|
|
11
|
+
Accept: 'application/json',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
return response.ok;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getToken(config: CopilotConfig): string | null {
|
|
21
|
+
return config.COPILOT_TOKEN ?? config.COPILOT_OAUTH_TOKEN ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// OAuth placeholder — wired up when OAuth Phase 2 is implemented
|
|
25
|
+
export async function refreshOAuthToken(
|
|
26
|
+
_refreshToken: string,
|
|
27
|
+
_clientId: string,
|
|
28
|
+
_clientSecret: string,
|
|
29
|
+
): Promise<{ accessToken: string; refreshToken: string } | null> {
|
|
30
|
+
// TODO: Implement GitHub OAuth with PKCE flow
|
|
31
|
+
return null;
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { CopilotBridgePlugin } from './plugin.js';
|
|
2
|
+
export { CopilotBridgeSession } from './session.js';
|
|
3
|
+
export type { CopilotModel, CopilotConfig } from './types.js';
|
|
4
|
+
export { COPILOT_MODELS } from './types.js';
|
|
5
|
+
export { validateToken, getToken } from './auth.js';
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BridgePlugin, BridgeSession, ModelInfo } from '@ai-ide-bridge/core';
|
|
2
|
+
import { CopilotBridgeSession } from './session.js';
|
|
3
|
+
import { COPILOT_MODELS, type CopilotConfig } from './types.js';
|
|
4
|
+
import { validateToken, getToken } from './auth.js';
|
|
5
|
+
|
|
6
|
+
export class CopilotBridgePlugin implements BridgePlugin {
|
|
7
|
+
name = 'copilot';
|
|
8
|
+
version = '2.0.0';
|
|
9
|
+
|
|
10
|
+
async authenticate(config: Record<string, string>): Promise<boolean> {
|
|
11
|
+
const token = getToken(config as CopilotConfig);
|
|
12
|
+
if (!token) return false;
|
|
13
|
+
return validateToken(token);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async listModels(config: Record<string, string>): Promise<ModelInfo[]> {
|
|
17
|
+
const token = getToken(config as CopilotConfig);
|
|
18
|
+
if (!token) throw new Error('Missing COPILOT_TOKEN');
|
|
19
|
+
return COPILOT_MODELS.map((m) => ({
|
|
20
|
+
id: m.id,
|
|
21
|
+
name: m.name,
|
|
22
|
+
capabilities: m.capabilities,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async createSession(config: Record<string, string>, model: string): Promise<BridgeSession> {
|
|
27
|
+
const token = getToken(config as CopilotConfig);
|
|
28
|
+
if (!token) throw new Error('Missing COPILOT_TOKEN');
|
|
29
|
+
return new CopilotBridgeSession(token, model);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { BridgeSession, Message, ToolDefinition, StreamChunk } from '@ai-ide-bridge/core';
|
|
2
|
+
import { translateTools } from './tools.js';
|
|
3
|
+
|
|
4
|
+
const COPILOT_API_BASE = 'https://api.github.com';
|
|
5
|
+
|
|
6
|
+
export class CopilotBridgeSession implements BridgeSession {
|
|
7
|
+
private token: string;
|
|
8
|
+
private modelId: string;
|
|
9
|
+
|
|
10
|
+
constructor(token: string, modelId: string) {
|
|
11
|
+
this.token = token;
|
|
12
|
+
this.modelId = modelId;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async *send(messages: Message[], tools?: ToolDefinition[]): AsyncIterable<StreamChunk> {
|
|
16
|
+
const body = {
|
|
17
|
+
model: this.modelId,
|
|
18
|
+
messages: messages.map((m) => ({
|
|
19
|
+
role: m.role,
|
|
20
|
+
content: m.content ?? '',
|
|
21
|
+
...(m.tool_calls && { tool_calls: m.tool_calls }),
|
|
22
|
+
...(m.tool_call_id && { tool_call_id: m.tool_call_id }),
|
|
23
|
+
})),
|
|
24
|
+
...(tools && { tools: translateTools(tools) }),
|
|
25
|
+
stream: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(`${COPILOT_API_BASE}/copilot_internal/chat/completions`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: `Bearer ${this.token}`,
|
|
33
|
+
'Content-Type': 'application/json',
|
|
34
|
+
Accept: 'text/event-stream',
|
|
35
|
+
},
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (!response.ok) {
|
|
40
|
+
const errorText = await response.text();
|
|
41
|
+
yield {
|
|
42
|
+
type: 'error',
|
|
43
|
+
content: `Copilot API error: ${response.status} ${errorText}`,
|
|
44
|
+
finishReason: 'error',
|
|
45
|
+
};
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const reader = response.body?.getReader();
|
|
50
|
+
if (!reader) {
|
|
51
|
+
yield { type: 'error', content: 'No response body', finishReason: 'error' };
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const decoder = new TextDecoder();
|
|
56
|
+
let buffer = '';
|
|
57
|
+
let finished = false;
|
|
58
|
+
|
|
59
|
+
while (true) {
|
|
60
|
+
const { done, value } = await reader.read();
|
|
61
|
+
if (done) break;
|
|
62
|
+
|
|
63
|
+
buffer += decoder.decode(value, { stream: true });
|
|
64
|
+
const lines = buffer.split(/\r?\n/);
|
|
65
|
+
buffer = lines.pop() ?? '';
|
|
66
|
+
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed || !trimmed.startsWith('data:')) continue;
|
|
70
|
+
|
|
71
|
+
const data = trimmed.slice(5).trim();
|
|
72
|
+
if (data === '[DONE]') {
|
|
73
|
+
if (!finished) {
|
|
74
|
+
yield { type: 'done', finishReason: 'stop' };
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const parsed = JSON.parse(data);
|
|
81
|
+
const delta = parsed.choices?.[0]?.delta;
|
|
82
|
+
|
|
83
|
+
if (delta?.content) {
|
|
84
|
+
yield { type: 'text', content: delta.content };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (delta?.tool_calls) {
|
|
88
|
+
for (const tc of delta.tool_calls) {
|
|
89
|
+
yield {
|
|
90
|
+
type: 'tool_call',
|
|
91
|
+
toolCall: {
|
|
92
|
+
id: tc.id ?? '',
|
|
93
|
+
name: tc.function?.name ?? '',
|
|
94
|
+
arguments: tc.function?.arguments ?? '',
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (parsed.choices?.[0]?.finish_reason && !finished) {
|
|
101
|
+
const reason = parsed.choices[0].finish_reason;
|
|
102
|
+
const finishReason =
|
|
103
|
+
reason === 'stop'
|
|
104
|
+
? 'stop'
|
|
105
|
+
: reason === 'tool_calls'
|
|
106
|
+
? 'tool_calls'
|
|
107
|
+
: reason === 'length'
|
|
108
|
+
? 'length'
|
|
109
|
+
: 'error';
|
|
110
|
+
yield {
|
|
111
|
+
type: 'done',
|
|
112
|
+
finishReason,
|
|
113
|
+
};
|
|
114
|
+
finished = true;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Skip malformed SSE data
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (e) {
|
|
122
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
123
|
+
yield { type: 'error', content: msg, finishReason: 'error' };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async dispose(): Promise<void> {
|
|
128
|
+
// No persistent connections to dispose
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ToolDefinition } from '@ai-ide-bridge/core';
|
|
2
|
+
|
|
3
|
+
export interface CopilotTool {
|
|
4
|
+
type: 'function';
|
|
5
|
+
function: {
|
|
6
|
+
name: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
parameters: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function translateTools(tools: ToolDefinition[]): CopilotTool[] {
|
|
13
|
+
return tools.map((tool) => ({
|
|
14
|
+
type: 'function' as const,
|
|
15
|
+
function: {
|
|
16
|
+
name: tool.function.name,
|
|
17
|
+
description: tool.function.description,
|
|
18
|
+
parameters: tool.function.parameters as Record<string, unknown>,
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface CopilotModel {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
capabilities: {
|
|
5
|
+
streaming: boolean;
|
|
6
|
+
tools: boolean;
|
|
7
|
+
vision?: boolean;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const COPILOT_MODELS: CopilotModel[] = [
|
|
12
|
+
{
|
|
13
|
+
id: 'gpt-4-copilot',
|
|
14
|
+
name: 'GPT-4 (Copilot)',
|
|
15
|
+
capabilities: { streaming: true, tools: true },
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: 'gpt-4o-copilot',
|
|
19
|
+
name: 'GPT-4o (Copilot)',
|
|
20
|
+
capabilities: { streaming: true, tools: true },
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'claude-3.5-sonnet-copilot',
|
|
24
|
+
name: 'Claude 3.5 Sonnet (Copilot)',
|
|
25
|
+
capabilities: { streaming: true, tools: true },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'o1-copilot',
|
|
29
|
+
name: 'o1 (Copilot)',
|
|
30
|
+
capabilities: { streaming: true, tools: true },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'o1-mini-copilot',
|
|
34
|
+
name: 'o1-mini (Copilot)',
|
|
35
|
+
capabilities: { streaming: true, tools: false },
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export interface CopilotConfig {
|
|
40
|
+
COPILOT_TOKEN?: string;
|
|
41
|
+
COPILOT_OAUTH_TOKEN?: string;
|
|
42
|
+
COPILOT_OAUTH_REFRESH_TOKEN?: string;
|
|
43
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { getToken, validateToken } from '../src/auth.js';
|
|
3
|
+
|
|
4
|
+
describe('getToken', () => {
|
|
5
|
+
it('returns COPILOT_TOKEN when present', () => {
|
|
6
|
+
const result = getToken({ COPILOT_TOKEN: 'test-token' });
|
|
7
|
+
expect(result).toBe('test-token');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns COPILOT_OAUTH_TOKEN when COPILOT_TOKEN is missing', () => {
|
|
11
|
+
const result = getToken({ COPILOT_OAUTH_TOKEN: 'oauth-token' });
|
|
12
|
+
expect(result).toBe('oauth-token');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns null when no token is present', () => {
|
|
16
|
+
const result = getToken({});
|
|
17
|
+
expect(result).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('prefers COPILOT_TOKEN over COPILOT_OAUTH_TOKEN', () => {
|
|
21
|
+
const result = getToken({
|
|
22
|
+
COPILOT_TOKEN: 'primary-token',
|
|
23
|
+
COPILOT_OAUTH_TOKEN: 'fallback-token',
|
|
24
|
+
});
|
|
25
|
+
expect(result).toBe('primary-token');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('validateToken', () => {
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns true when API responds with 200', async () => {
|
|
35
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
36
|
+
ok: true,
|
|
37
|
+
} as Response);
|
|
38
|
+
const result = await validateToken('valid-token');
|
|
39
|
+
expect(result).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns false when API responds with error', async () => {
|
|
43
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
44
|
+
ok: false,
|
|
45
|
+
} as Response);
|
|
46
|
+
const result = await validateToken('invalid-token');
|
|
47
|
+
expect(result).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false on network error', async () => {
|
|
51
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
|
52
|
+
const result = await validateToken('bad-token');
|
|
53
|
+
expect(result).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { CopilotBridgePlugin } from '../src/plugin.js';
|
|
3
|
+
import * as auth from '../src/auth.js';
|
|
4
|
+
import { COPILOT_MODELS } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('../src/auth.js', () => ({
|
|
7
|
+
validateToken: vi.fn(),
|
|
8
|
+
getToken: vi.fn(),
|
|
9
|
+
refreshOAuthToken: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe('CopilotBridgePlugin', () => {
|
|
13
|
+
let plugin: CopilotBridgePlugin;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
plugin = new CopilotBridgePlugin();
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('authenticates with valid token', async () => {
|
|
21
|
+
(auth.validateToken as any).mockResolvedValue(true);
|
|
22
|
+
(auth.getToken as any).mockReturnValue('ghp_test_token');
|
|
23
|
+
const result = await plugin.authenticate({ COPILOT_TOKEN: 'ghp_test_token' });
|
|
24
|
+
expect(result).toBe(true);
|
|
25
|
+
expect(auth.validateToken).toHaveBeenCalledWith('ghp_test_token');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('fails authentication with missing token', async () => {
|
|
29
|
+
(auth.getToken as any).mockReturnValue(null);
|
|
30
|
+
const result = await plugin.authenticate({});
|
|
31
|
+
expect(result).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('fails authentication with invalid token', async () => {
|
|
35
|
+
(auth.validateToken as any).mockResolvedValue(false);
|
|
36
|
+
(auth.getToken as any).mockReturnValue('invalid');
|
|
37
|
+
const result = await plugin.authenticate({ COPILOT_TOKEN: 'invalid' });
|
|
38
|
+
expect(result).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('lists models', async () => {
|
|
42
|
+
(auth.getToken as any).mockReturnValue('ghp_test_token');
|
|
43
|
+
const models = await plugin.listModels({ COPILOT_TOKEN: 'ghp_test_token' });
|
|
44
|
+
expect(models.length).toBeGreaterThan(0);
|
|
45
|
+
expect(models[0].id).toBe(COPILOT_MODELS[0].id);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('throws on listModels without token', async () => {
|
|
49
|
+
(auth.getToken as any).mockReturnValue(null);
|
|
50
|
+
await expect(plugin.listModels({})).rejects.toThrow('Missing COPILOT_TOKEN');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('creates a session', async () => {
|
|
54
|
+
(auth.getToken as any).mockReturnValue('ghp_test_token');
|
|
55
|
+
const session = await plugin.createSession(
|
|
56
|
+
{ COPILOT_TOKEN: 'ghp_test_token' },
|
|
57
|
+
'gpt-4o-copilot',
|
|
58
|
+
);
|
|
59
|
+
expect(session).toBeDefined();
|
|
60
|
+
expect(typeof session.send).toBe('function');
|
|
61
|
+
expect(typeof session.dispose).toBe('function');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws on createSession without token', async () => {
|
|
65
|
+
(auth.getToken as any).mockReturnValue(null);
|
|
66
|
+
await expect(plugin.createSession({}, 'gpt-4o-copilot')).rejects.toThrow(
|
|
67
|
+
'Missing COPILOT_TOKEN',
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { CopilotBridgeSession } from '../src/session.js';
|
|
3
|
+
import type { Message } from '@ai-ide-bridge/core';
|
|
4
|
+
|
|
5
|
+
describe('CopilotBridgeSession', () => {
|
|
6
|
+
let session: CopilotBridgeSession;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
session = new CopilotBridgeSession('test_token', 'gpt-4o-copilot');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.restoreAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('creates a session with token and model', () => {
|
|
17
|
+
expect(session).toBeDefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('disposes without error', async () => {
|
|
21
|
+
await expect(session.dispose()).resolves.not.toThrow();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('yields error chunk on HTTP error', async () => {
|
|
25
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
26
|
+
ok: false,
|
|
27
|
+
status: 401,
|
|
28
|
+
text: async () => 'Unauthorized',
|
|
29
|
+
} as Response);
|
|
30
|
+
|
|
31
|
+
const messages: Message[] = [{ role: 'user', content: 'hello' }];
|
|
32
|
+
const chunks: any[] = [];
|
|
33
|
+
|
|
34
|
+
for await (const chunk of session.send(messages)) {
|
|
35
|
+
chunks.push(chunk);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
expect(chunks).toHaveLength(1);
|
|
39
|
+
expect(chunks[0].type).toBe('error');
|
|
40
|
+
expect(chunks[0].content).toContain('401');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('yields text and done chunks on success', async () => {
|
|
44
|
+
const encoder = new TextEncoder();
|
|
45
|
+
const sseData = [
|
|
46
|
+
'data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}',
|
|
47
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
|
|
48
|
+
'data: [DONE]',
|
|
49
|
+
].join('\n');
|
|
50
|
+
|
|
51
|
+
const mockReader = {
|
|
52
|
+
read: vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockResolvedValueOnce({ done: false, value: encoder.encode(sseData) })
|
|
55
|
+
.mockResolvedValueOnce({ done: true, value: undefined }),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
59
|
+
ok: true,
|
|
60
|
+
body: { getReader: () => mockReader },
|
|
61
|
+
} as unknown as Response);
|
|
62
|
+
|
|
63
|
+
const messages: Message[] = [{ role: 'user', content: 'hello' }];
|
|
64
|
+
const chunks: any[] = [];
|
|
65
|
+
|
|
66
|
+
for await (const chunk of session.send(messages)) {
|
|
67
|
+
chunks.push(chunk);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
expect(chunks).toHaveLength(2);
|
|
71
|
+
expect(chunks[0]).toEqual({ type: 'text', content: 'Hello' });
|
|
72
|
+
expect(chunks[1]).toEqual({ type: 'done', finishReason: 'stop' });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('yields error chunk on network failure', async () => {
|
|
76
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error'));
|
|
77
|
+
|
|
78
|
+
const messages: Message[] = [{ role: 'user', content: 'hello' }];
|
|
79
|
+
const chunks: any[] = [];
|
|
80
|
+
|
|
81
|
+
for await (const chunk of session.send(messages)) {
|
|
82
|
+
chunks.push(chunk);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(chunks).toHaveLength(1);
|
|
86
|
+
expect(chunks[0].type).toBe('error');
|
|
87
|
+
expect(chunks[0].content).toBe('Network error');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { translateTools } from '../src/tools.js';
|
|
3
|
+
import type { ToolDefinition } from '@ai-ide-bridge/core';
|
|
4
|
+
|
|
5
|
+
describe('translateTools', () => {
|
|
6
|
+
it('translates OpenAI tool definitions to Copilot format', () => {
|
|
7
|
+
const tools: ToolDefinition[] = [
|
|
8
|
+
{
|
|
9
|
+
type: 'function',
|
|
10
|
+
function: {
|
|
11
|
+
name: 'search',
|
|
12
|
+
description: 'Search the web',
|
|
13
|
+
parameters: { type: 'object', properties: { q: { type: 'string' } } },
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
const result = translateTools(tools);
|
|
18
|
+
expect(result).toHaveLength(1);
|
|
19
|
+
expect(result[0].type).toBe('function');
|
|
20
|
+
expect(result[0].function.name).toBe('search');
|
|
21
|
+
expect(result[0].function.description).toBe('Search the web');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('handles multiple tools', () => {
|
|
25
|
+
const tools: ToolDefinition[] = [
|
|
26
|
+
{ type: 'function', function: { name: 'a', description: 'A', parameters: {} } },
|
|
27
|
+
{ type: 'function', function: { name: 'b', description: 'B', parameters: {} } },
|
|
28
|
+
];
|
|
29
|
+
const result = translateTools(tools);
|
|
30
|
+
expect(result).toHaveLength(2);
|
|
31
|
+
expect(result[0].function.name).toBe('a');
|
|
32
|
+
expect(result[1].function.name).toBe('b');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { COPILOT_MODELS } from '../src/types.js';
|
|
3
|
+
|
|
4
|
+
describe('COPILOT_MODELS', () => {
|
|
5
|
+
it('has 5 models', () => {
|
|
6
|
+
expect(COPILOT_MODELS).toHaveLength(5);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('has correct model IDs', () => {
|
|
10
|
+
const ids = COPILOT_MODELS.map((m) => m.id);
|
|
11
|
+
expect(ids).toContain('gpt-4-copilot');
|
|
12
|
+
expect(ids).toContain('gpt-4o-copilot');
|
|
13
|
+
expect(ids).toContain('claude-3.5-sonnet-copilot');
|
|
14
|
+
expect(ids).toContain('o1-copilot');
|
|
15
|
+
expect(ids).toContain('o1-mini-copilot');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('all models have streaming capability', () => {
|
|
19
|
+
for (const model of COPILOT_MODELS) {
|
|
20
|
+
expect(model.capabilities.streaming).toBe(true);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('o1-mini-copilot has tools disabled', () => {
|
|
25
|
+
const o1Mini = COPILOT_MODELS.find((m) => m.id === 'o1-mini-copilot');
|
|
26
|
+
expect(o1Mini).toBeDefined();
|
|
27
|
+
expect(o1Mini?.capabilities.tools).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|