@opeoginni/opencode-copilot-auto 0.1.5 → 0.1.7

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 +240 -19
  3. package/package.json +1 -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,25 +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: payload.tools } : {},
97
- ...payload.tool_choice !== undefined ? { tool_choice: payload.tool_choice } : {}
214
+ ...Array.isArray(payload.tools) ? { tools: payload.tools.map(unwrapFunction) } : {},
215
+ ...payload.tool_choice !== undefined ? { tool_choice: unwrapFunction(payload.tool_choice) } : {}
98
216
  };
99
217
  }
218
+ function unwrapFunction(value) {
219
+ if (!isRecord(value) || !isRecord(value.function))
220
+ return value;
221
+ const { function: fn, ...rest } = value;
222
+ return { ...fn, ...rest };
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
+ }
100
318
  async function getSession(fetcher, requestHeaders) {
101
319
  const key = requestHeaders.get("authorization") ?? "anonymous";
102
320
  const cached = sessions.get(key);
@@ -131,12 +349,15 @@ async function route(fetcher, requestHeaders, session, payload) {
131
349
  body: JSON.stringify({
132
350
  prompt,
133
351
  available_models: session.availableModels,
134
- session_id: "opencode-session://auto",
135
- reference_count: 0,
136
- prompt_char_count: prompt.length,
137
- turn_number: userTurns(messages),
138
- routing_method: "hydra",
139
- 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
+ } : {}
140
361
  }),
141
362
  signal: AbortSignal.timeout(5000)
142
363
  });
@@ -147,7 +368,7 @@ async function route(fetcher, requestHeaders, session, payload) {
147
368
  }
148
369
  function copilotHeaders(requestHeaders) {
149
370
  const headers = new Headers(requestHeaders);
150
- headers.set("Content-Type", "text/plain;charset=UTF-8");
371
+ headers.set("Content-Type", "application/json");
151
372
  headers.set("X-GitHub-Api-Version", COPILOT_API_VERSION);
152
373
  return headers;
153
374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opeoginni/opencode-copilot-auto",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Add GitHub Copilot Auto model routing to OpenCode",
5
5
  "keywords": [
6
6
  "opencode",