@yhong91/cpac 0.1.25 → 0.1.26

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/claude.js ADDED
@@ -0,0 +1,373 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { createServer, request as httpRequest, } from "node:http";
4
+ import { request as httpsRequest } from "node:https";
5
+ import { apiBase, catalogModelId, catalogModelRows, fetchCatalog, loadConfig, } from "./config.js";
6
+ import { proxyHeaders, responseHeaders, upstreamUrl } from "./proxy.js";
7
+ import { CPACError, atomicWrite, checkboxPicker, objectValue, resolveApiKey, } from "./util.js";
8
+ const CLAUDE_ALIAS_PREFIX = "claude-cpac--";
9
+ // Claude Code accepts CLAUDE_CODE_AUTO_COMPACT_WINDOW in 100K–1M (binary-verified).
10
+ // A single global window cannot be per-model; 350K is opencodex's user-approved
11
+ // default — high enough to mark mid-size models (372K/500K), low enough that
12
+ // marking never outgrows a model's real window.
13
+ const ONE_MILLION = 1_000_000;
14
+ const COMPACT_WINDOW_DEFAULT = 350_000;
15
+ function claudeCompactWindow(maxContext) {
16
+ return maxContext > 200_000 ? COMPACT_WINDOW_DEFAULT : undefined;
17
+ }
18
+ // [1m] marking predicate (opencodex rule): native claude models manage their own
19
+ // marker; aliases mark when the real window is >= 1M, or when it clears 200K and
20
+ // can host the compact window (marking below it would trip API limits mid-session).
21
+ function shouldMarkOneMillion(id, window, compactWindow) {
22
+ if (id.startsWith("claude"))
23
+ return false;
24
+ if (window >= ONE_MILLION)
25
+ return true;
26
+ return (compactWindow !== undefined && window > 200_000 && window >= compactWindow);
27
+ }
28
+ // Auto tier defaults. Within the claude-family pool (whole catalog when no
29
+ // claude model exists), opus matches its family name or stays unset; sonnet
30
+ // and haiku match their family name first, then fall back through
31
+ // gemini → grok → luna → first catalog row.
32
+ function defaultClaudeSlots(models) {
33
+ if (!models.length)
34
+ return undefined;
35
+ const claudeRows = models.filter((model) => model.id.startsWith("claude"));
36
+ const pool = claudeRows.length ? claudeRows : models;
37
+ const family = (keyword) => pool.find((model) => model.id.includes(keyword));
38
+ const fallback = (keyword) => family(keyword) ??
39
+ models.find((model) => model.id.includes("gemini")) ??
40
+ models.find((model) => model.id.includes("grok")) ??
41
+ models.find((model) => model.id.includes("luna")) ??
42
+ models[0];
43
+ const slots = {
44
+ sonnet: fallback("sonnet").id,
45
+ haiku: fallback("haiku").id,
46
+ };
47
+ const opus = family("opus");
48
+ if (opus)
49
+ slots.opus = opus.id;
50
+ return slots;
51
+ }
52
+ // Resolve the tier-model env values: config overrides win over catalog
53
+ // defaults; values are [1m]-marked per the predicate above (Claude Code
54
+ // strips the marker before the request leaves).
55
+ export function claudeTierSlots(override, models, compactWindow) {
56
+ const defaults = defaultClaudeSlots(models);
57
+ if (!defaults)
58
+ return undefined;
59
+ const windowById = new Map(models.map((model) => [model.id, model.window]));
60
+ const mark = (id) => !/\[1m\]$/i.test(id) &&
61
+ shouldMarkOneMillion(id, windowById.get(id) ?? 0, compactWindow)
62
+ ? `${id}[1m]`
63
+ : id;
64
+ const slots = {
65
+ sonnet: mark(override?.sonnet ?? defaults.sonnet),
66
+ haiku: mark(override?.haiku ?? defaults.haiku),
67
+ };
68
+ const opus = override?.opus ?? defaults.opus;
69
+ if (opus)
70
+ slots.opus = mark(opus);
71
+ return slots;
72
+ }
73
+ async function claudeModelList(cpaUrl, apiKey, response) {
74
+ let rows;
75
+ try {
76
+ const catalog = await fetch(`${apiBase(cpaUrl)}/models?client_version=1`, {
77
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
78
+ signal: AbortSignal.timeout(20_000),
79
+ });
80
+ if (catalog.ok)
81
+ rows = catalogModelRows(await catalog.json());
82
+ }
83
+ catch {
84
+ rows = undefined;
85
+ }
86
+ if (!rows) {
87
+ response.writeHead(502, { "content-type": "application/json" });
88
+ response.end(JSON.stringify({ error: "CPA catalog request failed" }));
89
+ return;
90
+ }
91
+ // Claude Code's /model picker only lists ids starting with claude/anthropic;
92
+ // expose every other catalog model as claude-cpac--<id>. Append the [1m]
93
+ // marker when the model can host the auto-compact window — that marker is
94
+ // the only signal that lifts context accounting above the 200K default
95
+ // (Claude Code strips it before the request reaches the proxy).
96
+ const rowsWithWindow = [];
97
+ let maxContext = 0;
98
+ for (const model of rows) {
99
+ const id = catalogModelId(model);
100
+ if (!id)
101
+ continue;
102
+ const window = typeof model.context_window === "number" && model.context_window > 0
103
+ ? Math.floor(model.context_window)
104
+ : 0;
105
+ if (window > maxContext)
106
+ maxContext = window;
107
+ rowsWithWindow.push({
108
+ id,
109
+ window,
110
+ alias: id.startsWith("claude") ? id : `${CLAUDE_ALIAS_PREFIX}${id}`,
111
+ display: typeof model.display_name === "string" && model.display_name.trim()
112
+ ? model.display_name
113
+ : id,
114
+ });
115
+ }
116
+ const compactWindow = claudeCompactWindow(maxContext);
117
+ const data = rowsWithWindow.map((row) => ({
118
+ type: "model",
119
+ id: shouldMarkOneMillion(row.id, row.window, compactWindow)
120
+ ? `${row.alias}[1m]`
121
+ : row.alias,
122
+ display_name: row.display,
123
+ }));
124
+ response.writeHead(200, { "content-type": "application/json" });
125
+ response.end(JSON.stringify({
126
+ data,
127
+ has_more: false,
128
+ first_id: data.length > 0 ? data[0].id : null,
129
+ last_id: data.length > 0 ? data[data.length - 1].id : null,
130
+ }));
131
+ }
132
+ export async function createClaudeProxy(cpaUrl, apiKey) {
133
+ const server = createServer((request, response) => {
134
+ const requestUrl = request.url ?? "/";
135
+ if (request.method === "GET" && requestUrl.startsWith("/v1/models")) {
136
+ void claudeModelList(cpaUrl, apiKey, response);
137
+ return;
138
+ }
139
+ let target;
140
+ try {
141
+ target = upstreamUrl(cpaUrl, requestUrl);
142
+ }
143
+ catch {
144
+ response.writeHead(400).end("invalid request URL");
145
+ return;
146
+ }
147
+ const chunks = [];
148
+ request.on("data", (chunk) => chunks.push(chunk));
149
+ request.once("error", () => response.destroy());
150
+ request.once("end", () => {
151
+ let body = Buffer.concat(chunks);
152
+ if (body.length > 0) {
153
+ try {
154
+ const parsed = JSON.parse(body.toString("utf8"));
155
+ if (objectValue(parsed) &&
156
+ typeof parsed.model === "string" &&
157
+ parsed.model.startsWith(CLAUDE_ALIAS_PREFIX)) {
158
+ // Claude Code strips [1m] itself; keep the strip as a guard.
159
+ parsed.model = parsed.model
160
+ .slice(CLAUDE_ALIAS_PREFIX.length)
161
+ .replace(/\[(1|2)m\]$/i, "");
162
+ body = Buffer.from(JSON.stringify(parsed));
163
+ }
164
+ }
165
+ catch {
166
+ // not JSON; forward unchanged
167
+ }
168
+ }
169
+ const headers = proxyHeaders(request.headers, apiKey);
170
+ headers["content-length"] = String(body.length);
171
+ const send = target.protocol === "https:" ? httpsRequest : httpRequest;
172
+ const upstream = send(target, { method: request.method, headers }, (upstreamResponse) => {
173
+ response.writeHead(upstreamResponse.statusCode ?? 502, responseHeaders(upstreamResponse.headers));
174
+ upstreamResponse.pipe(response);
175
+ });
176
+ upstream.once("error", () => {
177
+ if (!response.headersSent) {
178
+ response.writeHead(502, { "content-type": "application/json" });
179
+ }
180
+ if (!response.writableEnded)
181
+ response.end(JSON.stringify({ error: "CPA upstream request failed" }));
182
+ });
183
+ request.once("aborted", () => upstream.destroy());
184
+ response.once("close", () => {
185
+ if (!response.writableEnded)
186
+ upstream.destroy();
187
+ });
188
+ upstream.end(body);
189
+ });
190
+ });
191
+ server.on("clientError", (_error, socket) => socket.destroy());
192
+ await new Promise((resolveListen, rejectListen) => {
193
+ server.once("error", rejectListen);
194
+ server.listen(0, "127.0.0.1", () => {
195
+ server.off("error", rejectListen);
196
+ resolveListen();
197
+ });
198
+ });
199
+ const address = server.address();
200
+ if (!address || typeof address === "string") {
201
+ server.close();
202
+ throw new CPACError("cannot determine Claude proxy port");
203
+ }
204
+ return { server, port: address.port };
205
+ }
206
+ export async function runClaudeConfig(config, configPath, options = {}) {
207
+ if (options.reset) {
208
+ let raw = {};
209
+ try {
210
+ const file = JSON.parse(readFileSync(configPath, "utf8"));
211
+ if (!objectValue(file))
212
+ throw new CPACError("config must be a JSON object");
213
+ raw = file;
214
+ }
215
+ catch (error) {
216
+ if (error.code !== "ENOENT") {
217
+ if (error instanceof CPACError)
218
+ throw error;
219
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
220
+ }
221
+ }
222
+ delete raw.claude_models;
223
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
224
+ console.log("Claude Code tier models reset to CPA catalog auto-selection");
225
+ return 0;
226
+ }
227
+ if (options.interactive && (!process.stdin.isTTY || !process.stderr.isTTY)) {
228
+ console.log(config.claude_models
229
+ ? JSON.stringify(config.claude_models, null, 2)
230
+ : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
231
+ return 0;
232
+ }
233
+ const models = { ...(options.models || {}) };
234
+ const shouldPick = options.interactive || (options.pick && options.pick.length > 0);
235
+ if (shouldPick) {
236
+ const apiKey = await resolveApiKey(config.api_key_env);
237
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
238
+ let document;
239
+ try {
240
+ document = JSON.parse(new TextDecoder().decode(catalog.bytes));
241
+ }
242
+ catch {
243
+ throw new CPACError("CPA catalog response is not valid JSON");
244
+ }
245
+ const slugs = (document.models ?? [])
246
+ .map((model) => (typeof model?.slug === "string" ? model.slug : ""))
247
+ .filter(Boolean);
248
+ const slotsToPick = options.pick && options.pick.length > 0
249
+ ? options.pick
250
+ : ["sonnet", "haiku", "opus"];
251
+ for (const slot of slotsToPick) {
252
+ const choices = slot === "sonnet" ? slugs : ["(auto-select from catalog)", ...slugs];
253
+ const [picked] = await checkboxPicker(`Select Claude Code ${slot.toUpperCase()} tier model:`, choices, 1);
254
+ if (picked && picked !== "(auto-select from catalog)") {
255
+ models[slot] = picked;
256
+ }
257
+ }
258
+ }
259
+ if (!Object.keys(models).length && !shouldPick) {
260
+ console.log(config.claude_models
261
+ ? JSON.stringify(config.claude_models, null, 2)
262
+ : "claude_models not set; cpac claude auto-picks tier models from the CPA catalog");
263
+ return 0;
264
+ }
265
+ let raw = {};
266
+ try {
267
+ const file = JSON.parse(readFileSync(configPath, "utf8"));
268
+ if (!objectValue(file))
269
+ throw new CPACError("config must be a JSON object");
270
+ raw = file;
271
+ }
272
+ catch (error) {
273
+ if (error.code !== "ENOENT") {
274
+ if (error instanceof CPACError)
275
+ throw error;
276
+ throw new CPACError(`cannot read config: ${error instanceof Error ? error.message : String(error)}`);
277
+ }
278
+ }
279
+ const existing = objectValue(raw.claude_models)
280
+ ? { ...raw.claude_models }
281
+ : {};
282
+ Object.assign(existing, models);
283
+ raw.claude_models = existing;
284
+ atomicWrite(configPath, Buffer.from(`${JSON.stringify(raw, null, 2)}\n`));
285
+ loadConfig(configPath);
286
+ console.log(`Claude Code tier models updated in ${configPath}:\n${JSON.stringify(raw.claude_models, null, 2)}`);
287
+ return 0;
288
+ }
289
+ export async function runClaudeModels(parsed) {
290
+ const config = loadConfig(parsed.configPath, true);
291
+ return await runClaudeConfig(config, parsed.configPath, {
292
+ models: parsed.models,
293
+ pick: parsed.pick,
294
+ reset: parsed.reset,
295
+ interactive: false,
296
+ });
297
+ }
298
+ export async function runClaude(config, args, executable = "claude") {
299
+ const apiKey = await resolveApiKey(config.api_key_env);
300
+ const proxy = await createClaudeProxy(config.cpa_url, apiKey);
301
+ const stopProxy = () => {
302
+ proxy.server.closeAllConnections?.();
303
+ proxy.server.close();
304
+ };
305
+ // Gateway discovery only carries {id, display_name}; Claude Code accounts
306
+ // claude-prefixed unknown models at 200K and ignores MAX_CONTEXT_TOKENS for
307
+ // them. The [1m] marker on the alias (added by the models listing) plus the
308
+ // auto-compact window is opencodex's working mechanism for bigger windows.
309
+ let maxContext = 0;
310
+ const catalogWindows = [];
311
+ try {
312
+ const catalog = await fetchCatalog(config.cpa_url, apiKey);
313
+ const rows = catalogModelRows(JSON.parse(new TextDecoder("utf-8").decode(catalog.bytes))) ?? [];
314
+ for (const row of rows) {
315
+ const id = catalogModelId(row);
316
+ const window = typeof row.context_window === "number" && row.context_window > 0
317
+ ? Math.floor(row.context_window)
318
+ : 0;
319
+ if (id)
320
+ catalogWindows.push({ id, window });
321
+ if (window > maxContext)
322
+ maxContext = window;
323
+ }
324
+ }
325
+ catch {
326
+ // keep the default window when the catalog is unreachable
327
+ }
328
+ const compactWindow = claudeCompactWindow(maxContext);
329
+ const slots = claudeTierSlots(config.claude_models, catalogWindows, compactWindow);
330
+ const env = {
331
+ ...process.env,
332
+ ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxy.port}`,
333
+ ANTHROPIC_AUTH_TOKEN: apiKey,
334
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
335
+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: "1",
336
+ };
337
+ if (compactWindow !== undefined &&
338
+ !(Number(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) > 0)) {
339
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(compactWindow);
340
+ }
341
+ const setSlot = (name, value) => {
342
+ if (process.env[name] === undefined)
343
+ env[name] = value;
344
+ };
345
+ if (slots) {
346
+ // Route haiku/subagent tier traffic at catalog models instead of the
347
+ // stock claude-haiku-* ids the gateway does not carry. The opus slot
348
+ // stays unset when the catalog has no opus-family model.
349
+ if (slots.opus)
350
+ setSlot("ANTHROPIC_DEFAULT_OPUS_MODEL", slots.opus);
351
+ setSlot("ANTHROPIC_DEFAULT_SONNET_MODEL", slots.sonnet);
352
+ setSlot("ANTHROPIC_DEFAULT_HAIKU_MODEL", slots.haiku);
353
+ setSlot("ANTHROPIC_SMALL_FAST_MODEL", slots.haiku);
354
+ }
355
+ delete env.ANTHROPIC_API_KEY;
356
+ delete env.CLAUDE_CODE_USE_ANTHROPIC_AWS;
357
+ delete env.CLAUDE_CODE_USE_BEDROCK;
358
+ delete env.CLAUDE_CODE_USE_FOUNDRY;
359
+ delete env.CLAUDE_CODE_USE_VERTEX;
360
+ return await new Promise((resolve, reject) => {
361
+ const child = spawn(executable, args, { env, stdio: "inherit" });
362
+ child.once("error", (error) => {
363
+ stopProxy();
364
+ reject(new CPACError(error instanceof Error && "code" in error && error.code === "ENOENT"
365
+ ? `${executable} not found`
366
+ : `cannot start ${executable}: ${error.message}`));
367
+ });
368
+ child.once("close", (code) => {
369
+ stopProxy();
370
+ resolve(code ?? 1);
371
+ });
372
+ });
373
+ }