copillm 0.1.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 +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- package/package.json +50 -0
package/dist/pi/init.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CopilotTokenManager } from "../auth/copilotToken.js";
|
|
5
|
+
import { loadStoredCredential } from "../auth/credentials.js";
|
|
6
|
+
import { loadConfig } from "../config/config.js";
|
|
7
|
+
import { listModelsUnion } from "../models/discovery.js";
|
|
8
|
+
import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
9
|
+
export async function generatePiHome(options) {
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
const creds = await loadStoredCredential();
|
|
12
|
+
if (!creds) {
|
|
13
|
+
throw new Error("Not authenticated. Run `copillm login` first.");
|
|
14
|
+
}
|
|
15
|
+
const tokenManager = new CopilotTokenManager(creds.token);
|
|
16
|
+
await tokenManager.ensureToken(false);
|
|
17
|
+
const discovery = await listModelsUnion(config.accountType, creds.token, 3);
|
|
18
|
+
const eligible = discovery.models.filter(isPickerEligible);
|
|
19
|
+
// Split the catalog by which upstream endpoint each model supports. Models
|
|
20
|
+
// that advertise `/chat/completions` flow through copillm's Anthropic surface
|
|
21
|
+
// (the proxy translates Anthropic-messages → OpenAI chat completions
|
|
22
|
+
// upstream). Models that only advertise `/responses` (newer GPT-5.x
|
|
23
|
+
// responses-only variants, codex-class models) flow through copillm's
|
|
24
|
+
// `/codex/v1/responses` route and surface in pi as a second provider using
|
|
25
|
+
// pi's `openai-responses` api. A model that supports both endpoints is
|
|
26
|
+
// exposed via the Anthropic-messages path only, so pi's picker doesn't
|
|
27
|
+
// double-list it.
|
|
28
|
+
const anthropicEligible = uniqueByModelId(eligible.filter(supportsChatCompletions));
|
|
29
|
+
const responsesEligible = uniqueByModelId(eligible.filter((m) => !supportsChatCompletions(m) && supportsResponses(m)));
|
|
30
|
+
if (anthropicEligible.length === 0 && responsesEligible.length === 0) {
|
|
31
|
+
throw new Error("No models discovered for pi config.");
|
|
32
|
+
}
|
|
33
|
+
const proxyUrl = `http://127.0.0.1:${options.port}/anthropic`;
|
|
34
|
+
// OpenAI SDK posts to `<baseUrl>/responses`, so the baseUrl must include `/v1`.
|
|
35
|
+
const responsesProxyUrl = `http://127.0.0.1:${options.port}/codex/v1`;
|
|
36
|
+
const providerId = options.providerId.trim().length > 0 ? options.providerId : "copillm";
|
|
37
|
+
const responsesProviderId = `${providerId}-responses`;
|
|
38
|
+
const providers = {};
|
|
39
|
+
if (anthropicEligible.length > 0) {
|
|
40
|
+
providers[providerId] = {
|
|
41
|
+
baseUrl: proxyUrl,
|
|
42
|
+
api: "anthropic-messages",
|
|
43
|
+
apiKey: "copillm-local",
|
|
44
|
+
models: anthropicEligible.map(toPiModelEntry)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (responsesEligible.length > 0) {
|
|
48
|
+
providers[responsesProviderId] = {
|
|
49
|
+
baseUrl: responsesProxyUrl,
|
|
50
|
+
api: "openai-responses",
|
|
51
|
+
apiKey: "copillm-local",
|
|
52
|
+
models: responsesEligible.map(toPiModelEntry)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const payload = { providers };
|
|
56
|
+
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
|
57
|
+
// 1. Write copillm-owned mirror under ~/.copillm/pi/models.json
|
|
58
|
+
const absOutDir = path.resolve(options.outDir);
|
|
59
|
+
ensureSecureDirectory(absOutDir);
|
|
60
|
+
const mirrorPath = path.join(absOutDir, "models.json");
|
|
61
|
+
writeFileSecureAtomic(mirrorPath, json, 0o600);
|
|
62
|
+
// 2. Write the real config pi reads at launch, backing up any pre-existing file.
|
|
63
|
+
const configPath = piModelsJsonPath();
|
|
64
|
+
const backupPath = backupIfMismatch(configPath, json);
|
|
65
|
+
ensureSecureDirectory(path.dirname(configPath));
|
|
66
|
+
writeFileSecureAtomic(configPath, json, 0o600);
|
|
67
|
+
return {
|
|
68
|
+
outDir: absOutDir,
|
|
69
|
+
mirrorPath,
|
|
70
|
+
configPath,
|
|
71
|
+
backupPath,
|
|
72
|
+
modelCount: anthropicEligible.length + responsesEligible.length,
|
|
73
|
+
anthropicModelCount: anthropicEligible.length,
|
|
74
|
+
responsesModelCount: responsesEligible.length,
|
|
75
|
+
proxyUrl,
|
|
76
|
+
responsesProxyUrl
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
export function defaultOutputDir(home) {
|
|
80
|
+
return path.join(home, "pi");
|
|
81
|
+
}
|
|
82
|
+
/** Absolute path to `~/.pi/agent/models.json`. Honors $HOME for tests. */
|
|
83
|
+
export function piModelsJsonPath() {
|
|
84
|
+
return path.join(os.homedir(), ".pi", "agent", "models.json");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Eligibility filter shared by both pi providers. Mirrors the gating in
|
|
88
|
+
* `/anthropic/v1/models` and `/codex/v1/models`: a model must be marked
|
|
89
|
+
* picker-enabled by the upstream catalog and its policy must not be disabled.
|
|
90
|
+
* We deliberately do NOT filter by vendor name (so OpenAI- and Gemini-family
|
|
91
|
+
* models surface in pi alongside Anthropic ones); per-endpoint filtering is
|
|
92
|
+
* done by the caller via `supportsChatCompletions` / `supportsResponses`.
|
|
93
|
+
*/
|
|
94
|
+
function isPickerEligible(model) {
|
|
95
|
+
if (typeof model.id !== "string" || model.id.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
if (getNested(model, "model_picker_enabled") !== true)
|
|
98
|
+
return false;
|
|
99
|
+
const policyState = getNested(model, "policy", "state");
|
|
100
|
+
if (typeof policyState === "string" && policyState !== "enabled")
|
|
101
|
+
return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
function supportsChatCompletions(model) {
|
|
105
|
+
return getEndpointList(model).includes("/chat/completions");
|
|
106
|
+
}
|
|
107
|
+
function supportsResponses(model) {
|
|
108
|
+
return getEndpointList(model).includes("/responses");
|
|
109
|
+
}
|
|
110
|
+
function getEndpointList(model) {
|
|
111
|
+
const raw = model.supported_endpoints;
|
|
112
|
+
return Array.isArray(raw) ? raw.filter((v) => typeof v === "string") : [];
|
|
113
|
+
}
|
|
114
|
+
function toPiModelEntry(model) {
|
|
115
|
+
const entry = { id: model.id };
|
|
116
|
+
const contextWindow = getNested(model, "capabilities", "limits", "max_context_window_tokens");
|
|
117
|
+
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
|
|
118
|
+
entry.contextWindow = contextWindow;
|
|
119
|
+
}
|
|
120
|
+
const maxOutput = getNested(model, "capabilities", "limits", "max_output_tokens");
|
|
121
|
+
if (typeof maxOutput === "number" && Number.isFinite(maxOutput) && maxOutput > 0) {
|
|
122
|
+
entry.maxTokens = maxOutput;
|
|
123
|
+
}
|
|
124
|
+
return entry;
|
|
125
|
+
}
|
|
126
|
+
function uniqueByModelId(models) {
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const m of models) {
|
|
130
|
+
const id = typeof m.id === "string" ? m.id : "";
|
|
131
|
+
if (id.length === 0 || seen.has(id))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(id);
|
|
134
|
+
out.push(m);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
function getNested(source, ...path) {
|
|
139
|
+
let cur = source;
|
|
140
|
+
for (const key of path) {
|
|
141
|
+
if (cur === null || typeof cur !== "object")
|
|
142
|
+
return undefined;
|
|
143
|
+
cur = cur[key];
|
|
144
|
+
}
|
|
145
|
+
return cur;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* If `target` exists and its contents differ from the new payload, copy it
|
|
149
|
+
* aside to a timestamped `.bak`. Returns the backup path, or null when no
|
|
150
|
+
* backup was needed. Best-effort: failures don't block the write — we'd
|
|
151
|
+
* rather configure pi correctly than abort because of a backup error.
|
|
152
|
+
*/
|
|
153
|
+
function backupIfMismatch(target, newContent) {
|
|
154
|
+
let existing;
|
|
155
|
+
try {
|
|
156
|
+
existing = fs.readFileSync(target, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (error.code === "ENOENT")
|
|
160
|
+
return null;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (existing === newContent)
|
|
164
|
+
return null;
|
|
165
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
166
|
+
const backupPath = `${target}.copillm-backup-${stamp}.bak`;
|
|
167
|
+
try {
|
|
168
|
+
fs.copyFileSync(target, backupPath);
|
|
169
|
+
return backupPath;
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's only client-side marker for a 1M-context-budget model is a
|
|
3
|
+
* literal `[1m]` suffix on the model id (matched in its binary via
|
|
4
|
+
* `id.includes("opus") && id.includes("[1m]")` for opus and
|
|
5
|
+
* `id.includes("sonnet[1m]") || id.includes("sonnet-4-6[1m]")` for sonnet).
|
|
6
|
+
* Without it, Claude Code falls back to its hardcoded 200K per-model cap for
|
|
7
|
+
* unrecognised model ids and auto-compacts conversations well before they
|
|
8
|
+
* approach the model's real conversation budget.
|
|
9
|
+
*
|
|
10
|
+
* Aliasing the advertised id with `[1m]` is the only way to unlock Claude
|
|
11
|
+
* Code's 1M-class autocompact behaviour via the gateway discovery path —
|
|
12
|
+
* the `/anthropic/v1/models` response shape itself has no field for a numeric
|
|
13
|
+
* context window (Claude Code only reads `id` + `display_name`).
|
|
14
|
+
*
|
|
15
|
+
* Request bodies that carry an aliased id are normalised back to the
|
|
16
|
+
* canonical upstream id before being forwarded (see
|
|
17
|
+
* `stripOneMillionAlias` in `src/translation/openaiAnthropic.ts` and the
|
|
18
|
+
* defensive strip in `src/server/proxy.ts`).
|
|
19
|
+
*/
|
|
20
|
+
export const ONE_M_ALIAS_SUFFIX = "[1m]";
|
|
21
|
+
const ONE_M_CONTEXT_THRESHOLD_TOKENS = 1_000_000;
|
|
22
|
+
export function buildAnthropicModelsResponse(models) {
|
|
23
|
+
const filtered = models.filter(isAnthropicSurfaceEligible);
|
|
24
|
+
const nowIso = new Date().toISOString();
|
|
25
|
+
const data = filtered.map((model) => ({
|
|
26
|
+
type: "model",
|
|
27
|
+
id: applyOneMillionAlias(model),
|
|
28
|
+
display_name: extractDisplayName(model),
|
|
29
|
+
created_at: extractCreatedAt(model) ?? nowIso
|
|
30
|
+
}));
|
|
31
|
+
return {
|
|
32
|
+
data,
|
|
33
|
+
has_more: false,
|
|
34
|
+
first_id: data.length > 0 ? data[0].id : null,
|
|
35
|
+
last_id: data.length > 0 ? data[data.length - 1].id : null
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Append `[1m]` to a model id when (and only when) the upstream catalog
|
|
40
|
+
* reports `capabilities.limits.max_context_window_tokens >= 1_000_000` AND
|
|
41
|
+
* the id contains `opus`.
|
|
42
|
+
*
|
|
43
|
+
* Claude Code's 1M-tier matchers (extracted from its binary) only fire for
|
|
44
|
+
* opus models:
|
|
45
|
+
*
|
|
46
|
+
* dN3(id) = !... && !... && id.toLowerCase().includes("opus") && id.toLowerCase().includes("[1m]")
|
|
47
|
+
*
|
|
48
|
+
* The sonnet matcher (`cN3`) requires the literal substring `sonnet[1m]` or
|
|
49
|
+
* `sonnet-4-6[1m]`, which doesn't fit copillm-aliased ids (an aliased
|
|
50
|
+
* sonnet would end in `[1m]` but its base would not contain a contiguous
|
|
51
|
+
* `sonnet[1m]` substring), so a `[1m]` suffix on a sonnet wouldn't unlock
|
|
52
|
+
* the 1M cap anyway. And no non-Claude vendor (gpt, gemini, ...) has any
|
|
53
|
+
* `[1m]` matcher at all.
|
|
54
|
+
*
|
|
55
|
+
* Restricting the alias to opus avoids showing a misleading `[1m]` next to
|
|
56
|
+
* a non-opus model (e.g. a gpt or gemini variant) in Claude Code's `/model`
|
|
57
|
+
* picker — a label that would imply 1M-class behaviour Claude Code would
|
|
58
|
+
* never deliver for that vendor.
|
|
59
|
+
*
|
|
60
|
+
* Models already carrying the suffix are left alone. Models below the 1M
|
|
61
|
+
* threshold get no alias regardless of name — Claude Code has no
|
|
62
|
+
* client-side marker for the 200K-1M intermediate range, and over-claiming
|
|
63
|
+
* would set the wrong autocompact trigger.
|
|
64
|
+
*/
|
|
65
|
+
export function applyOneMillionAlias(model) {
|
|
66
|
+
const baseId = typeof model.id === "string" ? model.id : "";
|
|
67
|
+
if (baseId.endsWith(ONE_M_ALIAS_SUFFIX)) {
|
|
68
|
+
return baseId;
|
|
69
|
+
}
|
|
70
|
+
if (!baseId.toLowerCase().includes("opus")) {
|
|
71
|
+
return baseId;
|
|
72
|
+
}
|
|
73
|
+
const maxContext = getNested(model, "capabilities", "limits", "max_context_window_tokens");
|
|
74
|
+
if (typeof maxContext !== "number" || !Number.isFinite(maxContext)) {
|
|
75
|
+
return baseId;
|
|
76
|
+
}
|
|
77
|
+
if (maxContext < ONE_M_CONTEXT_THRESHOLD_TOKENS) {
|
|
78
|
+
return baseId;
|
|
79
|
+
}
|
|
80
|
+
return `${baseId}${ONE_M_ALIAS_SUFFIX}`;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Decide which upstream Copilot models should appear in `/anthropic/v1/models`.
|
|
84
|
+
*
|
|
85
|
+
* The proxy's Anthropic surface (`/anthropic/v1/messages`) already translates
|
|
86
|
+
* Anthropic-shape requests into OpenAI `/chat/completions` calls upstream and
|
|
87
|
+
* translates responses back — see src/server/proxy.ts line ~234, which sends
|
|
88
|
+
* EVERY Anthropic-surface request to `/chat/completions` regardless of the
|
|
89
|
+
* model's vendor. The historical filter was a regex on model id matching
|
|
90
|
+
* `^(claude|anthropic)` which silently dropped Gemini, gpt-4.1, and the
|
|
91
|
+
* gpt-5.x family from the Claude Code model picker even though the translation
|
|
92
|
+
* pipeline already handled them end-to-end.
|
|
93
|
+
*
|
|
94
|
+
* Gate eligibility on *capability* instead of vendor naming:
|
|
95
|
+
* 1. `model_picker_enabled === true` — upstream marks the model as user-pickable.
|
|
96
|
+
* 2. `policy.state` is "enabled" or absent — upstream policy doesn't disable it.
|
|
97
|
+
* 3. `supported_endpoints` includes `/chat/completions` — the upstream actually
|
|
98
|
+
* speaks the protocol the proxy translates against.
|
|
99
|
+
*
|
|
100
|
+
* Models that fail any one of these don't appear in the picker. Whether a model
|
|
101
|
+
* that DOES pass actually returns 2xx for a translated Anthropic request is a
|
|
102
|
+
* separate concern (some gpt-5.x reasoning models reject the default
|
|
103
|
+
* chat-completions body shape and 400 at runtime); that's surfaced as an
|
|
104
|
+
* upstream error per request, not hidden at the catalog level.
|
|
105
|
+
*/
|
|
106
|
+
export function isAnthropicSurfaceEligible(model) {
|
|
107
|
+
if (typeof model.id !== "string" || model.id.length === 0) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const pickerEnabled = getNested(model, "model_picker_enabled") === true;
|
|
111
|
+
if (!pickerEnabled) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const policyState = getNested(model, "policy", "state");
|
|
115
|
+
if (policyState !== undefined && policyState !== "enabled") {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
const supportedEndpoints = getNested(model, "supported_endpoints");
|
|
119
|
+
if (!Array.isArray(supportedEndpoints) || !supportedEndpoints.includes("/chat/completions")) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function getNested(obj, ...keys) {
|
|
125
|
+
let current = obj;
|
|
126
|
+
for (const key of keys) {
|
|
127
|
+
if (!current || typeof current !== "object") {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
current = current[key];
|
|
131
|
+
}
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
function extractDisplayName(model) {
|
|
135
|
+
const candidate = model.name;
|
|
136
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
137
|
+
return candidate;
|
|
138
|
+
}
|
|
139
|
+
const displayCandidate = model.display_name;
|
|
140
|
+
if (typeof displayCandidate === "string" && displayCandidate.length > 0) {
|
|
141
|
+
return displayCandidate;
|
|
142
|
+
}
|
|
143
|
+
return model.id;
|
|
144
|
+
}
|
|
145
|
+
function extractCreatedAt(model) {
|
|
146
|
+
const candidate = model.created_at;
|
|
147
|
+
if (typeof candidate === "string" && candidate.length > 0) {
|
|
148
|
+
return candidate;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const VALID_REASONING_EFFORTS = new Set([
|
|
2
|
+
"none",
|
|
3
|
+
"minimal",
|
|
4
|
+
"low",
|
|
5
|
+
"medium",
|
|
6
|
+
"high",
|
|
7
|
+
"xhigh"
|
|
8
|
+
]);
|
|
9
|
+
function toReasoningEffort(value) {
|
|
10
|
+
if (typeof value === "string" && VALID_REASONING_EFFORTS.has(value)) {
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
return "medium";
|
|
14
|
+
}
|
|
15
|
+
function getNested(obj, ...keys) {
|
|
16
|
+
let current = obj;
|
|
17
|
+
for (const key of keys) {
|
|
18
|
+
if (!current || typeof current !== "object") {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
current = current[key];
|
|
22
|
+
}
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
export function isCodexEligible(model) {
|
|
26
|
+
const pickerEnabled = getNested(model, "model_picker_enabled") === true;
|
|
27
|
+
if (!pickerEnabled) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const policyState = getNested(model, "policy", "state");
|
|
31
|
+
if (policyState !== undefined && policyState !== "enabled") {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
const supportedEndpoints = getNested(model, "supported_endpoints");
|
|
35
|
+
if (!Array.isArray(supportedEndpoints) || !supportedEndpoints.includes("/responses")) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
export function mapCopilotModelToCodex(model) {
|
|
41
|
+
const supportsReasoning = getNested(model, "capabilities", "supports", "reasoning_effort");
|
|
42
|
+
const reasoningArray = Array.isArray(supportsReasoning) ? supportsReasoning : ["medium"];
|
|
43
|
+
const supportedLevels = reasoningArray.map((effort) => ({
|
|
44
|
+
effort: toReasoningEffort(effort),
|
|
45
|
+
description: String(effort)
|
|
46
|
+
}));
|
|
47
|
+
const contextWindow = getNested(model, "capabilities", "limits", "max_context_window_tokens") ?? null;
|
|
48
|
+
const parallelTools = getNested(model, "capabilities", "supports", "parallel_tool_calls") === true;
|
|
49
|
+
const pickerEnabled = getNested(model, "model_picker_enabled") === true;
|
|
50
|
+
const vendor = getNested(model, "vendor") ?? "Unknown";
|
|
51
|
+
const displayName = getNested(model, "name") ?? model.id;
|
|
52
|
+
// Derive vision support from the upstream Copilot capability flag rather
|
|
53
|
+
// than advertising image input for every model. Models without
|
|
54
|
+
// `capabilities.supports.vision === true` (e.g. gpt-3.5-turbo, gpt-4-0613,
|
|
55
|
+
// gpt-4o-mini) would 400 on image content if we claimed otherwise.
|
|
56
|
+
// `capabilities.limits.vision` (when present) carries per-model image
|
|
57
|
+
// budgets — image count, byte size, allowed media types. We don't surface
|
|
58
|
+
// those limits in Codex's schema (it has no field for them today), but the
|
|
59
|
+
// boolean gate alone is enough to prevent advertising image_modality on
|
|
60
|
+
// text-only models.
|
|
61
|
+
const supportsVision = getNested(model, "capabilities", "supports", "vision") === true;
|
|
62
|
+
return {
|
|
63
|
+
slug: model.id,
|
|
64
|
+
display_name: displayName,
|
|
65
|
+
description: `${vendor} model: ${model.id}`,
|
|
66
|
+
default_reasoning_level: toReasoningEffort(reasoningArray[0] ?? "medium"),
|
|
67
|
+
supported_reasoning_levels: supportedLevels,
|
|
68
|
+
shell_type: "default",
|
|
69
|
+
visibility: pickerEnabled ? "list" : "hide",
|
|
70
|
+
supported_in_api: true,
|
|
71
|
+
priority: 0,
|
|
72
|
+
additional_speed_tiers: [],
|
|
73
|
+
service_tiers: [],
|
|
74
|
+
availability_nux: null,
|
|
75
|
+
upgrade: null,
|
|
76
|
+
base_instructions: "",
|
|
77
|
+
model_messages: null,
|
|
78
|
+
supports_reasoning_summaries: false,
|
|
79
|
+
default_reasoning_summary: "none",
|
|
80
|
+
support_verbosity: false,
|
|
81
|
+
default_verbosity: null,
|
|
82
|
+
apply_patch_tool_type: null,
|
|
83
|
+
web_search_tool_type: "text",
|
|
84
|
+
truncation_policy: { mode: "bytes", limit: 10_000 },
|
|
85
|
+
supports_parallel_tool_calls: parallelTools,
|
|
86
|
+
supports_image_detail_original: supportsVision,
|
|
87
|
+
context_window: contextWindow,
|
|
88
|
+
max_context_window: contextWindow,
|
|
89
|
+
auto_compact_token_limit: null,
|
|
90
|
+
effective_context_window_percent: 95,
|
|
91
|
+
experimental_supported_tools: [],
|
|
92
|
+
input_modalities: supportsVision ? ["text", "image"] : ["text"],
|
|
93
|
+
supports_search_tool: false
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function buildCodexCatalog(models) {
|
|
97
|
+
return {
|
|
98
|
+
models: models.filter(isCodexEligible).map(mapCopilotModelToCodex)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { githubUserUrl } from "../config/upstream.js";
|
|
2
|
+
const CACHE_TTL_MS = 5 * 60 * 1_000;
|
|
3
|
+
let cached = null;
|
|
4
|
+
export async function getGithubUserSummary(githubToken, options = {}) {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
7
|
+
return cached.summary;
|
|
8
|
+
}
|
|
9
|
+
const response = await fetch(githubUserUrl(), {
|
|
10
|
+
headers: {
|
|
11
|
+
Authorization: `token ${githubToken}`,
|
|
12
|
+
Accept: "application/vnd.github+json",
|
|
13
|
+
"User-Agent": "copillm/0.1.0",
|
|
14
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
15
|
+
},
|
|
16
|
+
signal: typeof options.timeoutMs === "number" ? AbortSignal.timeout(options.timeoutMs) : undefined
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
const detail = await response.text();
|
|
20
|
+
throw new GithubUserFetchError(response.status, detail.slice(0, 256));
|
|
21
|
+
}
|
|
22
|
+
const payload = (await response.json());
|
|
23
|
+
const summary = {
|
|
24
|
+
login: typeof payload.login === "string" ? payload.login : "",
|
|
25
|
+
id: typeof payload.id === "number" ? payload.id : 0,
|
|
26
|
+
name: typeof payload.name === "string" ? payload.name : null,
|
|
27
|
+
email: typeof payload.email === "string" ? payload.email : null,
|
|
28
|
+
type: typeof payload.type === "string" ? payload.type : "User",
|
|
29
|
+
avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
|
|
30
|
+
html_url: typeof payload.html_url === "string" ? payload.html_url : null,
|
|
31
|
+
plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
|
|
32
|
+
};
|
|
33
|
+
cached = { fetchedAt: now, summary };
|
|
34
|
+
return summary;
|
|
35
|
+
}
|
|
36
|
+
export function clearGithubUserCache() {
|
|
37
|
+
cached = null;
|
|
38
|
+
}
|
|
39
|
+
export class GithubUserFetchError extends Error {
|
|
40
|
+
status;
|
|
41
|
+
bodySnippet;
|
|
42
|
+
constructor(status, bodySnippet) {
|
|
43
|
+
super(`GitHub user lookup failed (${status}).`);
|
|
44
|
+
this.status = status;
|
|
45
|
+
this.bodySnippet = bodySnippet;
|
|
46
|
+
this.name = "GithubUserFetchError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { getCopillmHome, lockPath, lockReadPath } from "../config/home.js";
|
|
4
|
+
import { ensureSecureDirectory, writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
5
|
+
export class LockAlreadyRunningError extends Error {
|
|
6
|
+
lock;
|
|
7
|
+
constructor(lock) {
|
|
8
|
+
super(`copillm is already running (pid ${lock.pid}, port ${lock.port}).`);
|
|
9
|
+
this.lock = lock;
|
|
10
|
+
this.name = "LockAlreadyRunningError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function acquireLock(port, options) {
|
|
14
|
+
await acquireLockWithRetry(port, options, false);
|
|
15
|
+
}
|
|
16
|
+
async function acquireLockWithRetry(port, options, alreadyRetried) {
|
|
17
|
+
const file = lockPath();
|
|
18
|
+
ensureSecureDirectory(getCopillmHome());
|
|
19
|
+
const data = {
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
port,
|
|
22
|
+
started_at_iso: new Date().toISOString()
|
|
23
|
+
};
|
|
24
|
+
try {
|
|
25
|
+
const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
26
|
+
fs.closeSync(fd);
|
|
27
|
+
writeFileSecureAtomic(file, JSON.stringify(data, null, 2), 0o600);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const inspection = inspectLock();
|
|
36
|
+
if (inspection.state === "running") {
|
|
37
|
+
if (options?.isRunning && (await options.isRunning(inspection.lock))) {
|
|
38
|
+
throw new LockAlreadyRunningError(inspection.lock);
|
|
39
|
+
}
|
|
40
|
+
if (alreadyRetried) {
|
|
41
|
+
throw new Error("Unable to acquire lock after removing stale lock.");
|
|
42
|
+
}
|
|
43
|
+
tryUnlinkLock();
|
|
44
|
+
await acquireLockWithRetry(port, options, true);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (alreadyRetried) {
|
|
48
|
+
const reason = inspection.state === "stale" ? inspection.reason : "lock_exists";
|
|
49
|
+
throw new Error(`Unable to acquire lock: ${reason}`);
|
|
50
|
+
}
|
|
51
|
+
tryUnlinkLock();
|
|
52
|
+
await acquireLockWithRetry(port, options, true);
|
|
53
|
+
}
|
|
54
|
+
export function releaseLock() {
|
|
55
|
+
tryUnlinkLock();
|
|
56
|
+
}
|
|
57
|
+
export function inspectLock() {
|
|
58
|
+
const file = lockReadPath();
|
|
59
|
+
if (!fs.existsSync(file)) {
|
|
60
|
+
return { state: "missing" };
|
|
61
|
+
}
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(file, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
return { state: "stale", reason: `failed_to_read_lock: ${errorMessage(error)}`, lock: null };
|
|
68
|
+
}
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { state: "stale", reason: "lock_json_invalid", lock: null };
|
|
75
|
+
}
|
|
76
|
+
const lock = parseLockFileData(parsed);
|
|
77
|
+
if (!lock) {
|
|
78
|
+
return { state: "stale", reason: "lock_schema_invalid", lock: null };
|
|
79
|
+
}
|
|
80
|
+
const alive = processAlive(lock.pid);
|
|
81
|
+
if (!alive) {
|
|
82
|
+
return { state: "stale", reason: "pid_not_alive", lock };
|
|
83
|
+
}
|
|
84
|
+
return { state: "running", lock };
|
|
85
|
+
}
|
|
86
|
+
export function readLock() {
|
|
87
|
+
const inspection = inspectLock();
|
|
88
|
+
if (inspection.state !== "running") {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return inspection.lock;
|
|
92
|
+
}
|
|
93
|
+
function processAlive(pid) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
if (!(error instanceof Error) || !("code" in error)) {
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
const code = error.code;
|
|
103
|
+
if (code === "ESRCH") {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (code === "EPERM") {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function tryUnlinkLock() {
|
|
113
|
+
const file = lockPath();
|
|
114
|
+
if (!fs.existsSync(file)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
fs.unlinkSync(file);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function parseLockFileData(input) {
|
|
127
|
+
if (!input || typeof input !== "object") {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const obj = input;
|
|
131
|
+
if (typeof obj.pid !== "number" ||
|
|
132
|
+
!Number.isInteger(obj.pid) ||
|
|
133
|
+
obj.pid <= 0 ||
|
|
134
|
+
typeof obj.port !== "number" ||
|
|
135
|
+
!Number.isInteger(obj.port) ||
|
|
136
|
+
obj.port < 1 ||
|
|
137
|
+
obj.port > 65535 ||
|
|
138
|
+
typeof obj.started_at_iso !== "string" ||
|
|
139
|
+
obj.started_at_iso.length === 0) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
pid: obj.pid,
|
|
144
|
+
port: obj.port,
|
|
145
|
+
started_at_iso: obj.started_at_iso
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function errorMessage(error) {
|
|
149
|
+
return error instanceof Error ? error.message : "unknown_error";
|
|
150
|
+
}
|