@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.
Files changed (3) hide show
  1. package/README.md +6 -0
  2. package/dist/index.js +233 -21
  3. 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-06-01";
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 CopilotAutoPlugin = async () => {
8
- installFetchAdapter();
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
- const next = usesResponses(model) ? toResponsesRequest(payload, model) : { ...payload, model };
65
- const url = usesResponses(model) ? toResponsesUrl(request.url) : request.url;
66
- return originalFetch(new Request(url, {
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 && !modelID.startsWith("gpt-5-mini"));
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 : undefined;
88
- const input = messages ? promptText(messages) : typeof payload.input === "string" ? payload.input : promptText(payload.input);
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: flattenTools(payload.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
- session_id: "opencode-session://auto",
144
- reference_count: 0,
145
- prompt_char_count: prompt.length,
146
- turn_number: userTurns(messages),
147
- routing_method: "hydra",
148
- copilot_plan: "individual"
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", "text/plain;charset=UTF-8");
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.6",
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",