@opeoginni/opencode-copilot-auto 0.1.6 → 0.1.8
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 +6 -0
- package/dist/index.js +233 -21
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -17,6 +17,12 @@ Restart OpenCode, authenticate GitHub Copilot if necessary with `opencode auth l
|
|
|
17
17
|
|
|
18
18
|
The plugin uses the existing OpenCode GitHub Copilot authentication and sends the prompt to Copilot's routing endpoint solely to select a model.
|
|
19
19
|
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
- `/copilot-refresh`: Clears the routing cache so the next prompt selects a fresh model.
|
|
23
|
+
- `/copilot-autorefresh`: Toggles fresh model selection for every prompt. Run it again to resume using the cached routing session.
|
|
24
|
+
- `/copilot-notify`: Toggles notifications between a toast and the projection bus.
|
|
25
|
+
|
|
20
26
|
## Development
|
|
21
27
|
|
|
22
28
|
```sh
|
package/dist/index.js
CHANGED
|
@@ -1,15 +1,88 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/index.ts
|
|
3
3
|
var COPILOT_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
4
|
-
var COPILOT_API_VERSION = "2026-
|
|
4
|
+
var COPILOT_API_VERSION = "2026-07-01";
|
|
5
5
|
var SESSION_REFRESH_BUFFER_SECONDS = 30;
|
|
6
|
+
var HYDRA_ROUTING = false;
|
|
7
|
+
var PROJECTION_KEY = "copilot-auto";
|
|
8
|
+
var notifyMode = "toast";
|
|
6
9
|
var sessions = new Map;
|
|
7
|
-
var
|
|
8
|
-
|
|
10
|
+
var autoRefresh = false;
|
|
11
|
+
function hasToast(client) {
|
|
12
|
+
return typeof client === "object" && client !== null && "tui" in client && typeof client.tui?.showToast === "function";
|
|
13
|
+
}
|
|
14
|
+
function hasBus(client) {
|
|
15
|
+
return typeof client === "object" && client !== null && "bus" in client && typeof client.bus?.publish === "function";
|
|
16
|
+
}
|
|
17
|
+
async function notify(client, message) {
|
|
18
|
+
if (notifyMode === "projection" && hasBus(client)) {
|
|
19
|
+
await client.bus.publish({
|
|
20
|
+
topic: "companion.projection",
|
|
21
|
+
body: { key: PROJECTION_KEY, kind: "markdown", content: message }
|
|
22
|
+
}).catch(() => {});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (hasToast(client)) {
|
|
26
|
+
await client.tui.showToast({
|
|
27
|
+
body: { title: "Copilot Auto", message, variant: "info" }
|
|
28
|
+
}).catch(() => {});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function makeTextPart(sessionID, text) {
|
|
32
|
+
return {
|
|
33
|
+
id: crypto.randomUUID(),
|
|
34
|
+
sessionID,
|
|
35
|
+
messageID: crypto.randomUUID(),
|
|
36
|
+
type: "text",
|
|
37
|
+
text
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
var CopilotAutoPlugin = async (input) => {
|
|
41
|
+
const client = input.client;
|
|
42
|
+
installFetchAdapter(client);
|
|
43
|
+
const notifyClient = (message) => notify(client, message);
|
|
9
44
|
return {
|
|
10
45
|
provider: {
|
|
11
46
|
id: "github-copilot",
|
|
12
47
|
models: async (provider) => ({ ...provider.models, auto: autoModel() })
|
|
48
|
+
},
|
|
49
|
+
config: async (input2) => {
|
|
50
|
+
input2.command ??= {};
|
|
51
|
+
input2.command["copilot-refresh"] ??= {
|
|
52
|
+
template: "/copilot-refresh",
|
|
53
|
+
description: "Clear Copilot Auto routing cache so the next prompt re-selects a model"
|
|
54
|
+
};
|
|
55
|
+
input2.command["copilot-autorefresh"] ??= {
|
|
56
|
+
template: "/copilot-autorefresh",
|
|
57
|
+
description: "Toggle automatic model re-selection on every prompt"
|
|
58
|
+
};
|
|
59
|
+
input2.command["copilot-notify"] ??= {
|
|
60
|
+
template: "/copilot-notify",
|
|
61
|
+
description: "Toggle between toast and projection bus notifications"
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
"command.execute.before": async (input2, output) => {
|
|
65
|
+
if (input2.command === "copilot-refresh") {
|
|
66
|
+
sessions.clear();
|
|
67
|
+
await notifyClient("Routing cache cleared. Next prompt will select a fresh model.");
|
|
68
|
+
output.parts.length = 0;
|
|
69
|
+
output.parts.push(makeTextPart(input2.sessionID, "Copilot Auto routing cache cleared. The next prompt will select a fresh model."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (input2.command === "copilot-autorefresh") {
|
|
73
|
+
autoRefresh = !autoRefresh;
|
|
74
|
+
await notifyClient(autoRefresh ? "Refresh enabled. Every prompt will select a fresh model." : "Refresh disabled. Reusing cached routing session.");
|
|
75
|
+
output.parts.length = 0;
|
|
76
|
+
output.parts.push(makeTextPart(input2.sessionID, autoRefresh ? "Copilot Auto refresh enabled. Every prompt will select a fresh model." : "Copilot Auto refresh disabled. Reusing cached routing session."));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (input2.command === "copilot-notify") {
|
|
80
|
+
notifyMode = notifyMode === "toast" ? "projection" : "toast";
|
|
81
|
+
await notifyClient(`Notification mode: ${notifyMode}`);
|
|
82
|
+
output.parts.length = 0;
|
|
83
|
+
output.parts.push(makeTextPart(input2.sessionID, `Copilot Auto notification mode: ${notifyMode}`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
13
86
|
}
|
|
14
87
|
};
|
|
15
88
|
};
|
|
@@ -42,7 +115,7 @@ function autoModel() {
|
|
|
42
115
|
variants: {}
|
|
43
116
|
};
|
|
44
117
|
}
|
|
45
|
-
function installFetchAdapter() {
|
|
118
|
+
function installFetchAdapter(client) {
|
|
46
119
|
const marker = Symbol.for("opeoginni.opencode-copilot-auto.fetch-adapter");
|
|
47
120
|
const runtime = globalThis;
|
|
48
121
|
if (runtime[marker])
|
|
@@ -57,18 +130,24 @@ function installFetchAdapter() {
|
|
|
57
130
|
const payload = parseJson(body);
|
|
58
131
|
if (!payload || payload.model !== "auto")
|
|
59
132
|
return originalFetch(input, init);
|
|
133
|
+
if (autoRefresh)
|
|
134
|
+
sessions.clear();
|
|
60
135
|
const session = await getSession(originalFetch, request.headers);
|
|
61
136
|
const model = await route(originalFetch, request.headers, session, payload);
|
|
137
|
+
await notify(client, `Routed to ${model}`);
|
|
138
|
+
const useResponses = usesResponses(model);
|
|
62
139
|
const headers = new Headers(request.headers);
|
|
63
140
|
headers.set("copilot-session-token", session.token);
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
141
|
+
headers.set("X-GitHub-Api-Version", COPILOT_API_VERSION);
|
|
142
|
+
const next = useResponses ? toResponsesRequest(payload, model) : { ...payload, model };
|
|
143
|
+
const url = useResponses ? toResponsesUrl(request.url) : request.url;
|
|
144
|
+
const response = await originalFetch(new Request(url, {
|
|
67
145
|
method: request.method,
|
|
68
146
|
headers,
|
|
69
147
|
body: JSON.stringify(next),
|
|
70
148
|
signal: request.signal
|
|
71
149
|
}));
|
|
150
|
+
return useResponses ? wrapResponsesResponse(response) : response;
|
|
72
151
|
};
|
|
73
152
|
globalThis.fetch = Object.assign(adapter, originalFetch);
|
|
74
153
|
}
|
|
@@ -78,34 +157,164 @@ function isAutoRequest(request) {
|
|
|
78
157
|
}
|
|
79
158
|
function usesResponses(modelID) {
|
|
80
159
|
const match = /^gpt-(\d+)/.exec(modelID);
|
|
81
|
-
return Boolean(match && Number(match[1]) >= 5
|
|
160
|
+
return Boolean(match && Number(match[1]) >= 5);
|
|
82
161
|
}
|
|
83
162
|
function toResponsesUrl(url) {
|
|
84
163
|
return url.replace(/\/chat\/completions\/?$/, "/responses");
|
|
85
164
|
}
|
|
86
165
|
function toResponsesRequest(payload, model) {
|
|
87
|
-
const messages = Array.isArray(payload.messages) ? payload.messages :
|
|
88
|
-
const
|
|
166
|
+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
|
167
|
+
const instructions = messages.filter((m) => isRecord(m) && m.role === "system").map((m) => isRecord(m) && typeof m.content === "string" ? m.content : "").filter(Boolean).join(`
|
|
168
|
+
`);
|
|
169
|
+
const input = messages.filter((m) => isRecord(m) && m.role !== "system").flatMap((m) => {
|
|
170
|
+
const msg = m;
|
|
171
|
+
const role = msg.role;
|
|
172
|
+
const content = msg.content;
|
|
173
|
+
if (role === "tool") {
|
|
174
|
+
return [{
|
|
175
|
+
type: "function_call_output",
|
|
176
|
+
call_id: msg.tool_call_id,
|
|
177
|
+
output: typeof content === "string" ? content : JSON.stringify(content)
|
|
178
|
+
}];
|
|
179
|
+
}
|
|
180
|
+
if (role === "assistant" && Array.isArray(msg.tool_calls)) {
|
|
181
|
+
const items = msg.tool_calls.map((tc) => {
|
|
182
|
+
if (!isRecord(tc) || !isRecord(tc.function))
|
|
183
|
+
return null;
|
|
184
|
+
return {
|
|
185
|
+
type: "function_call",
|
|
186
|
+
call_id: tc.id,
|
|
187
|
+
name: tc.function.name,
|
|
188
|
+
arguments: tc.function.arguments
|
|
189
|
+
};
|
|
190
|
+
}).filter((x) => x !== null);
|
|
191
|
+
if (typeof content === "string" && content) {
|
|
192
|
+
items.unshift({
|
|
193
|
+
role: "assistant",
|
|
194
|
+
content: [{ type: "output_text", text: content }]
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return items;
|
|
198
|
+
}
|
|
199
|
+
const text = typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => isRecord(part) && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
|
|
200
|
+
`) : "";
|
|
201
|
+
return [{
|
|
202
|
+
role,
|
|
203
|
+
content: [{ type: role === "user" ? "input_text" : "output_text", text }]
|
|
204
|
+
}];
|
|
205
|
+
});
|
|
89
206
|
return {
|
|
90
207
|
model,
|
|
91
208
|
input,
|
|
92
209
|
stream: payload.stream === true,
|
|
210
|
+
...instructions ? { instructions } : {},
|
|
93
211
|
...typeof payload.temperature === "number" ? { temperature: payload.temperature } : {},
|
|
94
212
|
...typeof payload.top_p === "number" ? { top_p: payload.top_p } : {},
|
|
95
213
|
...typeof payload.max_tokens === "number" ? { max_output_tokens: payload.max_tokens } : typeof payload.max_completion_tokens === "number" ? { max_output_tokens: payload.max_completion_tokens } : {},
|
|
96
|
-
...Array.isArray(payload.tools) ? { tools:
|
|
214
|
+
...Array.isArray(payload.tools) ? { tools: payload.tools.map(unwrapFunction) } : {},
|
|
97
215
|
...payload.tool_choice !== undefined ? { tool_choice: unwrapFunction(payload.tool_choice) } : {}
|
|
98
216
|
};
|
|
99
217
|
}
|
|
100
|
-
function flattenTools(tools) {
|
|
101
|
-
return tools.map(unwrapFunction);
|
|
102
|
-
}
|
|
103
218
|
function unwrapFunction(value) {
|
|
104
219
|
if (!isRecord(value) || !isRecord(value.function))
|
|
105
220
|
return value;
|
|
106
221
|
const { function: fn, ...rest } = value;
|
|
107
222
|
return { ...fn, ...rest };
|
|
108
223
|
}
|
|
224
|
+
function wrapResponsesResponse(response) {
|
|
225
|
+
const chunkId = `chatcmpl-auto-${Date.now()}`;
|
|
226
|
+
const decoder = new TextDecoder;
|
|
227
|
+
const encoder = new TextEncoder;
|
|
228
|
+
let buffer = "";
|
|
229
|
+
let toolCallIndex = -1;
|
|
230
|
+
const transformed = new ReadableStream({
|
|
231
|
+
start(controller) {
|
|
232
|
+
const reader = response.body?.getReader();
|
|
233
|
+
if (!reader) {
|
|
234
|
+
controller.close();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const stream = reader;
|
|
238
|
+
function emitChunk(delta, finishReason) {
|
|
239
|
+
const chunk = {
|
|
240
|
+
id: chunkId,
|
|
241
|
+
object: "chat.completion.chunk",
|
|
242
|
+
choices: [{ index: 0, delta, ...finishReason ? { finish_reason: finishReason } : {} }]
|
|
243
|
+
};
|
|
244
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}
|
|
245
|
+
|
|
246
|
+
`));
|
|
247
|
+
}
|
|
248
|
+
function processLine(line) {
|
|
249
|
+
if (line.startsWith("event:"))
|
|
250
|
+
return;
|
|
251
|
+
if (!line.startsWith("data:"))
|
|
252
|
+
return;
|
|
253
|
+
const data = line.slice(5).trim();
|
|
254
|
+
if (data === "[DONE]")
|
|
255
|
+
return;
|
|
256
|
+
try {
|
|
257
|
+
const event = JSON.parse(data);
|
|
258
|
+
const type = event.type;
|
|
259
|
+
if (type === "response.output_text.delta") {
|
|
260
|
+
emitChunk({ content: event.delta });
|
|
261
|
+
} else if (type === "response.output_item.added" && event.item?.type === "function_call") {
|
|
262
|
+
toolCallIndex++;
|
|
263
|
+
emitChunk({
|
|
264
|
+
tool_calls: [{
|
|
265
|
+
index: toolCallIndex,
|
|
266
|
+
id: event.item.call_id,
|
|
267
|
+
type: "function",
|
|
268
|
+
function: { name: event.item.name, arguments: "" }
|
|
269
|
+
}]
|
|
270
|
+
});
|
|
271
|
+
} else if (type === "response.function_call_arguments.delta") {
|
|
272
|
+
emitChunk({
|
|
273
|
+
tool_calls: [{
|
|
274
|
+
index: toolCallIndex,
|
|
275
|
+
function: { arguments: event.delta }
|
|
276
|
+
}]
|
|
277
|
+
});
|
|
278
|
+
} else if (type === "response.completed") {
|
|
279
|
+
emitChunk({}, "stop");
|
|
280
|
+
controller.enqueue(encoder.encode(`data: [DONE]
|
|
281
|
+
|
|
282
|
+
`));
|
|
283
|
+
}
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
function pump() {
|
|
287
|
+
return stream.read().then(({ done, value }) => {
|
|
288
|
+
if (done) {
|
|
289
|
+
if (buffer) {
|
|
290
|
+
for (const line of buffer.split(`
|
|
291
|
+
`))
|
|
292
|
+
processLine(line);
|
|
293
|
+
}
|
|
294
|
+
controller.close();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
buffer += decoder.decode(value, { stream: true });
|
|
298
|
+
const lines = buffer.split(`
|
|
299
|
+
`);
|
|
300
|
+
buffer = lines.pop() ?? "";
|
|
301
|
+
for (const line of lines)
|
|
302
|
+
processLine(line);
|
|
303
|
+
return pump();
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
pump().catch(() => controller.close());
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
return new Response(transformed, {
|
|
310
|
+
status: response.status,
|
|
311
|
+
statusText: response.statusText,
|
|
312
|
+
headers: new Headers({
|
|
313
|
+
"content-type": "text/event-stream",
|
|
314
|
+
"cache-control": "no-cache"
|
|
315
|
+
})
|
|
316
|
+
});
|
|
317
|
+
}
|
|
109
318
|
async function getSession(fetcher, requestHeaders) {
|
|
110
319
|
const key = requestHeaders.get("authorization") ?? "anonymous";
|
|
111
320
|
const cached = sessions.get(key);
|
|
@@ -140,12 +349,15 @@ async function route(fetcher, requestHeaders, session, payload) {
|
|
|
140
349
|
body: JSON.stringify({
|
|
141
350
|
prompt,
|
|
142
351
|
available_models: session.availableModels,
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
352
|
+
has_image: false,
|
|
353
|
+
...HYDRA_ROUTING ? {
|
|
354
|
+
session_id: "opencode-session://auto",
|
|
355
|
+
reference_count: 0,
|
|
356
|
+
prompt_char_count: prompt.length,
|
|
357
|
+
turn_number: userTurns(messages),
|
|
358
|
+
routing_method: "hydra",
|
|
359
|
+
copilot_plan: "individual"
|
|
360
|
+
} : {}
|
|
149
361
|
}),
|
|
150
362
|
signal: AbortSignal.timeout(5000)
|
|
151
363
|
});
|
|
@@ -156,7 +368,7 @@ async function route(fetcher, requestHeaders, session, payload) {
|
|
|
156
368
|
}
|
|
157
369
|
function copilotHeaders(requestHeaders) {
|
|
158
370
|
const headers = new Headers(requestHeaders);
|
|
159
|
-
headers.set("Content-Type", "
|
|
371
|
+
headers.set("Content-Type", "application/json");
|
|
160
372
|
headers.set("X-GitHub-Api-Version", COPILOT_API_VERSION);
|
|
161
373
|
return headers;
|
|
162
374
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opeoginni/opencode-copilot-auto",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Add GitHub Copilot Auto model routing to OpenCode",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"opencode",
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
],
|
|
12
12
|
"license": "MIT",
|
|
13
13
|
"author": "Opeyemi Oginni",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/OpeOginni/opencode-copilot-auto.git"
|
|
17
|
+
},
|
|
14
18
|
"type": "module",
|
|
15
19
|
"files": [
|
|
16
20
|
"dist",
|