@melaya/runner 1.1.27 → 1.1.29
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/dist/assistantHost.py +33 -4
- package/dist/cli.js +22 -12
- package/dist/copilotLogin.d.ts +1 -0
- package/dist/copilotLogin.js +90 -0
- package/dist/detect.d.ts +1 -1
- package/dist/detect.js +100 -0
- package/package.json +1 -1
package/dist/assistantHost.py
CHANGED
|
@@ -889,7 +889,32 @@ def _register_stream_hooks(agent) -> None:
|
|
|
889
889
|
_log(f"stream hooks registered (delta={ok_a} tool={ok_b})")
|
|
890
890
|
|
|
891
891
|
|
|
892
|
-
def
|
|
892
|
+
def _build_user_msg(message: str, images=None):
|
|
893
|
+
"""Build the user Msg. With images (base64 blocks from the turn frame) and a
|
|
894
|
+
vision-capable model, this is a multimodal Msg ([text?] + image blocks in the
|
|
895
|
+
Anthropic-style `source.base64` shape agentscope formats per provider). Any
|
|
896
|
+
problem building the blocks falls back to a plain text Msg so a turn NEVER
|
|
897
|
+
breaks on a text-only model / older agentscope."""
|
|
898
|
+
from agentscope.message import Msg
|
|
899
|
+
if not images:
|
|
900
|
+
return Msg("user", message, "user")
|
|
901
|
+
try:
|
|
902
|
+
blocks = []
|
|
903
|
+
if message:
|
|
904
|
+
blocks.append({"type": "text", "text": message})
|
|
905
|
+
for im in images:
|
|
906
|
+
data = str((im or {}).get("data") or "")
|
|
907
|
+
mt = str((im or {}).get("media_type") or "")
|
|
908
|
+
if data and mt:
|
|
909
|
+
blocks.append({"type": "image", "source": {"type": "base64", "media_type": mt, "data": data}})
|
|
910
|
+
if len(blocks) > (1 if message else 0):
|
|
911
|
+
return Msg("user", blocks, "user")
|
|
912
|
+
except Exception:
|
|
913
|
+
pass
|
|
914
|
+
return Msg("user", message, "user")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False, images=None) -> None:
|
|
893
918
|
import asyncio
|
|
894
919
|
from agentscope.message import Msg
|
|
895
920
|
|
|
@@ -916,7 +941,7 @@ def _run_turn(agent, turn_id: str, message: str, browser_turn: bool = False) ->
|
|
|
916
941
|
# unwinds the agent loop wherever it is awaiting — including mid phone-command
|
|
917
942
|
# HTTP wait — so the run stops promptly instead of finishing the step first.
|
|
918
943
|
async def _go():
|
|
919
|
-
task = asyncio.ensure_future(agent(
|
|
944
|
+
task = asyncio.ensure_future(agent(_build_user_msg(message, images)))
|
|
920
945
|
while not task.done():
|
|
921
946
|
if _stream.get("cancel"):
|
|
922
947
|
task.cancel()
|
|
@@ -1108,6 +1133,10 @@ def main() -> int:
|
|
|
1108
1133
|
continue
|
|
1109
1134
|
turn_id = str(req.get("turnId") or "")
|
|
1110
1135
|
message = str(req.get("message") or "")
|
|
1136
|
+
# Vision: base64 image blocks for THIS turn (validated server-side). Used
|
|
1137
|
+
# to build a multimodal Msg when the model supports it; text-only otherwise.
|
|
1138
|
+
_images = req.get("images")
|
|
1139
|
+
images = _images if isinstance(_images, list) and _images else None
|
|
1111
1140
|
# Per-turn autonomy mode: the TS side carries an updated `hitl_mode`
|
|
1112
1141
|
# on runner:assistant_turn so a mid-session flip takes effect next
|
|
1113
1142
|
# turn. Absent ⇒ keep the current (spawn / previous-turn) mode. The
|
|
@@ -1123,7 +1152,7 @@ def main() -> int:
|
|
|
1123
1152
|
# instructions into the system prompt before running the turn (parity with
|
|
1124
1153
|
# the cloud path; handles set / edit / clear mid-conversation).
|
|
1125
1154
|
_apply_static_context(agent, base_sys_prompt, req.get("static_context"))
|
|
1126
|
-
if not message:
|
|
1155
|
+
if not message and not images:
|
|
1127
1156
|
_emit(turn_id, "done")
|
|
1128
1157
|
continue
|
|
1129
1158
|
# P2-6: nudge the live memory budget down if a prior turn's OOM downgraded
|
|
@@ -1135,7 +1164,7 @@ def main() -> int:
|
|
|
1135
1164
|
# ever survives in the warm host environment.
|
|
1136
1165
|
browser_turn = _apply_browser_turn_grant(req)
|
|
1137
1166
|
try:
|
|
1138
|
-
_run_turn(agent, turn_id, message, browser_turn=browser_turn)
|
|
1167
|
+
_run_turn(agent, turn_id, message, browser_turn=browser_turn, images=images)
|
|
1139
1168
|
finally:
|
|
1140
1169
|
_purge_browser_turn_grant()
|
|
1141
1170
|
_emit_usage(agent, turn_id)
|
package/dist/cli.js
CHANGED
|
@@ -17,14 +17,6 @@ import { Command } from "commander";
|
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import { connect } from "./connection.js";
|
|
19
19
|
import { detectModels } from "./detect.js";
|
|
20
|
-
const program = new Command()
|
|
21
|
-
.name("melaya-runner")
|
|
22
|
-
.description("Run Melaya AI pipelines locally with your own models")
|
|
23
|
-
.requiredOption("--token <token>", "Runner token from the Melaya platform")
|
|
24
|
-
.option("--server <url>", "Server URL", "wss://api.melaya.org")
|
|
25
|
-
.option("--verbose", "Show detailed logs", false)
|
|
26
|
-
.parse(process.argv);
|
|
27
|
-
const opts = program.opts();
|
|
28
20
|
// Logo colors: cyan → blue → indigo gradient (matching melaya.org)
|
|
29
21
|
const c1 = chalk.hex("#22D3EE").bold;
|
|
30
22
|
const c2 = chalk.hex("#10B0F0").bold;
|
|
@@ -40,7 +32,7 @@ import { dirname, join } from "path";
|
|
|
40
32
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
33
|
const { version } = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
42
34
|
const BANNER = `
|
|
43
|
-
${c1("███╗ ███╗")} ${c2("███████╗")} ${c3("██╗")}
|
|
35
|
+
${c1("███╗ ███╗")} ${c2("███████╗")} ${c3("██╗")} ${c4("█████╗")} ${c5("██╗ ██╗")} ${c6("█████╗")}
|
|
44
36
|
${c1("████╗ ████║")} ${c2("██╔════╝")} ${c3("██║")} ${c4("██╔══██╗")} ${c5("╚██╗ ██╔╝")} ${c6("██╔══██╗")}
|
|
45
37
|
${c1("██╔████╔██║")} ${c2("█████╗")} ${c3("██║")} ${c4("███████║")} ${c5("╚████╔╝")} ${c6("███████║")}
|
|
46
38
|
${c1("██║╚██╔╝██║")} ${c2("██╔══╝")} ${c3("██║")} ${c4("██╔══██║")} ${c5("╚██╔╝")} ${c6("██╔══██║")}
|
|
@@ -51,6 +43,14 @@ const BANNER = `
|
|
|
51
43
|
${br("─────────────────────────────────────────────────────")}
|
|
52
44
|
`;
|
|
53
45
|
async function main() {
|
|
46
|
+
const program = new Command()
|
|
47
|
+
.name("melaya-runner")
|
|
48
|
+
.description("Run Melaya AI pipelines locally with your own models")
|
|
49
|
+
.requiredOption("--token <token>", "Runner token from the Melaya platform")
|
|
50
|
+
.option("--server <url>", "Server URL", "wss://api.melaya.org")
|
|
51
|
+
.option("--verbose", "Show detailed logs", false)
|
|
52
|
+
.parse(process.argv);
|
|
53
|
+
const opts = program.opts();
|
|
54
54
|
console.log(BANNER);
|
|
55
55
|
// Detect Python
|
|
56
56
|
const python = await findPython();
|
|
@@ -63,8 +63,8 @@ async function main() {
|
|
|
63
63
|
// Detect local models
|
|
64
64
|
const models = await detectModels();
|
|
65
65
|
if (models.length === 0) {
|
|
66
|
-
console.log(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running
|
|
67
|
-
console.log(chalk.gray(" Start LM Studio or Ollama
|
|
66
|
+
console.log(chalk.yellow(" ⚠ No local models detected (LM Studio / Ollama not running; Claude Code / Codex / GitHub Copilot not logged in)"));
|
|
67
|
+
console.log(chalk.gray(" Start LM Studio or Ollama, log in to Claude Code / Codex, or run `melaya-runner copilot login`, then restart the runner"));
|
|
68
68
|
}
|
|
69
69
|
else {
|
|
70
70
|
for (const m of models) {
|
|
@@ -93,7 +93,17 @@ async function findPython() {
|
|
|
93
93
|
}
|
|
94
94
|
return null;
|
|
95
95
|
}
|
|
96
|
-
|
|
96
|
+
// `melaya-runner copilot login` — GitHub Copilot device-flow sign-in, cached
|
|
97
|
+
// locally. Handled before the runner arg-parse so it needs no --token.
|
|
98
|
+
async function bootstrap() {
|
|
99
|
+
if (process.argv[2] === "copilot") {
|
|
100
|
+
const { copilotLogin } = await import("./copilotLogin.js");
|
|
101
|
+
await copilotLogin();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await main();
|
|
105
|
+
}
|
|
106
|
+
bootstrap().catch((e) => {
|
|
97
107
|
console.error(chalk.red(`\n Fatal: ${e.message}\n`));
|
|
98
108
|
process.exit(1);
|
|
99
109
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function copilotLogin(): Promise<void>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Copilot device-flow login for the runner.
|
|
3
|
+
*
|
|
4
|
+
* github_copilot is a runner CLI provider (like claude_code/codex): the runtime
|
|
5
|
+
* reads a GitHub OAuth token from disk on THIS machine. The standalone
|
|
6
|
+
* `@github/copilot` CLI stores its token via MSAL (encrypted, unreadable), so
|
|
7
|
+
* for users who don't already have a readable token (editor plugin apps.json /
|
|
8
|
+
* gh), this command runs GitHub's device flow ONCE and caches the durable
|
|
9
|
+
* `gho_` token at ~/.melaya-runner/github_copilot.json — the same file the
|
|
10
|
+
* runtime (model.py::_read_github_copilot_token) and detect.ts read.
|
|
11
|
+
*
|
|
12
|
+
* Nothing here touches the Melaya cloud: the token is minted against GitHub and
|
|
13
|
+
* stored locally. Run: `npx @melaya/runner copilot login`.
|
|
14
|
+
*/
|
|
15
|
+
import { writeFile, mkdir } from "fs/promises";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import chalk from "chalk";
|
|
19
|
+
const CLIENT_ID = "Iv1.b507a08c87ecfe98"; // the well-known Copilot (copilot.vim) device-flow client id
|
|
20
|
+
const UA = "GithubCopilot/1.155.0";
|
|
21
|
+
export async function copilotLogin() {
|
|
22
|
+
console.log(chalk.cyan("\n GitHub Copilot — device sign-in\n"));
|
|
23
|
+
// 1) Request a device code.
|
|
24
|
+
const startRes = await fetch("https://github.com/login/device/code", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": UA },
|
|
27
|
+
body: new URLSearchParams({ client_id: CLIENT_ID, scope: "read:user" }),
|
|
28
|
+
});
|
|
29
|
+
if (!startRes.ok) {
|
|
30
|
+
console.log(chalk.red(` ✗ Could not start GitHub device flow (HTTP ${startRes.status}).`));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const dev = (await startRes.json());
|
|
34
|
+
const interval = Math.max(1, Number(dev.interval) || 5);
|
|
35
|
+
const deadline = Date.now() + Math.max(60, Number(dev.expires_in) || 900) * 1000;
|
|
36
|
+
console.log(` 1. Open: ${chalk.underline(dev.verification_uri || "https://github.com/login/device")}`);
|
|
37
|
+
console.log(` 2. Enter: ${chalk.bold.yellow(dev.user_code)}`);
|
|
38
|
+
console.log(chalk.gray("\n Waiting for you to authorize…\n"));
|
|
39
|
+
// 2) Poll for the token.
|
|
40
|
+
let token = "";
|
|
41
|
+
while (Date.now() < deadline) {
|
|
42
|
+
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
43
|
+
const pollRes = await fetch("https://github.com/login/oauth/access_token", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", "User-Agent": UA },
|
|
46
|
+
body: new URLSearchParams({
|
|
47
|
+
client_id: CLIENT_ID,
|
|
48
|
+
device_code: dev.device_code,
|
|
49
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
const data = (await pollRes.json().catch(() => ({})));
|
|
53
|
+
if (data.access_token) {
|
|
54
|
+
token = data.access_token;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
if (data.error && data.error !== "authorization_pending" && data.error !== "slow_down") {
|
|
58
|
+
console.log(chalk.red(` ✗ ${data.error === "expired_token" ? "The code expired — run the command again." : data.error === "access_denied" ? "Sign-in was denied." : `Sign-in failed (${data.error}).`}`));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!token) {
|
|
63
|
+
console.log(chalk.red(" ✗ Timed out waiting for authorization. Run the command again."));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
// 3) Confirm the token actually has a Copilot seat (exchange), then cache it.
|
|
67
|
+
let login = "";
|
|
68
|
+
try {
|
|
69
|
+
const ex = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
70
|
+
headers: { Authorization: `token ${token}`, "User-Agent": UA, "Editor-Version": "vscode/1.95.0", "Editor-Plugin-Version": "copilot-chat/0.22.0" },
|
|
71
|
+
});
|
|
72
|
+
if (!ex.ok) {
|
|
73
|
+
console.log(chalk.red(` ✗ Signed in, but this GitHub account has no active Copilot subscription (HTTP ${ex.status}).`));
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch { /* transient — still cache; the runtime re-exchanges */ }
|
|
78
|
+
try {
|
|
79
|
+
const who = await fetch("https://api.github.com/user", { headers: { Authorization: `token ${token}`, "User-Agent": UA, Accept: "application/vnd.github+json" } });
|
|
80
|
+
if (who.ok)
|
|
81
|
+
login = String((await who.json()).login || "");
|
|
82
|
+
}
|
|
83
|
+
catch { /* optional */ }
|
|
84
|
+
const dir = join(homedir(), ".melaya-runner");
|
|
85
|
+
await mkdir(dir, { recursive: true });
|
|
86
|
+
const file = join(dir, "github_copilot.json");
|
|
87
|
+
await writeFile(file, JSON.stringify({ oauth_token: token, user: login, host: "github.com" }, null, 2), { mode: 0o600 });
|
|
88
|
+
console.log(chalk.green(`\n ✓ GitHub Copilot connected${login ? ` as ${login}` : ""}.`));
|
|
89
|
+
console.log(chalk.gray(` Token cached at ${file} (this machine only). Restart the runner to pick it up.\n`));
|
|
90
|
+
}
|
package/dist/detect.d.ts
CHANGED
package/dist/detect.js
CHANGED
|
@@ -8,6 +8,9 @@ import { readFile } from "fs/promises";
|
|
|
8
8
|
import { homedir } from "os";
|
|
9
9
|
import { join } from "path";
|
|
10
10
|
const execFileAsync = promisify(execFile);
|
|
11
|
+
// Fallback ONLY when the live /models fetch fails. Must be ENABLED-by-default
|
|
12
|
+
// Copilot models (policy!=disabled) — Claude/Gemini default to disabled and 400.
|
|
13
|
+
const COPILOT_MODELS_FALLBACK = ["gpt-4.1", "gpt-4o", "gpt-5-mini"];
|
|
11
14
|
/** Fallbacks used ONLY when live discovery fails (offline runner, transient
|
|
12
15
|
* error, missing cache). The REAL lists are fetched dynamically below so a new
|
|
13
16
|
* claude/codex release is available on Melaya automatically — no code change,
|
|
@@ -136,6 +139,101 @@ async function detectCodex() {
|
|
|
136
139
|
catch { /* malformed creds → treat as logged out */ }
|
|
137
140
|
return []; // not logged in → don't advertise models
|
|
138
141
|
}
|
|
142
|
+
/** Read the user's GitHub OAuth token for Copilot from a readable source on
|
|
143
|
+
* THIS machine, in priority order. The standalone `@github/copilot` CLI stores
|
|
144
|
+
* its token via MSAL (encrypted, unreadable), so we read the editor-plugin
|
|
145
|
+
* token, the gh CLI token, or a token cached by the runner's own device-flow
|
|
146
|
+
* login (`melaya-runner copilot login` → ~/.melaya-runner/github_copilot.json).
|
|
147
|
+
* Mirrors model.py::_read_github_copilot_token — keep the source list in sync. */
|
|
148
|
+
async function readGithubCopilotToken() {
|
|
149
|
+
// 1) Editor plugins (copilot.vim / JetBrains / Neovim): plaintext oauth_token.
|
|
150
|
+
for (const rel of [
|
|
151
|
+
join(homedir(), ".config", "github-copilot", "apps.json"),
|
|
152
|
+
join(homedir(), ".config", "github-copilot", "hosts.json"),
|
|
153
|
+
]) {
|
|
154
|
+
try {
|
|
155
|
+
const j = JSON.parse(await readFile(rel, "utf8"));
|
|
156
|
+
for (const v of Object.values(j)) {
|
|
157
|
+
if (v?.oauth_token)
|
|
158
|
+
return v.oauth_token;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch { /* absent → next source */ }
|
|
162
|
+
}
|
|
163
|
+
// 2) Runner-cached token from our own device-flow login.
|
|
164
|
+
try {
|
|
165
|
+
const j = JSON.parse(await readFile(join(homedir(), ".melaya-runner", "github_copilot.json"), "utf8"));
|
|
166
|
+
if (j?.oauth_token)
|
|
167
|
+
return j.oauth_token;
|
|
168
|
+
}
|
|
169
|
+
catch { /* not logged in via runner */ }
|
|
170
|
+
// 3) gh CLI env token (broad-scope PAT/OAuth; works for the exchange).
|
|
171
|
+
if (process.env.GH_TOKEN)
|
|
172
|
+
return process.env.GH_TOKEN;
|
|
173
|
+
if (process.env.GITHUB_TOKEN)
|
|
174
|
+
return process.env.GITHUB_TOKEN;
|
|
175
|
+
return "";
|
|
176
|
+
}
|
|
177
|
+
/** GitHub Copilot as a runner CLI provider: when a readable GitHub OAuth token
|
|
178
|
+
* is present on THIS machine, exchange it for a short-lived Copilot token and
|
|
179
|
+
* list the models the subscription serves, so the picker shows TRUE local
|
|
180
|
+
* availability (copilot pipelines execute HERE, same as claude_code/codex). */
|
|
181
|
+
async function detectGithubCopilot() {
|
|
182
|
+
const gh = await readGithubCopilotToken();
|
|
183
|
+
if (!gh)
|
|
184
|
+
return [];
|
|
185
|
+
try {
|
|
186
|
+
const ex = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
187
|
+
headers: {
|
|
188
|
+
Authorization: `token ${gh}`,
|
|
189
|
+
"User-Agent": "GithubCopilot/1.155.0",
|
|
190
|
+
"Editor-Version": "vscode/1.95.0",
|
|
191
|
+
"Editor-Plugin-Version": "copilot-chat/0.22.0",
|
|
192
|
+
},
|
|
193
|
+
signal: AbortSignal.timeout(6000),
|
|
194
|
+
});
|
|
195
|
+
if (!ex.ok)
|
|
196
|
+
return []; // no Copilot seat / revoked → don't advertise
|
|
197
|
+
const exd = (await ex.json());
|
|
198
|
+
if (!exd.token)
|
|
199
|
+
return [];
|
|
200
|
+
const base = (exd.endpoints?.api || "https://api.githubcopilot.com").replace(/\/+$/, "");
|
|
201
|
+
try {
|
|
202
|
+
const r = await fetch(`${base}/models`, {
|
|
203
|
+
headers: {
|
|
204
|
+
Authorization: `Bearer ${exd.token}`,
|
|
205
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
206
|
+
"Editor-Version": "vscode/1.95.0",
|
|
207
|
+
"Editor-Plugin-Version": "copilot-chat/0.22.0",
|
|
208
|
+
},
|
|
209
|
+
signal: AbortSignal.timeout(6000),
|
|
210
|
+
});
|
|
211
|
+
if (r.ok) {
|
|
212
|
+
const d = (await r.json());
|
|
213
|
+
// Keep ONLY real chat models the agent runtime can DRIVE + CALL:
|
|
214
|
+
// - tool calling required (agent loop),
|
|
215
|
+
// - `policy.state !== "disabled"` — Copilot's /models over-reports:
|
|
216
|
+
// Gemini/Claude/kimi/gpt-5.4-5.6 default to policy=disabled and a
|
|
217
|
+
// chat/completions call to one 400s `model_not_supported` until the
|
|
218
|
+
// user enables it at github.com/settings/copilot/features. `enabled`
|
|
219
|
+
// and absent-policy (base gpt-4o/4.1) are callable. Enabling a model
|
|
220
|
+
// there flips it to `enabled` and it auto-appears on the next detect.
|
|
221
|
+
// - drop Copilot-internal routing/search/compaction/embedding ids.
|
|
222
|
+
const ids = (d.data ?? [])
|
|
223
|
+
.filter((m) => m?.capabilities?.type === "chat" && m?.capabilities?.supports?.tool_calls === true)
|
|
224
|
+
.filter((m) => (m?.policy?.state ?? "enabled") !== "disabled")
|
|
225
|
+
.map((m) => m?.id ?? "")
|
|
226
|
+
.filter((s) => s && !/^copilot-search|^exec-agent|^trajectory-compaction|-free-auto$|-flash-(picker|secondary|tertiary)$|embedding/i.test(s));
|
|
227
|
+
if (ids.length)
|
|
228
|
+
return ids.map((name) => ({ provider: "github_copilot", name }));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch { /* models list failed → fallback below */ }
|
|
232
|
+
return COPILOT_MODELS_FALLBACK.map((name) => ({ provider: "github_copilot", name }));
|
|
233
|
+
}
|
|
234
|
+
catch { /* offline / transient → not advertised */ }
|
|
235
|
+
return [];
|
|
236
|
+
}
|
|
139
237
|
export async function detectModels() {
|
|
140
238
|
const models = [];
|
|
141
239
|
// LM Studio — OpenAI-compatible at :1234
|
|
@@ -164,5 +262,7 @@ export async function detectModels() {
|
|
|
164
262
|
models.push(...await detectClaudeCode());
|
|
165
263
|
// OpenAI Codex CLI — subscription auth on THIS machine
|
|
166
264
|
models.push(...await detectCodex());
|
|
265
|
+
// GitHub Copilot — GitHub OAuth token readable on THIS machine
|
|
266
|
+
models.push(...await detectGithubCopilot());
|
|
167
267
|
return models;
|
|
168
268
|
}
|