@opeoginni/opencode-copilot-auto 0.1.8 → 1.0.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 CHANGED
@@ -1,33 +1,63 @@
1
1
  # @opeoginni/opencode-copilot-auto
2
2
 
3
- Adds GitHub Copilot's **Auto** model to OpenCode. Copilot selects the appropriate available model for every request.
3
+ Adds GitHub Copilot's **Auto** model to OpenCode V2. Pick `github-copilot/auto` and Copilot chooses which of your available models handles each prompt, the same way Auto works in VS Code.
4
4
 
5
- ## Install
5
+ Requires OpenCode `2.x`. For OpenCode 1, use `0.1.x`.
6
6
 
7
- Add the plugin to `opencode.json`:
7
+ ## Setup
8
8
 
9
- ```json
9
+ Connect GitHub Copilot in OpenCode (`/connect`), then add the plugin to `opencode.jsonc`:
10
+
11
+ ```jsonc
10
12
  {
11
13
  "$schema": "https://opencode.ai/config.json",
12
- "plugin": ["@opeoginni/opencode-copilot-auto"]
14
+ "plugins": ["@opeoginni/opencode-copilot-auto"]
13
15
  }
14
16
  ```
15
17
 
16
- Restart OpenCode, authenticate GitHub Copilot if necessary with `opencode auth login`, then select `github-copilot/auto`.
18
+ Restart OpenCode and select **Auto** under GitHub Copilot in the model picker. It only appears once Copilot is connected.
19
+
20
+ ## How it works
21
+
22
+ Each request to `auto` is sent to Copilot's routing endpoint, which picks a model from those available on your plan. The plugin then makes the real request with that model over its native protocol (Responses for GPT-5 and newer, Chat Completions otherwise), so tools, images and reasoning behave exactly as they do when you select the model directly.
23
+
24
+ Routing happens once per user prompt; tool calls within the same turn reuse the choice.
17
25
 
18
- The plugin uses the existing OpenCode GitHub Copilot authentication and sends the prompt to Copilot's routing endpoint solely to select a model.
26
+ ## Options
27
+
28
+ ```jsonc
29
+ {
30
+ "plugins": [
31
+ {
32
+ "package": "@opeoginni/opencode-copilot-auto",
33
+ "options": { "sticky": true, "notifications": true }
34
+ }
35
+ ]
36
+ }
37
+ ```
19
38
 
20
- ## Commands
39
+ | Option | Default | Description |
40
+ | --------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ |
41
+ | `sticky` | `false` | `false`: Copilot picks a model for every prompt. `true`: the first model Copilot picks is kept for the whole session. |
42
+ | `notifications` | `false` | Show a toast naming the model Copilot picked. Fires once per prompt, or once per session when `sticky` is on. Mostly useful while developing. |
21
43
 
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.
44
+ OpenCode does not record which model answered on the message itself, so the toast is the only place the choice is visible.
25
45
 
26
46
  ## Development
27
47
 
28
48
  ```sh
29
49
  bun install
30
50
  bun run check
31
- bun run test
51
+ bun test
32
52
  bun run build
33
53
  ```
54
+
55
+ To try the plugin locally without publishing, copy the example config, build, and start an isolated OpenCode in this directory:
56
+
57
+ ```sh
58
+ cp opencode.example.jsonc opencode.jsonc
59
+ bun run build
60
+ opencode --standalone
61
+ ```
62
+
63
+ `opencode.jsonc` points at `./dist`, so rebuild after changes. Running with `--standalone` keeps the test server separate from your regular OpenCode service.
package/dist/auto.js ADDED
@@ -0,0 +1,52 @@
1
+ import { lastUserPrompt } from "./prompt.js";
2
+ const PROVIDER_ID = "github-copilot";
3
+ const MODEL_ID = "auto";
4
+ const COPILOT_PACKAGE = "aisdk:@ai-sdk/github-copilot";
5
+ function isCopilotSDK(sdk) {
6
+ return (typeof sdk === "object" || typeof sdk === "function") && sdk !== null && typeof sdk.chat === "function" && typeof sdk.responses === "function";
7
+ }
8
+ function endpointFor(model, endpoints) {
9
+ const known = endpoints.get(model);
10
+ if (known === "responses") return "responses";
11
+ if (known === "chat" || known === "messages") return "chat";
12
+ if (model.startsWith("mai-")) return "responses";
13
+ const match = /^gpt-(\d+)/.exec(model);
14
+ return match && Number(match[1]) >= 5 && !model.startsWith("gpt-5-mini") ? "responses" : "chat";
15
+ }
16
+ function autoModel(input) {
17
+ const resolve = async (options) => {
18
+ const decision = await input.decide(lastUserPrompt(options.prompt));
19
+ const endpoint = endpointFor(decision.model, input.endpoints);
20
+ const model = endpoint === "responses" ? input.sdk.responses(decision.model) : input.sdk.chat(decision.model);
21
+ return {
22
+ model,
23
+ options: {
24
+ ...options,
25
+ headers: { ...options.headers, "copilot-session-token": decision.token }
26
+ }
27
+ };
28
+ };
29
+ return {
30
+ specificationVersion: "v3",
31
+ provider: PROVIDER_ID,
32
+ modelId: MODEL_ID,
33
+ supportedUrls: {},
34
+ async doGenerate(options) {
35
+ const next = await resolve(options);
36
+ return next.model.doGenerate(next.options);
37
+ },
38
+ async doStream(options) {
39
+ const next = await resolve(options);
40
+ return next.model.doStream(next.options);
41
+ }
42
+ };
43
+ }
44
+ export {
45
+ COPILOT_PACKAGE,
46
+ MODEL_ID,
47
+ PROVIDER_ID,
48
+ autoModel,
49
+ endpointFor,
50
+ isCopilotSDK
51
+ };
52
+ //# sourceMappingURL=auto.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auto.ts"],"sourcesContent":["import type { LanguageModelV3, LanguageModelV3CallOptions } from \"@ai-sdk/provider\"\nimport { lastUserPrompt, type Prompt } from \"./prompt.js\"\nimport type { Decision } from \"./router.js\"\n\nexport const PROVIDER_ID = \"github-copilot\"\nexport const MODEL_ID = \"auto\"\nexport const COPILOT_PACKAGE = \"aisdk:@ai-sdk/github-copilot\"\n\n/** The SDK OpenCode's built-in Copilot plugin creates for `@ai-sdk/github-copilot` models. */\nexport type CopilotSDK = {\n chat: (modelID: string) => LanguageModelV3\n responses: (modelID: string) => LanguageModelV3\n}\n\n// AI SDK providers are callable functions with model factories attached as properties.\nexport function isCopilotSDK(sdk: unknown): sdk is CopilotSDK {\n return (\n (typeof sdk === \"object\" || typeof sdk === \"function\") &&\n sdk !== null &&\n typeof (sdk as CopilotSDK).chat === \"function\" &&\n typeof (sdk as CopilotSDK).responses === \"function\"\n )\n}\n\n/**\n * Mirrors the built-in Copilot plugin: use the endpoint Copilot advertises for\n * the model, otherwise GPT-5 and newer speak Responses. `/v1/messages` models\n * fall back to chat because the OpenAI-compatible SDK cannot speak it.\n */\nexport function endpointFor(model: string, endpoints: ReadonlyMap<string, string>): \"chat\" | \"responses\" {\n const known = endpoints.get(model)\n if (known === \"responses\") return \"responses\"\n if (known === \"chat\" || known === \"messages\") return \"chat\"\n if (model.startsWith(\"mai-\")) return \"responses\"\n const match = /^gpt-(\\d+)/.exec(model)\n return match && Number(match[1]) >= 5 && !model.startsWith(\"gpt-5-mini\") ? \"responses\" : \"chat\"\n}\n\nexport type AutoModelInput = {\n sdk: CopilotSDK\n endpoints: ReadonlyMap<string, string>\n decide: (prompt: Prompt) => Promise<Decision>\n}\n\n/**\n * A language model that asks Copilot which model to use, then delegates the\n * call to that model's native protocol.\n */\nexport function autoModel(input: AutoModelInput): LanguageModelV3 {\n const resolve = async (options: LanguageModelV3CallOptions) => {\n const decision = await input.decide(lastUserPrompt(options.prompt))\n const endpoint = endpointFor(decision.model, input.endpoints)\n const model = endpoint === \"responses\" ? input.sdk.responses(decision.model) : input.sdk.chat(decision.model)\n return {\n model,\n options: {\n ...options,\n headers: { ...options.headers, \"copilot-session-token\": decision.token },\n },\n }\n }\n\n return {\n specificationVersion: \"v3\",\n provider: PROVIDER_ID,\n modelId: MODEL_ID,\n supportedUrls: {},\n async doGenerate(options) {\n const next = await resolve(options)\n return next.model.doGenerate(next.options)\n },\n async doStream(options) {\n const next = await resolve(options)\n return next.model.doStream(next.options)\n },\n }\n}\n"],"mappings":"AACA,SAAS,sBAAmC;AAGrC,MAAM,cAAc;AACpB,MAAM,WAAW;AACjB,MAAM,kBAAkB;AASxB,SAAS,aAAa,KAAiC;AAC5D,UACG,OAAO,QAAQ,YAAY,OAAO,QAAQ,eAC3C,QAAQ,QACR,OAAQ,IAAmB,SAAS,cACpC,OAAQ,IAAmB,cAAc;AAE7C;AAOO,SAAS,YAAY,OAAe,WAA8D;AACvG,QAAM,QAAQ,UAAU,IAAI,KAAK;AACjC,MAAI,UAAU,YAAa,QAAO;AAClC,MAAI,UAAU,UAAU,UAAU,WAAY,QAAO;AACrD,MAAI,MAAM,WAAW,MAAM,EAAG,QAAO;AACrC,QAAM,QAAQ,aAAa,KAAK,KAAK;AACrC,SAAO,SAAS,OAAO,MAAM,CAAC,CAAC,KAAK,KAAK,CAAC,MAAM,WAAW,YAAY,IAAI,cAAc;AAC3F;AAYO,SAAS,UAAU,OAAwC;AAChE,QAAM,UAAU,OAAO,YAAwC;AAC7D,UAAM,WAAW,MAAM,MAAM,OAAO,eAAe,QAAQ,MAAM,CAAC;AAClE,UAAM,WAAW,YAAY,SAAS,OAAO,MAAM,SAAS;AAC5D,UAAM,QAAQ,aAAa,cAAc,MAAM,IAAI,UAAU,SAAS,KAAK,IAAI,MAAM,IAAI,KAAK,SAAS,KAAK;AAC5G,WAAO;AAAA,MACL;AAAA,MACA,SAAS;AAAA,QACP,GAAG;AAAA,QACH,SAAS,EAAE,GAAG,QAAQ,SAAS,yBAAyB,SAAS,MAAM;AAAA,MACzE;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AAAA,IACL,sBAAsB;AAAA,IACtB,UAAU;AAAA,IACV,SAAS;AAAA,IACT,eAAe,CAAC;AAAA,IAChB,MAAM,WAAW,SAAS;AACxB,YAAM,OAAO,MAAM,QAAQ,OAAO;AAClC,aAAO,KAAK,MAAM,WAAW,KAAK,OAAO;AAAA,IAC3C;AAAA,IACA,MAAM,SAAS,SAAS;AACtB,YAAM,OAAO,MAAM,QAAQ,OAAO;AAClC,aAAO,KAAK,MAAM,SAAS,KAAK,OAAO;AAAA,IACzC;AAAA,EACF;AACF;","names":[]}
package/dist/cache.js ADDED
@@ -0,0 +1,29 @@
1
+ class Cache {
2
+ constructor(limit) {
3
+ this.limit = limit;
4
+ }
5
+ limit;
6
+ entries = /* @__PURE__ */ new Map();
7
+ get(key) {
8
+ return this.entries.get(key);
9
+ }
10
+ set(key, value) {
11
+ this.entries.delete(key);
12
+ this.entries.set(key, value);
13
+ if (this.entries.size > this.limit) {
14
+ const oldest = this.entries.keys().next();
15
+ if (!oldest.done) this.entries.delete(oldest.value);
16
+ }
17
+ return value;
18
+ }
19
+ delete(key) {
20
+ this.entries.delete(key);
21
+ }
22
+ get size() {
23
+ return this.entries.size;
24
+ }
25
+ }
26
+ export {
27
+ Cache
28
+ };
29
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cache.ts"],"sourcesContent":["/** Small insertion-ordered cache that drops the oldest entry past `limit`. */\nexport class Cache<K, V> {\n private readonly entries = new Map<K, V>()\n\n constructor(private readonly limit: number) {}\n\n get(key: K): V | undefined {\n return this.entries.get(key)\n }\n\n set(key: K, value: V): V {\n this.entries.delete(key)\n this.entries.set(key, value)\n if (this.entries.size > this.limit) {\n const oldest = this.entries.keys().next()\n if (!oldest.done) this.entries.delete(oldest.value)\n }\n return value\n }\n\n delete(key: K) {\n this.entries.delete(key)\n }\n\n get size() {\n return this.entries.size\n }\n}\n"],"mappings":"AACO,MAAM,MAAY;AAAA,EAGvB,YAA6B,OAAe;AAAf;AAAA,EAAgB;AAAA,EAAhB;AAAA,EAFZ,UAAU,oBAAI,IAAU;AAAA,EAIzC,IAAI,KAAuB;AACzB,WAAO,KAAK,QAAQ,IAAI,GAAG;AAAA,EAC7B;AAAA,EAEA,IAAI,KAAQ,OAAa;AACvB,SAAK,QAAQ,OAAO,GAAG;AACvB,SAAK,QAAQ,IAAI,KAAK,KAAK;AAC3B,QAAI,KAAK,QAAQ,OAAO,KAAK,OAAO;AAClC,YAAM,SAAS,KAAK,QAAQ,KAAK,EAAE,KAAK;AACxC,UAAI,CAAC,OAAO,KAAM,MAAK,QAAQ,OAAO,OAAO,KAAK;AAAA,IACpD;AACA,WAAO;AAAA,EACT;AAAA,EAEA,OAAO,KAAQ;AACb,SAAK,QAAQ,OAAO,GAAG;AAAA,EACzB;AAAA,EAEA,IAAI,OAAO;AACT,WAAO,KAAK,QAAQ;AAAA,EACtB;AACF;","names":[]}
package/dist/index.js CHANGED
@@ -1,407 +1,101 @@
1
- // @bun
2
- // src/index.ts
3
- var COPILOT_BASE_URL = "https://api.individual.githubcopilot.com";
4
- var COPILOT_API_VERSION = "2026-07-01";
5
- var SESSION_REFRESH_BUFFER_SECONDS = 30;
6
- var HYDRA_ROUTING = false;
7
- var PROJECTION_KEY = "copilot-auto";
8
- var notifyMode = "toast";
9
- var sessions = new Map;
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);
44
- return {
45
- provider: {
46
- id: "github-copilot",
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
- }
86
- }
87
- };
88
- };
89
- function autoModel() {
90
- return {
91
- id: "auto",
92
- providerID: "github-copilot",
93
- name: "Auto",
94
- family: "gpt",
95
- api: {
96
- id: "auto",
97
- url: COPILOT_BASE_URL,
98
- npm: "@ai-sdk/github-copilot"
99
- },
100
- status: "active",
101
- headers: {},
102
- options: {},
103
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
104
- limit: { context: 128000, input: 128000, output: 16384 },
105
- capabilities: {
106
- temperature: true,
107
- reasoning: false,
108
- attachment: true,
109
- toolcall: true,
110
- input: { text: true, audio: false, image: true, video: false, pdf: false },
111
- output: { text: true, audio: false, image: false, video: false, pdf: false },
112
- interleaved: false
113
- },
114
- release_date: "",
115
- variants: {}
116
- };
117
- }
118
- function installFetchAdapter(client) {
119
- const marker = Symbol.for("opeoginni.opencode-copilot-auto.fetch-adapter");
120
- const runtime = globalThis;
121
- if (runtime[marker])
122
- return;
123
- runtime[marker] = true;
124
- const originalFetch = globalThis.fetch.bind(globalThis);
125
- const adapter = async (input, init) => {
126
- const request = new Request(input, init);
127
- if (!isAutoRequest(request))
128
- return originalFetch(input, init);
129
- const body = await request.clone().text();
130
- const payload = parseJson(body);
131
- if (!payload || payload.model !== "auto")
132
- return originalFetch(input, init);
133
- if (autoRefresh)
134
- sessions.clear();
135
- const session = await getSession(originalFetch, request.headers);
136
- const model = await route(originalFetch, request.headers, session, payload);
137
- await notify(client, `Routed to ${model}`);
138
- const useResponses = usesResponses(model);
139
- const headers = new Headers(request.headers);
140
- headers.set("copilot-session-token", session.token);
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, {
145
- method: request.method,
146
- headers,
147
- body: JSON.stringify(next),
148
- signal: request.signal
149
- }));
150
- return useResponses ? wrapResponsesResponse(response) : response;
151
- };
152
- globalThis.fetch = Object.assign(adapter, originalFetch);
153
- }
154
- function isAutoRequest(request) {
155
- const url = new URL(request.url);
156
- return url.origin === COPILOT_BASE_URL && request.method === "POST" && (url.pathname.endsWith("/chat/completions") || url.pathname.endsWith("/responses"));
157
- }
158
- function usesResponses(modelID) {
159
- const match = /^gpt-(\d+)/.exec(modelID);
160
- return Boolean(match && Number(match[1]) >= 5);
161
- }
162
- function toResponsesUrl(url) {
163
- return url.replace(/\/chat\/completions\/?$/, "/responses");
164
- }
165
- function toResponsesRequest(payload, model) {
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
- });
206
- return {
207
- model,
208
- input,
209
- stream: payload.stream === true,
210
- ...instructions ? { instructions } : {},
211
- ...typeof payload.temperature === "number" ? { temperature: payload.temperature } : {},
212
- ...typeof payload.top_p === "number" ? { top_p: payload.top_p } : {},
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 } : {},
214
- ...Array.isArray(payload.tools) ? { tools: payload.tools.map(unwrapFunction) } : {},
215
- ...payload.tool_choice !== undefined ? { tool_choice: unwrapFunction(payload.tool_choice) } : {}
216
- };
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;
1
+ import { Plugin } from "@opencode/plugin";
2
+ import { autoModel, COPILOT_PACKAGE, isCopilotSDK, MODEL_ID, PROVIDER_ID } from "./auto.js";
3
+ import { Cache } from "./cache.js";
4
+ import { fingerprint, lastUserPrompt } from "./prompt.js";
5
+ import { Router } from "./router.js";
6
+ import { CopilotAuto } from "./rpc.js";
7
+ const DEFAULT_BASE_URL = "https://api.githubcopilot.com";
8
+ var index_default = Plugin.define({
9
+ id: "opencode-copilot-auto",
10
+ async setup(ctx) {
11
+ const sticky = ctx.options.sticky === true;
12
+ const notifications = ctx.options.notifications === true;
13
+ const rpc = notifications ? await ctx.rpc.register(CopilotAuto, {}) : void 0;
14
+ const endpoints = /* @__PURE__ */ new Map();
15
+ const sessions = new Cache(500);
16
+ const models = new Cache(500);
17
+ const routers = /* @__PURE__ */ new Map();
18
+ await ctx.provider.transform((editor) => {
19
+ endpoints.clear();
20
+ const record = editor.get(PROVIDER_ID);
21
+ if (!record?.sourceConnection) return;
22
+ let template;
23
+ for (const model of record.models.values()) {
24
+ if (model.id === MODEL_ID) continue;
25
+ const endpoint = model.settings?.endpoint;
26
+ if (typeof endpoint === "string") endpoints.set(model.id, endpoint);
27
+ if (!template && model.package === COPILOT_PACKAGE) template = model;
236
28
  }
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]")
29
+ if (!template) return;
30
+ const baseURL = template.settings?.baseURL;
31
+ editor.models.update(PROVIDER_ID, MODEL_ID, (model) => {
32
+ model.name = "Auto";
33
+ model.package = COPILOT_PACKAGE;
34
+ model.settings = { ...typeof baseURL === "string" ? { baseURL } : {}, endpoint: "chat" };
35
+ model.capabilities = { tools: true, input: ["text", "image"], output: ["text"] };
36
+ model.limit = { context: 128e3, output: 16384 };
37
+ model.enabled = true;
38
+ });
39
+ });
40
+ const remember = (event) => {
41
+ if (event.model.id !== MODEL_ID) return;
42
+ sessions.set(fingerprint(lastUserPrompt(event.messages)), event.sessionID);
43
+ };
44
+ await ctx.session.hook("context", remember, { providerID: PROVIDER_ID });
45
+ await ctx.session.hook("compaction", remember, { providerID: PROVIDER_ID });
46
+ await ctx.session.hook("generate", remember, { providerID: PROVIDER_ID });
47
+ await ctx.session.hook("title", remember, { providerID: PROVIDER_ID });
48
+ const decide = async (router, prompt) => {
49
+ const prompted = fingerprint(prompt);
50
+ const sessionID = sessions.get(prompted);
51
+ const key = sticky ? sessionID ?? prompted : prompted;
52
+ const pending = models.get(key) ?? models.set(
53
+ key,
54
+ router.route(prompt).then((model) => {
55
+ void rpc?.events.emit("routed", { model, ...sessionID ? { sessionID } : {} }).catch(() => {
56
+ });
57
+ return model;
58
+ }).catch((error) => {
59
+ models.delete(key);
60
+ throw error;
61
+ })
62
+ );
63
+ return { model: await pending, token: await router.token() };
64
+ };
65
+ await ctx.aisdk.hook(
66
+ "language",
67
+ (event) => {
68
+ if (event.model.providerID !== PROVIDER_ID || event.model.id !== MODEL_ID) return;
69
+ if (!isCopilotSDK(event.sdk)) {
70
+ console.error(`[copilot-auto] unexpected Copilot SDK shape, leaving model untouched`);
255
71
  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();
72
+ }
73
+ const baseURL = typeof event.options.baseURL === "string" ? event.options.baseURL : DEFAULT_BASE_URL;
74
+ const apiKey = typeof event.options.apiKey === "string" ? event.options.apiKey : "";
75
+ const key = `${baseURL} ${apiKey}`;
76
+ const router = routers.get(key) ?? new Router(baseURL, fetcher(event.options));
77
+ routers.set(key, router);
78
+ event.language = autoModel({
79
+ sdk: event.sdk,
80
+ endpoints,
81
+ decide: (prompt) => decide(router, prompt)
304
82
  });
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
- }
318
- async function getSession(fetcher, requestHeaders) {
319
- const key = requestHeaders.get("authorization") ?? "anonymous";
320
- const cached = sessions.get(key);
321
- if (cached && cached.expiresAt > Math.floor(Date.now() / 1000) + SESSION_REFRESH_BUFFER_SECONDS)
322
- return cached;
323
- const response = await fetcher(`${COPILOT_BASE_URL}/models/session`, {
324
- method: "POST",
325
- headers: copilotHeaders(requestHeaders),
326
- body: JSON.stringify({ auto_mode: { model_hints: ["auto"] } }),
327
- signal: AbortSignal.timeout(5000)
328
- });
329
- if (!response.ok)
330
- throw new Error(`Copilot Auto could not create a routing session: ${response.status}`);
331
- const data = await response.json();
332
- const session = {
333
- availableModels: data.available_models,
334
- selectedModel: data.selected_model,
335
- token: data.session_token,
336
- expiresAt: data.expires_at
337
- };
338
- sessions.set(key, session);
339
- return session;
340
- }
341
- async function route(fetcher, requestHeaders, session, payload) {
342
- const messages = payload.messages ?? payload.input;
343
- const prompt = promptText(messages);
344
- const headers = copilotHeaders(requestHeaders);
345
- headers.set("copilot-session-token", session.token);
346
- const response = await fetcher(`${COPILOT_BASE_URL}/models/session/intent`, {
347
- method: "POST",
348
- headers,
349
- body: JSON.stringify({
350
- prompt,
351
- available_models: session.availableModels,
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
- } : {}
361
- }),
362
- signal: AbortSignal.timeout(5000)
363
- });
364
- if (!response.ok)
365
- throw new Error(`Copilot Auto could not select a model: ${response.status}`);
366
- const intent = await response.json();
367
- return intent.chosen_model ?? session.selectedModel;
368
- }
369
- function copilotHeaders(requestHeaders) {
370
- const headers = new Headers(requestHeaders);
371
- headers.set("Content-Type", "application/json");
372
- headers.set("X-GitHub-Api-Version", COPILOT_API_VERSION);
373
- return headers;
374
- }
375
- function parseJson(value) {
376
- try {
377
- const parsed = JSON.parse(value);
378
- return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : undefined;
379
- } catch {
380
- return;
83
+ },
84
+ { providerID: PROVIDER_ID }
85
+ );
381
86
  }
87
+ });
88
+ function fetcher(options) {
89
+ if (typeof options.fetch === "function") return options.fetch;
90
+ const apiKey = typeof options.apiKey === "string" ? options.apiKey : void 0;
91
+ if (!apiKey) return fetch;
92
+ return (input, init) => {
93
+ const headers = new Headers(init?.headers);
94
+ headers.set("Authorization", `Bearer ${apiKey}`);
95
+ return fetch(input, { ...init, headers });
96
+ };
382
97
  }
383
- function promptText(messages) {
384
- if (!Array.isArray(messages))
385
- return "";
386
- const message = [...messages].reverse().find((item) => isRecord(item) && item.role === "user");
387
- if (!message)
388
- return "";
389
- const content = message.content;
390
- if (typeof content === "string")
391
- return content;
392
- if (!Array.isArray(content))
393
- return "";
394
- return content.map((part) => isRecord(part) && typeof part.text === "string" ? part.text : "").filter(Boolean).join(`
395
- `);
396
- }
397
- function userTurns(messages) {
398
- return Array.isArray(messages) ? messages.filter((item) => isRecord(item) && item.role === "user").length : 0;
399
- }
400
- function isRecord(value) {
401
- return typeof value === "object" && value !== null && !Array.isArray(value);
402
- }
403
- var src_default = CopilotAutoPlugin;
404
98
  export {
405
- src_default as default,
406
- CopilotAutoPlugin
99
+ index_default as default
407
100
  };
101
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { Plugin, type Model } from \"@opencode/plugin\"\nimport { autoModel, COPILOT_PACKAGE, isCopilotSDK, MODEL_ID, PROVIDER_ID } from \"./auto.js\"\nimport { Cache } from \"./cache.js\"\nimport { fingerprint, lastUserPrompt, type Prompt } from \"./prompt.js\"\nimport { Router, type Decision, type Fetch } from \"./router.js\"\nimport { CopilotAuto } from \"./rpc.js\"\n\nconst DEFAULT_BASE_URL = \"https://api.githubcopilot.com\"\n\nexport default Plugin.define({\n id: \"opencode-copilot-auto\",\n async setup(ctx) {\n const sticky = ctx.options.sticky === true\n const notifications = ctx.options.notifications === true\n // The TUI half of this package listens for `routed` and shows a toast.\n const rpc = notifications ? await ctx.rpc.register(CopilotAuto, {}) : undefined\n\n // Endpoint Copilot advertises per model, captured from the provider inventory.\n const endpoints = new Map<string, string>()\n // Last user prompt -> session, so the model wrapper can tell sessions apart.\n const sessions = new Cache<string, string>(500)\n // Routing decisions keyed by session (sticky) or by prompt.\n const models = new Cache<string, Promise<string>>(500)\n const routers = new Map<string, Router>()\n\n await ctx.provider.transform((editor) => {\n endpoints.clear()\n const record = editor.get(PROVIDER_ID)\n // The built-in Copilot plugin binds the inventory to a connection only\n // after a successful login, so this is the \"user has authed\" signal.\n if (!record?.sourceConnection) return\n\n let template: Model.Info | undefined\n for (const model of record.models.values()) {\n if (model.id === MODEL_ID) continue\n const endpoint = model.settings?.endpoint\n if (typeof endpoint === \"string\") endpoints.set(model.id, endpoint)\n if (!template && model.package === COPILOT_PACKAGE) template = model\n }\n if (!template) return\n\n const baseURL = template.settings?.baseURL\n editor.models.update(PROVIDER_ID, MODEL_ID, (model) => {\n model.name = \"Auto\"\n model.package = COPILOT_PACKAGE\n model.settings = { ...(typeof baseURL === \"string\" ? { baseURL } : {}), endpoint: \"chat\" }\n model.capabilities = { tools: true, input: [\"text\", \"image\"], output: [\"text\"] }\n model.limit = { context: 128_000, output: 16_384 }\n model.enabled = true\n })\n })\n\n const remember = (event: { sessionID: string; model: Model.Ref; messages: unknown }) => {\n if (event.model.id !== MODEL_ID) return\n sessions.set(fingerprint(lastUserPrompt(event.messages)), event.sessionID)\n }\n await ctx.session.hook(\"context\", remember, { providerID: PROVIDER_ID })\n await ctx.session.hook(\"compaction\", remember, { providerID: PROVIDER_ID })\n await ctx.session.hook(\"generate\", remember, { providerID: PROVIDER_ID })\n await ctx.session.hook(\"title\", remember, { providerID: PROVIDER_ID })\n\n const decide = async (router: Router, prompt: Prompt): Promise<Decision> => {\n const prompted = fingerprint(prompt)\n const sessionID = sessions.get(prompted)\n const key = sticky ? (sessionID ?? prompted) : prompted\n const pending =\n models.get(key) ??\n models.set(\n key,\n router\n .route(prompt)\n .then((model) => {\n // Fresh decision: once per session when sticky, once per prompt otherwise.\n // Fire-and-forget so the model request never waits on the UI.\n void rpc?.events.emit(\"routed\", { model, ...(sessionID ? { sessionID } : {}) }).catch(() => {})\n return model\n })\n .catch((error: unknown) => {\n models.delete(key)\n throw error\n }),\n )\n return { model: await pending, token: await router.token() }\n }\n\n await ctx.aisdk.hook(\n \"language\",\n (event) => {\n if (event.model.providerID !== PROVIDER_ID || event.model.id !== MODEL_ID) return\n if (!isCopilotSDK(event.sdk)) {\n console.error(`[copilot-auto] unexpected Copilot SDK shape, leaving model untouched`)\n return\n }\n\n const baseURL = typeof event.options.baseURL === \"string\" ? event.options.baseURL : DEFAULT_BASE_URL\n const apiKey = typeof event.options.apiKey === \"string\" ? event.options.apiKey : \"\"\n const key = `${baseURL} ${apiKey}`\n const router = routers.get(key) ?? new Router(baseURL, fetcher(event.options))\n routers.set(key, router)\n\n event.language = autoModel({\n sdk: event.sdk,\n endpoints,\n decide: (prompt) => decide(router, prompt),\n })\n },\n { providerID: PROVIDER_ID },\n )\n },\n})\n\n/**\n * The built-in Copilot plugin installs an authenticated fetch on the model\n * options. Fall back to a bearer token when it is not there.\n */\nfunction fetcher(options: Record<string, unknown>): Fetch {\n if (typeof options.fetch === \"function\") return options.fetch as Fetch\n const apiKey = typeof options.apiKey === \"string\" ? options.apiKey : undefined\n if (!apiKey) return fetch\n return (input, init) => {\n const headers = new Headers(init?.headers)\n headers.set(\"Authorization\", `Bearer ${apiKey}`)\n return fetch(input, { ...init, headers })\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@opeoginni/opencode-copilot-auto",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "exports": {
7
+ ".": "./index.js",
8
+ "./rpc": "./rpc.js",
9
+ "./tui": "./tui.js"
10
+ },
11
+ "dependencies": {
12
+ "@opencode/plugin": "^2.0.12"
13
+ }
14
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createHash } from "node:crypto";
2
+ function lastUserPrompt(messages) {
3
+ const message = lastUser(messages);
4
+ if (!message) return { text: "", image: false };
5
+ const content = message.content;
6
+ if (typeof content === "string") return { text: content, image: false };
7
+ if (!Array.isArray(content)) return { text: "", image: false };
8
+ const parts = content.filter(isRecord);
9
+ return {
10
+ text: parts.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n"),
11
+ image: parts.some(
12
+ (part) => (part.type === "file" || part.type === "media" || part.type === "image") && typeof part.mediaType === "string" && part.mediaType.startsWith("image/")
13
+ )
14
+ };
15
+ }
16
+ function fingerprint(prompt) {
17
+ return createHash("sha1").update(prompt.text).digest("hex");
18
+ }
19
+ function lastUser(messages) {
20
+ if (!Array.isArray(messages)) return void 0;
21
+ for (let i = messages.length - 1; i >= 0; i--) {
22
+ const message = messages[i];
23
+ if (isRecord(message) && message.role === "user") return message;
24
+ }
25
+ return void 0;
26
+ }
27
+ function isRecord(value) {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ }
30
+ export {
31
+ fingerprint,
32
+ isRecord,
33
+ lastUserPrompt
34
+ };
35
+ //# sourceMappingURL=prompt.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/prompt.ts"],"sourcesContent":["import { createHash } from \"node:crypto\"\n\n// Works for both OpenCode's internal messages (parts: text | media) and the\n// AI SDK LanguageModelV3 prompt (parts: text | file). Only the fields shared\n// by both shapes are read.\nexport type Prompt = {\n text: string\n image: boolean\n}\n\nexport function lastUserPrompt(messages: unknown): Prompt {\n const message = lastUser(messages)\n if (!message) return { text: \"\", image: false }\n const content = message.content\n if (typeof content === \"string\") return { text: content, image: false }\n if (!Array.isArray(content)) return { text: \"\", image: false }\n const parts = content.filter(isRecord)\n return {\n text: parts\n .filter((part) => part.type === \"text\" && typeof part.text === \"string\")\n .map((part) => part.text as string)\n .join(\"\\n\"),\n image: parts.some(\n (part) =>\n (part.type === \"file\" || part.type === \"media\" || part.type === \"image\") &&\n typeof part.mediaType === \"string\" &&\n part.mediaType.startsWith(\"image/\"),\n ),\n }\n}\n\nexport function fingerprint(prompt: Prompt): string {\n return createHash(\"sha1\").update(prompt.text).digest(\"hex\")\n}\n\nfunction lastUser(messages: unknown): Record<string, unknown> | undefined {\n if (!Array.isArray(messages)) return undefined\n for (let i = messages.length - 1; i >= 0; i--) {\n const message: unknown = messages[i]\n if (isRecord(message) && message.role === \"user\") return message\n }\n return undefined\n}\n\nexport function isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value)\n}\n"],"mappings":"AAAA,SAAS,kBAAkB;AAUpB,SAAS,eAAe,UAA2B;AACxD,QAAM,UAAU,SAAS,QAAQ;AACjC,MAAI,CAAC,QAAS,QAAO,EAAE,MAAM,IAAI,OAAO,MAAM;AAC9C,QAAM,UAAU,QAAQ;AACxB,MAAI,OAAO,YAAY,SAAU,QAAO,EAAE,MAAM,SAAS,OAAO,MAAM;AACtE,MAAI,CAAC,MAAM,QAAQ,OAAO,EAAG,QAAO,EAAE,MAAM,IAAI,OAAO,MAAM;AAC7D,QAAM,QAAQ,QAAQ,OAAO,QAAQ;AACrC,SAAO;AAAA,IACL,MAAM,MACH,OAAO,CAAC,SAAS,KAAK,SAAS,UAAU,OAAO,KAAK,SAAS,QAAQ,EACtE,IAAI,CAAC,SAAS,KAAK,IAAc,EACjC,KAAK,IAAI;AAAA,IACZ,OAAO,MAAM;AAAA,MACX,CAAC,UACE,KAAK,SAAS,UAAU,KAAK,SAAS,WAAW,KAAK,SAAS,YAChE,OAAO,KAAK,cAAc,YAC1B,KAAK,UAAU,WAAW,QAAQ;AAAA,IACtC;AAAA,EACF;AACF;AAEO,SAAS,YAAY,QAAwB;AAClD,SAAO,WAAW,MAAM,EAAE,OAAO,OAAO,IAAI,EAAE,OAAO,KAAK;AAC5D;AAEA,SAAS,SAAS,UAAwD;AACxE,MAAI,CAAC,MAAM,QAAQ,QAAQ,EAAG,QAAO;AACrC,WAAS,IAAI,SAAS,SAAS,GAAG,KAAK,GAAG,KAAK;AAC7C,UAAM,UAAmB,SAAS,CAAC;AACnC,QAAI,SAAS,OAAO,KAAK,QAAQ,SAAS,OAAQ,QAAO;AAAA,EAC3D;AACA,SAAO;AACT;AAEO,SAAS,SAAS,OAAkD;AACzE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;","names":[]}
package/dist/router.js ADDED
@@ -0,0 +1,69 @@
1
+ const API_VERSION = "2026-08-01";
2
+ const REFRESH_BUFFER_SECONDS = 30;
3
+ const TIMEOUT_MS = 5e3;
4
+ class Router {
5
+ constructor(baseURL, call) {
6
+ this.baseURL = baseURL;
7
+ this.call = call;
8
+ }
9
+ baseURL;
10
+ call;
11
+ session;
12
+ /** Current routing session token. Refreshed when close to expiry. */
13
+ async token() {
14
+ return (await this.getSession()).token;
15
+ }
16
+ /** Ask Copilot which model should handle the prompt. */
17
+ async route(prompt) {
18
+ const session = await this.getSession();
19
+ const response = await this.call(`${this.baseURL}/models/session/intent`, {
20
+ method: "POST",
21
+ headers: headers({ "copilot-session-token": session.token }),
22
+ body: JSON.stringify({
23
+ prompt: prompt.text,
24
+ available_models: session.availableModels,
25
+ has_image: prompt.image
26
+ }),
27
+ signal: AbortSignal.timeout(TIMEOUT_MS)
28
+ });
29
+ if (!response.ok) throw new Error(`Copilot Auto could not select a model: ${response.status}`);
30
+ const intent = await response.json();
31
+ debug("intent", intent);
32
+ return intent.chosen_model ?? session.selectedModel;
33
+ }
34
+ async getSession() {
35
+ const now = Math.floor(Date.now() / 1e3);
36
+ if (this.session && this.session.expiresAt > now + REFRESH_BUFFER_SECONDS) return this.session;
37
+ const response = await this.call(`${this.baseURL}/models/session`, {
38
+ method: "POST",
39
+ headers: headers(),
40
+ body: JSON.stringify({ auto_mode: { model_hints: ["auto"] } }),
41
+ signal: AbortSignal.timeout(TIMEOUT_MS)
42
+ });
43
+ if (!response.ok) throw new Error(`Copilot Auto could not create a routing session: ${response.status}`);
44
+ const data = await response.json();
45
+ debug("session", { ...data, session_token: "<redacted>" });
46
+ this.session = {
47
+ availableModels: data.available_models,
48
+ selectedModel: data.selected_model,
49
+ token: data.session_token,
50
+ expiresAt: data.expires_at
51
+ };
52
+ return this.session;
53
+ }
54
+ }
55
+ function debug(label, value) {
56
+ if (!process.env.OPENCODE_COPILOT_AUTO_DEBUG) return;
57
+ console.error(`[copilot-auto] ${label} ${JSON.stringify(value)}`);
58
+ }
59
+ function headers(extra = {}) {
60
+ return {
61
+ "Content-Type": "application/json",
62
+ "X-GitHub-Api-Version": API_VERSION,
63
+ ...extra
64
+ };
65
+ }
66
+ export {
67
+ Router
68
+ };
69
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/router.ts"],"sourcesContent":["import type { Prompt } from \"./prompt.js\"\n\nconst API_VERSION = \"2026-08-01\"\nconst REFRESH_BUFFER_SECONDS = 30\nconst TIMEOUT_MS = 5_000\n\nexport type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n\nexport type Decision = {\n model: string\n token: string\n}\n\ntype CopilotSession = {\n availableModels: string[]\n selectedModel: string\n token: string\n expiresAt: number\n}\n\n/**\n * Talks to Copilot's routing endpoints. The fetch passed in must already\n * carry Copilot authentication; OpenCode's built-in Copilot plugin provides\n * one on the model options.\n */\nexport class Router {\n private session?: CopilotSession\n\n constructor(\n private readonly baseURL: string,\n private readonly call: Fetch,\n ) {}\n\n /** Current routing session token. Refreshed when close to expiry. */\n async token(): Promise<string> {\n return (await this.getSession()).token\n }\n\n /** Ask Copilot which model should handle the prompt. */\n async route(prompt: Prompt): Promise<string> {\n const session = await this.getSession()\n const response = await this.call(`${this.baseURL}/models/session/intent`, {\n method: \"POST\",\n headers: headers({ \"copilot-session-token\": session.token }),\n body: JSON.stringify({\n prompt: prompt.text,\n available_models: session.availableModels,\n has_image: prompt.image,\n }),\n signal: AbortSignal.timeout(TIMEOUT_MS),\n })\n if (!response.ok) throw new Error(`Copilot Auto could not select a model: ${response.status}`)\n const intent = (await response.json()) as { chosen_model?: string }\n debug(\"intent\", intent)\n return intent.chosen_model ?? session.selectedModel\n }\n\n private async getSession(): Promise<CopilotSession> {\n const now = Math.floor(Date.now() / 1000)\n if (this.session && this.session.expiresAt > now + REFRESH_BUFFER_SECONDS) return this.session\n\n const response = await this.call(`${this.baseURL}/models/session`, {\n method: \"POST\",\n headers: headers(),\n body: JSON.stringify({ auto_mode: { model_hints: [\"auto\"] } }),\n signal: AbortSignal.timeout(TIMEOUT_MS),\n })\n if (!response.ok) throw new Error(`Copilot Auto could not create a routing session: ${response.status}`)\n const data = (await response.json()) as {\n available_models: string[]\n selected_model: string\n session_token: string\n expires_at: number\n }\n debug(\"session\", { ...data, session_token: \"<redacted>\" })\n this.session = {\n availableModels: data.available_models,\n selectedModel: data.selected_model,\n token: data.session_token,\n expiresAt: data.expires_at,\n }\n return this.session\n }\n}\n\n// Set OPENCODE_COPILOT_AUTO_DEBUG=1 on the server to print Copilot's raw routing responses.\nfunction debug(label: string, value: unknown) {\n if (!process.env.OPENCODE_COPILOT_AUTO_DEBUG) return\n console.error(`[copilot-auto] ${label} ${JSON.stringify(value)}`)\n}\n\nfunction headers(extra: Record<string, string> = {}) {\n return {\n \"Content-Type\": \"application/json\",\n \"X-GitHub-Api-Version\": API_VERSION,\n ...extra,\n }\n}\n"],"mappings":"AAEA,MAAM,cAAc;AACpB,MAAM,yBAAyB;AAC/B,MAAM,aAAa;AAqBZ,MAAM,OAAO;AAAA,EAGlB,YACmB,SACA,MACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAFgB;AAAA,EACA;AAAA,EAJX;AAAA;AAAA,EAQR,MAAM,QAAyB;AAC7B,YAAQ,MAAM,KAAK,WAAW,GAAG;AAAA,EACnC;AAAA;AAAA,EAGA,MAAM,MAAM,QAAiC;AAC3C,UAAM,UAAU,MAAM,KAAK,WAAW;AACtC,UAAM,WAAW,MAAM,KAAK,KAAK,GAAG,KAAK,OAAO,0BAA0B;AAAA,MACxE,QAAQ;AAAA,MACR,SAAS,QAAQ,EAAE,yBAAyB,QAAQ,MAAM,CAAC;AAAA,MAC3D,MAAM,KAAK,UAAU;AAAA,QACnB,QAAQ,OAAO;AAAA,QACf,kBAAkB,QAAQ;AAAA,QAC1B,WAAW,OAAO;AAAA,MACpB,CAAC;AAAA,MACD,QAAQ,YAAY,QAAQ,UAAU;AAAA,IACxC,CAAC;AACD,QAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,0CAA0C,SAAS,MAAM,EAAE;AAC7F,UAAM,SAAU,MAAM,SAAS,KAAK;AACpC,UAAM,UAAU,MAAM;AACtB,WAAO,OAAO,gBAAgB,QAAQ;AAAA,EACxC;AAAA,EAEA,MAAc,aAAsC;AAClD,UAAM,MAAM,KAAK,MAAM,KAAK,IAAI,IAAI,GAAI;AACxC,QAAI,KAAK,WAAW,KAAK,QAAQ,YAAY,MAAM,uBAAwB,QAAO,KAAK;AAEvF,UAAM,WAAW,MAAM,KAAK,KAAK,GAAG,KAAK,OAAO,mBAAmB;AAAA,MACjE,QAAQ;AAAA,MACR,SAAS,QAAQ;AAAA,MACjB,MAAM,KAAK,UAAU,EAAE,WAAW,EAAE,aAAa,CAAC,MAAM,EAAE,EAAE,CAAC;AAAA,MAC7D,QAAQ,YAAY,QAAQ,UAAU;AAAA,IACxC,CAAC;AACD,QAAI,CAAC,SAAS,GAAI,OAAM,IAAI,MAAM,oDAAoD,SAAS,MAAM,EAAE;AACvG,UAAM,OAAQ,MAAM,SAAS,KAAK;AAMlC,UAAM,WAAW,EAAE,GAAG,MAAM,eAAe,aAAa,CAAC;AACzD,SAAK,UAAU;AAAA,MACb,iBAAiB,KAAK;AAAA,MACtB,eAAe,KAAK;AAAA,MACpB,OAAO,KAAK;AAAA,MACZ,WAAW,KAAK;AAAA,IAClB;AACA,WAAO,KAAK;AAAA,EACd;AACF;AAGA,SAAS,MAAM,OAAe,OAAgB;AAC5C,MAAI,CAAC,QAAQ,IAAI,4BAA6B;AAC9C,UAAQ,MAAM,kBAAkB,KAAK,IAAI,KAAK,UAAU,KAAK,CAAC,EAAE;AAClE;AAEA,SAAS,QAAQ,QAAgC,CAAC,GAAG;AACnD,SAAO;AAAA,IACL,gBAAgB;AAAA,IAChB,wBAAwB;AAAA,IACxB,GAAG;AAAA,EACL;AACF;","names":[]}
package/dist/rpc.js ADDED
@@ -0,0 +1,23 @@
1
+ import { Rpc } from "@opencode/plugin/rpc";
2
+ const CopilotAuto = Rpc.define({
3
+ id: "copilot-auto",
4
+ methods: {},
5
+ events: {
6
+ /** Emitted when Copilot picks a model for a prompt. Only sent when `notifications` is enabled. */
7
+ routed: {
8
+ schema: {
9
+ type: "object",
10
+ properties: {
11
+ model: { type: "string" },
12
+ sessionID: { type: "string" }
13
+ },
14
+ required: ["model"],
15
+ additionalProperties: false
16
+ }
17
+ }
18
+ }
19
+ });
20
+ export {
21
+ CopilotAuto
22
+ };
23
+ //# sourceMappingURL=rpc.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rpc.ts"],"sourcesContent":["import { Rpc } from \"@opencode/plugin/rpc\"\n\nexport const CopilotAuto = Rpc.define({\n id: \"copilot-auto\",\n methods: {},\n events: {\n /** Emitted when Copilot picks a model for a prompt. Only sent when `notifications` is enabled. */\n routed: {\n schema: {\n type: \"object\",\n properties: {\n model: { type: \"string\" },\n sessionID: { type: \"string\" },\n },\n required: [\"model\"],\n additionalProperties: false,\n },\n },\n },\n})\n"],"mappings":"AAAA,SAAS,WAAW;AAEb,MAAM,cAAc,IAAI,OAAO;AAAA,EACpC,IAAI;AAAA,EACJ,SAAS,CAAC;AAAA,EACV,QAAQ;AAAA;AAAA,IAEN,QAAQ;AAAA,MACN,QAAQ;AAAA,QACN,MAAM;AAAA,QACN,YAAY;AAAA,UACV,OAAO,EAAE,MAAM,SAAS;AAAA,UACxB,WAAW,EAAE,MAAM,SAAS;AAAA,QAC9B;AAAA,QACA,UAAU,CAAC,OAAO;AAAA,QAClB,sBAAsB;AAAA,MACxB;AAAA,IACF;AAAA,EACF;AACF,CAAC;","names":[]}
package/dist/tui.js ADDED
@@ -0,0 +1,21 @@
1
+ import { Plugin } from "@opencode/plugin/tui";
2
+ import { CopilotAuto } from "./rpc.js";
3
+ var tui_default = Plugin.define({
4
+ id: "opencode-copilot-auto.tui",
5
+ setup(ctx) {
6
+ const api = ctx.client.rpc(CopilotAuto);
7
+ return api.events.on("routed", (event) => {
8
+ const data = event.data;
9
+ ctx.ui.toast.show({
10
+ title: "Copilot Auto",
11
+ message: `Answering with ${data.model}`,
12
+ variant: "info",
13
+ sessionID: data.sessionID
14
+ });
15
+ });
16
+ }
17
+ });
18
+ export {
19
+ tui_default as default
20
+ };
21
+ //# sourceMappingURL=tui.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/tui.ts"],"sourcesContent":["import { Plugin } from \"@opencode/plugin/tui\"\nimport { CopilotAuto } from \"./rpc.js\"\n\nexport default Plugin.define({\n id: \"opencode-copilot-auto.tui\",\n setup(ctx) {\n const api = ctx.client.rpc(CopilotAuto)\n return api.events.on(\"routed\", (event) => {\n const data = event.data as { model: string; sessionID?: string }\n ctx.ui.toast.show({\n title: \"Copilot Auto\",\n message: `Answering with ${data.model}`,\n variant: \"info\",\n sessionID: data.sessionID,\n })\n })\n },\n})\n"],"mappings":"AAAA,SAAS,cAAc;AACvB,SAAS,mBAAmB;AAE5B,IAAO,cAAQ,OAAO,OAAO;AAAA,EAC3B,IAAI;AAAA,EACJ,MAAM,KAAK;AACT,UAAM,MAAM,IAAI,OAAO,IAAI,WAAW;AACtC,WAAO,IAAI,OAAO,GAAG,UAAU,CAAC,UAAU;AACxC,YAAM,OAAO,MAAM;AACnB,UAAI,GAAG,MAAM,KAAK;AAAA,QAChB,OAAO;AAAA,QACP,SAAS,kBAAkB,KAAK,KAAK;AAAA,QACrC,SAAS;AAAA,QACT,WAAW,KAAK;AAAA,MAClB,CAAC;AAAA,IACH,CAAC;AAAA,EACH;AACF,CAAC;","names":[]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opeoginni/opencode-copilot-auto",
3
- "version": "0.1.8",
4
- "description": "Add GitHub Copilot Auto model routing to OpenCode",
3
+ "version": "1.0.0",
4
+ "description": "GitHub Copilot Auto model selection for OpenCode V2",
5
5
  "keywords": [
6
6
  "opencode",
7
7
  "opencode-plugin",
@@ -15,40 +15,41 @@
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/OpeOginni/opencode-copilot-auto.git"
17
17
  },
18
+ "homepage": "https://github.com/OpeOginni/opencode-copilot-auto#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/OpeOginni/opencode-copilot-auto/issues"
21
+ },
18
22
  "type": "module",
23
+ "exports": {
24
+ ".": "./dist/index.js",
25
+ "./rpc": "./dist/rpc.js",
26
+ "./tui": "./dist/tui.js"
27
+ },
19
28
  "files": [
20
29
  "dist",
21
30
  "README.md",
22
31
  "LICENSE"
23
32
  ],
24
- "main": "./dist/index.js",
25
- "types": "./dist/index.d.ts",
26
- "exports": {
27
- ".": {
28
- "types": "./dist/index.d.ts",
29
- "import": "./dist/index.js",
30
- "default": "./dist/index.js"
31
- }
32
- },
33
33
  "publishConfig": {
34
- "access": "public"
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
35
36
  },
36
37
  "scripts": {
37
- "build": "rm -rf dist && bun build src/index.ts --outdir dist --target bun --format esm && tsc -p tsconfig.build.json",
38
+ "build": "tsup",
38
39
  "check": "tsc --noEmit",
39
40
  "test": "bun test",
40
- "prepack": "bun run check && bun run test && bun run build"
41
+ "prepack": "bun run build",
42
+ "prepublishOnly": "bun run check && bun test",
43
+ "publish:dry-run": "bun publish --dry-run",
44
+ "release": "bun publish"
41
45
  },
42
- "peerDependencies": {
43
- "@opencode-ai/plugin": ">=1.17.20"
46
+ "dependencies": {
47
+ "@opencode/plugin": "^2.0.12"
44
48
  },
45
49
  "devDependencies": {
46
- "@opencode-ai/plugin": "^1.17.20",
47
- "@opencode-ai/sdk": "^1.17.20",
50
+ "@ai-sdk/provider": "^3.0.8",
48
51
  "@types/bun": "^1.2.6",
49
- "typescript": "^5.8.2"
50
- },
51
- "engines": {
52
- "bun": ">=1.2.0"
52
+ "tsup": "^8.5.1",
53
+ "typescript": "^5.9.0"
53
54
  }
54
55
  }
package/dist/index.d.ts DELETED
@@ -1,3 +0,0 @@
1
- import type { Plugin } from "@opencode-ai/plugin";
2
- export declare const CopilotAutoPlugin: Plugin;
3
- export default CopilotAutoPlugin;