@gururea/opencode-commandcode-provider 0.1.0 → 0.2.1
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 +10 -3
- package/command/commandcode-quota.md +5 -0
- package/command/commandcode-status.md +5 -0
- package/dist/index.js +734 -26
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# opencode-commandcode
|
|
1
|
+
# @gururea/opencode-commandcode-provider
|
|
2
2
|
|
|
3
3
|
An [opencode](https://opencode.ai) plugin that adds the [Command Code](https://commandcode.ai) provider to the interface and its authentication to the `/connect` dialog.
|
|
4
4
|
|
|
@@ -11,7 +11,7 @@ Add the plugin to your `opencode.json`:
|
|
|
11
11
|
```json
|
|
12
12
|
{
|
|
13
13
|
"$schema": "https://opencode.ai/config.json",
|
|
14
|
-
"plugin": ["opencode-commandcode"]
|
|
14
|
+
"plugin": ["@gururea/opencode-commandcode-provider"]
|
|
15
15
|
}
|
|
16
16
|
```
|
|
17
17
|
|
|
@@ -25,12 +25,17 @@ Restart opencode, then:
|
|
|
25
25
|
|
|
26
26
|
## What the plugin does
|
|
27
27
|
|
|
28
|
-
- **Registers the provider.** The plugin injects a `commandcode` custom provider into the opencode config (`@ai-sdk/openai-compatible`, base URL `https://api.commandcode.ai/provider/v1`).
|
|
28
|
+
- **Registers the provider.** The plugin injects a `commandcode` custom provider into the opencode config (`@ai-sdk/openai-compatible`, base URL `https://api.commandcode.ai/provider/v1`).
|
|
29
|
+
- **Live model discovery with caching.** The catalog is fetched from the Provider API at every startup. A successful fetch is cached to disk (`~/.cache/opencode/commandcode-models.json`) so the next start is fast and works offline; if both the endpoint and the cache are unavailable, a built-in fallback catalog keeps the provider visible.
|
|
29
30
|
- **Adds authentication.** The `/connect` dialog gets two methods via the plugin `auth` hook:
|
|
30
31
|
- **Browser login:** opens `commandcode.ai/studio/auth/cli` and receives the API key through a local callback server (fall back to pasting the key if the transfer fails).
|
|
31
32
|
- **API key:** the standard key-paste dialog.
|
|
33
|
+
- **`commandcode_quota` tool / `/commandcode-quota`.** Reports your account usage and quota: remaining and used credits, plan, billing-period usage (cost, requests, tokens), and the 5-hour and weekly usage windows.
|
|
34
|
+
- **`commandcode_status` tool / `/commandcode-status`.** Shows plugin diagnostics: catalog source (live/cache/fallback), model count, last fetch time, endpoint, zero-data-retention setting, and version.
|
|
32
35
|
- Command Code API keys (`user_...`) do not expire, so they are stored once and reused.
|
|
33
36
|
|
|
37
|
+
> The plugin installs the `commandcode-quota.md` and `commandcode-status.md` slash-command files into your project's `.opencode/command/` directory on startup, so the `/commandcode-quota` and `/commandcode-status` commands appear in the TUI autocomplete. The commands invoke the corresponding tools registered by the plugin.
|
|
38
|
+
|
|
34
39
|
## Environment variables
|
|
35
40
|
|
|
36
41
|
| Variable | Purpose |
|
|
@@ -38,6 +43,8 @@ Restart opencode, then:
|
|
|
38
43
|
| `COMMAND_CODE_API_KEY` / `COMMANDCODE_API_KEY` | API key used as a fallback when no auth is stored via `/connect` |
|
|
39
44
|
| `COMMANDCODE_API_BASE` | Override the provider API base URL (default `https://api.commandcode.ai/provider/v1`) |
|
|
40
45
|
| `COMMANDCODE_MODELS_URL` | Override the model catalog URL (default `<api base>/models`) |
|
|
46
|
+
| `COMMANDCODE_MODELS_CACHE` | Override the model cache path (default `~/.cache/opencode/commandcode-models.json`) |
|
|
47
|
+
| `COMMANDCODE_MODELS_TIMEOUT_MS` | Model catalog fetch timeout (default `10000` ms) |
|
|
41
48
|
| `CMD_ZDR=1` / `COMMANDCODE_ZDR=1` | Send the `x-cmd-zdr: 1` zero-data-retention header |
|
|
42
49
|
|
|
43
50
|
## Scope
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show Command Code account usage and quota (credits, plan, usage)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Use the **commandcode_quota** tool to fetch and report the user's Command Code account usage and quota. Summarize the credits remaining and used, the plan, the billing-period usage (cost, requests, tokens), and the 5-hour and weekly usage windows. If no API key is configured, tell the user to run `/connect` and select Command Code.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Show Command Code plugin diagnostics (catalog source, model count, endpoint)
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Use the **commandcode_status** tool to fetch and report the Command Code plugin diagnostics: the model catalog source (live/cache/fallback), model count, when the catalog was last fetched, the API endpoint, the zero-data-retention setting, and the plugin version.
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join as join2 } from "path";
|
|
4
|
+
|
|
1
5
|
// src/auth.ts
|
|
2
6
|
import { randomBytes } from "crypto";
|
|
3
7
|
|
|
@@ -233,10 +237,554 @@ function createAuthHook(options = {}) {
|
|
|
233
237
|
};
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
// src/commands.ts
|
|
241
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
242
|
+
import { join } from "path";
|
|
243
|
+
var QUOTA_TEMPLATE = `---
|
|
244
|
+
description: Show Command Code account usage and quota (credits, plan, usage)
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
Use the **commandcode_quota** tool to fetch and report the user's Command Code account usage and quota. Summarize the credits remaining and used, the plan, the billing-period usage (cost, requests, tokens), and the 5-hour and weekly usage windows. If no API key is configured, tell the user to run \`/connect\` and select Command Code.
|
|
248
|
+
`;
|
|
249
|
+
var STATUS_TEMPLATE = `---
|
|
250
|
+
description: Show Command Code plugin diagnostics (catalog source, model count, endpoint)
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
Use the **commandcode_status** tool to fetch and report the Command Code plugin diagnostics: the model catalog source (live/cache/fallback), model count, when the catalog was last fetched, the API endpoint, the zero-data-retention setting, and the plugin version.
|
|
254
|
+
`;
|
|
255
|
+
var COMMANDS = [
|
|
256
|
+
{ name: "commandcode-quota.md", content: QUOTA_TEMPLATE },
|
|
257
|
+
{ name: "commandcode-status.md", content: STATUS_TEMPLATE }
|
|
258
|
+
];
|
|
259
|
+
async function installCommandFiles(directory) {
|
|
260
|
+
if (!directory) return;
|
|
261
|
+
const commandDir = join(directory, ".opencode", "command");
|
|
262
|
+
await mkdir(commandDir, { recursive: true });
|
|
263
|
+
for (const command of COMMANDS) {
|
|
264
|
+
const target = join(commandDir, command.name);
|
|
265
|
+
try {
|
|
266
|
+
const existing = await readFile(target, "utf-8");
|
|
267
|
+
if (existing === command.content) continue;
|
|
268
|
+
return;
|
|
269
|
+
} catch {
|
|
270
|
+
}
|
|
271
|
+
await writeFile(target, command.content, "utf-8");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// src/quota-tool.ts
|
|
276
|
+
import { tool } from "@opencode-ai/plugin";
|
|
277
|
+
|
|
278
|
+
// src/api-key.ts
|
|
279
|
+
import { existsSync, readFileSync } from "fs";
|
|
280
|
+
var PROVIDER_ID = "commandcode";
|
|
281
|
+
function isRecord(value) {
|
|
282
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
283
|
+
}
|
|
284
|
+
function authKey(record) {
|
|
285
|
+
const type = record.type;
|
|
286
|
+
if (type === "api") return typeof record.key === "string" ? record.key : void 0;
|
|
287
|
+
if (type === "oauth") return typeof record.access === "string" ? record.access : void 0;
|
|
288
|
+
if (type === "wellknown") {
|
|
289
|
+
return typeof record.token === "string" ? record.token : typeof record.key === "string" ? record.key : void 0;
|
|
290
|
+
}
|
|
291
|
+
return void 0;
|
|
292
|
+
}
|
|
293
|
+
function credentialFromAuthJson(raw) {
|
|
294
|
+
try {
|
|
295
|
+
const parsed = JSON.parse(raw);
|
|
296
|
+
if (!isRecord(parsed)) return void 0;
|
|
297
|
+
const entry = parsed[PROVIDER_ID];
|
|
298
|
+
if (!isRecord(entry)) return void 0;
|
|
299
|
+
return authKey(entry);
|
|
300
|
+
} catch {
|
|
301
|
+
return void 0;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function defaultAuthPath() {
|
|
305
|
+
return `${process.env.HOME ?? ""}/.local/share/opencode/auth.json`;
|
|
306
|
+
}
|
|
307
|
+
function readCommandCodeKey(options = {}) {
|
|
308
|
+
const env = options.env ?? process.env;
|
|
309
|
+
if (env.COMMAND_CODE_API_KEY) return env.COMMAND_CODE_API_KEY;
|
|
310
|
+
if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY;
|
|
311
|
+
const authContent = options.authContent ?? env.OPENCODE_AUTH_CONTENT;
|
|
312
|
+
if (authContent) {
|
|
313
|
+
const fromContent = credentialFromAuthJson(authContent);
|
|
314
|
+
if (fromContent) return fromContent;
|
|
315
|
+
}
|
|
316
|
+
const authPath = options.authPath ?? defaultAuthPath();
|
|
317
|
+
try {
|
|
318
|
+
if (!existsSync(authPath)) return void 0;
|
|
319
|
+
const raw = readFileSync(authPath, "utf-8");
|
|
320
|
+
return credentialFromAuthJson(raw);
|
|
321
|
+
} catch {
|
|
322
|
+
return void 0;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// src/redact.ts
|
|
327
|
+
function redactBearer(value) {
|
|
328
|
+
return value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]");
|
|
329
|
+
}
|
|
330
|
+
function redactCredentials(value) {
|
|
331
|
+
return value.replace(
|
|
332
|
+
/\b(?:api[-_ ]?key|apikey|access[-_ ]?token|refresh[-_ ]?token|token|secret|password|authorization)\s*[=:]\s*[^\s,;&)]+/gi,
|
|
333
|
+
(match) => {
|
|
334
|
+
const separatorIndex = match.search(/[=:]/);
|
|
335
|
+
return separatorIndex < 0 ? "[redacted]" : `${match.slice(0, separatorIndex + 1)}[redacted]`;
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
function redactUserTokens(value) {
|
|
340
|
+
return value.replace(/\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi, "[redacted]");
|
|
341
|
+
}
|
|
342
|
+
function redactQuerySecrets(value) {
|
|
343
|
+
return value.replace(
|
|
344
|
+
/([?&](?:api[-_ ]?key|apikey|access_token|refresh_token|token|secret|password)=)[^&#\s]+/gi,
|
|
345
|
+
"$1[redacted]"
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
function redactStandaloneSecrets(value) {
|
|
349
|
+
return value.replace(
|
|
350
|
+
/\b(?:sk|rk|ghp|github_pat|xox[baprs])[-_A-Za-z0-9]{16,}\b|\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g,
|
|
351
|
+
"[redacted]"
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
function redactCommandCodeErrorText(value) {
|
|
355
|
+
return redactStandaloneSecrets(
|
|
356
|
+
redactQuerySecrets(redactUserTokens(redactCredentials(redactBearer(value))))
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/quota.ts
|
|
361
|
+
var DEFAULT_API_BASE = "https://api.commandcode.ai";
|
|
362
|
+
var QUOTA_TIMEOUT_MS = 15e3;
|
|
363
|
+
function isRecord2(value) {
|
|
364
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
365
|
+
}
|
|
366
|
+
function numberValue(value) {
|
|
367
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
368
|
+
}
|
|
369
|
+
function stringValue(value) {
|
|
370
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
371
|
+
}
|
|
372
|
+
function errorMessage(error) {
|
|
373
|
+
return error instanceof Error ? error.message : String(error);
|
|
374
|
+
}
|
|
375
|
+
function normalizeResetAt(value) {
|
|
376
|
+
let timestamp;
|
|
377
|
+
if (typeof value === "number" && Number.isFinite(value)) timestamp = value;
|
|
378
|
+
if (typeof value === "string" && value.length > 0) {
|
|
379
|
+
const trimmed = value.trim();
|
|
380
|
+
timestamp = /^\d+$/.test(trimmed) ? Number(trimmed) : Date.parse(trimmed);
|
|
381
|
+
}
|
|
382
|
+
if (timestamp === void 0 || !Number.isFinite(timestamp) || timestamp < 0) return null;
|
|
383
|
+
return timestamp >= 1e12 ? Math.round(timestamp / 1e3) : timestamp;
|
|
384
|
+
}
|
|
385
|
+
function windowLimitsFromCredits(value) {
|
|
386
|
+
if (!isRecord2(value)) return [];
|
|
387
|
+
const limits = [];
|
|
388
|
+
for (const [window, entry] of [
|
|
389
|
+
["fiveHour", value.fiveHour],
|
|
390
|
+
["weekly", value.weekly]
|
|
391
|
+
]) {
|
|
392
|
+
if (!isRecord2(entry)) continue;
|
|
393
|
+
const used = numberValue(entry.used);
|
|
394
|
+
const cap = numberValue(entry.cap);
|
|
395
|
+
if (used === void 0 || cap === void 0 || used === 0 && cap === 0) continue;
|
|
396
|
+
limits.push({ window, used, cap, resetAt: normalizeResetAt(entry.resetAt) });
|
|
397
|
+
}
|
|
398
|
+
return limits;
|
|
399
|
+
}
|
|
400
|
+
function parseCredits(value) {
|
|
401
|
+
if (!isRecord2(value) || !isRecord2(value.credits)) return null;
|
|
402
|
+
const credits = value.credits;
|
|
403
|
+
const monthlyCredits = numberValue(credits.monthlyCredits);
|
|
404
|
+
const purchasedCredits = numberValue(credits.purchasedCredits);
|
|
405
|
+
const freeCredits = numberValue(credits.freeCredits);
|
|
406
|
+
if (monthlyCredits === void 0 && purchasedCredits === void 0 && freeCredits === void 0) {
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
const monthly = monthlyCredits ?? 0;
|
|
410
|
+
const purchased = purchasedCredits ?? 0;
|
|
411
|
+
const free = freeCredits ?? 0;
|
|
412
|
+
return {
|
|
413
|
+
monthlyCredits: monthly,
|
|
414
|
+
purchasedCredits: purchased,
|
|
415
|
+
freeCredits: free,
|
|
416
|
+
remainingCredits: monthly + purchased + free,
|
|
417
|
+
windowLimits: windowLimitsFromCredits(value.windowLimits)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function parseSubscription(value) {
|
|
421
|
+
if (!isRecord2(value) || !isRecord2(value.data)) return null;
|
|
422
|
+
const data = value.data;
|
|
423
|
+
const planId = stringValue(data.planId);
|
|
424
|
+
const status = stringValue(data.status);
|
|
425
|
+
const currentPeriodStart = stringValue(data.currentPeriodStart);
|
|
426
|
+
const currentPeriodEnd = stringValue(data.currentPeriodEnd);
|
|
427
|
+
if (!planId && !status && !currentPeriodStart && !currentPeriodEnd) return null;
|
|
428
|
+
return {
|
|
429
|
+
planId: planId ?? null,
|
|
430
|
+
status: status ?? null,
|
|
431
|
+
currentPeriodStart: currentPeriodStart ?? null,
|
|
432
|
+
currentPeriodEnd: currentPeriodEnd ?? null
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function parseSummary(value) {
|
|
436
|
+
if (!isRecord2(value)) return null;
|
|
437
|
+
const totalCost = numberValue(value.totalCost);
|
|
438
|
+
const totalCount = numberValue(value.totalCount);
|
|
439
|
+
if (totalCost === void 0 || totalCount === void 0) return null;
|
|
440
|
+
const totalTokens = numberValue(value.totalTokens) ?? numberValue(value.tokens);
|
|
441
|
+
return { totalCost, totalCount, ...totalTokens === void 0 ? {} : { totalTokens } };
|
|
442
|
+
}
|
|
443
|
+
function parseWhoami(value) {
|
|
444
|
+
if (!isRecord2(value)) return null;
|
|
445
|
+
const org = isRecord2(value.org) ? value.org : void 0;
|
|
446
|
+
const user = isRecord2(value.user) ? value.user : void 0;
|
|
447
|
+
const login = (org ? stringValue(org.login) : void 0) ?? (user ? stringValue(user.userName) ?? stringValue(user.name) : void 0);
|
|
448
|
+
if (!login) return null;
|
|
449
|
+
const orgId = org ? stringValue(org.id) : void 0;
|
|
450
|
+
const keyName = user ? stringValue(user.keyName) ?? stringValue(user.displayName) : void 0;
|
|
451
|
+
return { login, orgId: orgId ?? null, ...keyName ? { keyName } : {} };
|
|
452
|
+
}
|
|
453
|
+
function buildUrl(path, params) {
|
|
454
|
+
const search = new URLSearchParams();
|
|
455
|
+
for (const [key, value] of Object.entries(params)) {
|
|
456
|
+
if (value) search.set(key, value);
|
|
457
|
+
}
|
|
458
|
+
const query = search.toString();
|
|
459
|
+
return `${path}${query ? `?${query}` : ""}`;
|
|
460
|
+
}
|
|
461
|
+
function isHttpError(value) {
|
|
462
|
+
return isRecord2(value) && value.__httpError === true && typeof value.message === "string" && typeof value.status === "number" && typeof value.body === "string";
|
|
463
|
+
}
|
|
464
|
+
function isQuotaError(value) {
|
|
465
|
+
return isRecord2(value) && value.__quotaError === true && (value.kind === "timeout" || value.kind === "network");
|
|
466
|
+
}
|
|
467
|
+
function isBlockingHttpError(error) {
|
|
468
|
+
return error.status === 401 || error.status === 403;
|
|
469
|
+
}
|
|
470
|
+
function httpFailure(error, context) {
|
|
471
|
+
const detail = error.body.trim().slice(0, 200);
|
|
472
|
+
return {
|
|
473
|
+
ok: false,
|
|
474
|
+
error: {
|
|
475
|
+
kind: "http",
|
|
476
|
+
message: redactCommandCodeErrorText(
|
|
477
|
+
`${context} request failed (${error.status}): ${detail || error.message}`
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
var QuotaTimeoutError = class extends Error {
|
|
483
|
+
};
|
|
484
|
+
async function fetchCommandCodeQuota(options) {
|
|
485
|
+
if (!options.apiKey) {
|
|
486
|
+
return { ok: false, error: { message: "No Command Code API key found", kind: "config" } };
|
|
487
|
+
}
|
|
488
|
+
const baseUrl = options.baseUrl ?? DEFAULT_API_BASE;
|
|
489
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
490
|
+
const timeoutMs = options.timeoutMs ?? QUOTA_TIMEOUT_MS;
|
|
491
|
+
const overallController = new AbortController();
|
|
492
|
+
const overallTimer = setTimeout(() => overallController.abort(), timeoutMs);
|
|
493
|
+
const headers = {
|
|
494
|
+
accept: "application/json",
|
|
495
|
+
Authorization: `Bearer ${options.apiKey}`,
|
|
496
|
+
...options.extraHeaders
|
|
497
|
+
};
|
|
498
|
+
const request = async (path) => {
|
|
499
|
+
if (overallController.signal.aborted) throw new QuotaTimeoutError();
|
|
500
|
+
try {
|
|
501
|
+
const response = await fetchImpl(`${baseUrl}${path}`, {
|
|
502
|
+
method: "GET",
|
|
503
|
+
headers,
|
|
504
|
+
signal: overallController.signal
|
|
505
|
+
});
|
|
506
|
+
if (!response.ok) {
|
|
507
|
+
return {
|
|
508
|
+
__httpError: true,
|
|
509
|
+
message: response.status === 401 || response.status === 403 ? "Command Code rejected the API key" : response.statusText,
|
|
510
|
+
status: response.status,
|
|
511
|
+
body: await response.text().catch(() => "")
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
return await response.json();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (overallController.signal.aborted) throw new QuotaTimeoutError();
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
const safeRequest = async (path) => {
|
|
521
|
+
try {
|
|
522
|
+
return await request(path);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
return {
|
|
525
|
+
__quotaError: true,
|
|
526
|
+
kind: error instanceof QuotaTimeoutError ? "timeout" : "network"
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
try {
|
|
531
|
+
const whoamiRaw = await request("/alpha/whoami");
|
|
532
|
+
if (isHttpError(whoamiRaw)) return httpFailure(whoamiRaw, "whoami");
|
|
533
|
+
const account = parseWhoami(whoamiRaw);
|
|
534
|
+
if (!account) {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
error: { kind: "http", message: "Command Code returned an unrecognized account response" }
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const orgId = account.orgId ?? void 0;
|
|
541
|
+
const [creditsRaw, subscriptionRaw] = await Promise.all([
|
|
542
|
+
safeRequest(buildUrl("/alpha/billing/credits", { orgId })),
|
|
543
|
+
safeRequest(buildUrl("/alpha/billing/subscriptions", { orgId }))
|
|
544
|
+
]);
|
|
545
|
+
if (isHttpError(creditsRaw) && isBlockingHttpError(creditsRaw)) {
|
|
546
|
+
return httpFailure(creditsRaw, "credits");
|
|
547
|
+
}
|
|
548
|
+
if (isHttpError(subscriptionRaw) && isBlockingHttpError(subscriptionRaw)) {
|
|
549
|
+
return httpFailure(subscriptionRaw, "subscription");
|
|
550
|
+
}
|
|
551
|
+
const unavailable = [];
|
|
552
|
+
const credits = isHttpError(creditsRaw) || isQuotaError(creditsRaw) ? null : parseCredits(creditsRaw);
|
|
553
|
+
if (!credits) unavailable.push("credits");
|
|
554
|
+
const subscription = isHttpError(subscriptionRaw) || isQuotaError(subscriptionRaw) ? null : parseSubscription(subscriptionRaw);
|
|
555
|
+
if (!subscription) unavailable.push("subscription");
|
|
556
|
+
const summaryRaw = await safeRequest(
|
|
557
|
+
buildUrl("/alpha/usage/summary", {
|
|
558
|
+
orgId,
|
|
559
|
+
since: subscription?.currentPeriodStart ?? void 0
|
|
560
|
+
})
|
|
561
|
+
);
|
|
562
|
+
if (isHttpError(summaryRaw) && isBlockingHttpError(summaryRaw)) {
|
|
563
|
+
return httpFailure(summaryRaw, "summary");
|
|
564
|
+
}
|
|
565
|
+
const summary = isHttpError(summaryRaw) || isQuotaError(summaryRaw) ? null : parseSummary(summaryRaw);
|
|
566
|
+
if (!summary) unavailable.push("usage");
|
|
567
|
+
if (!credits && !subscription && !summary) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
error: {
|
|
571
|
+
kind: overallController.signal.aborted ? "timeout" : "http",
|
|
572
|
+
message: overallController.signal.aborted ? "Command Code quota request timed out" : "Command Code returned no recognized usage data for the account"
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
return {
|
|
577
|
+
ok: true,
|
|
578
|
+
quota: {
|
|
579
|
+
account,
|
|
580
|
+
credits,
|
|
581
|
+
subscription,
|
|
582
|
+
summary,
|
|
583
|
+
...unavailable.length > 0 ? { unavailable } : {}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (error instanceof QuotaTimeoutError || overallController.signal.aborted) {
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
error: { message: "Command Code quota request timed out", kind: "timeout" }
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
ok: false,
|
|
595
|
+
error: {
|
|
596
|
+
message: redactCommandCodeErrorText(
|
|
597
|
+
`Failed to fetch Command Code quota: ${errorMessage(error)}`
|
|
598
|
+
),
|
|
599
|
+
kind: "network"
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
} finally {
|
|
603
|
+
clearTimeout(overallTimer);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/quota-format.ts
|
|
608
|
+
function formatWindowLimits(limits, now = Date.now) {
|
|
609
|
+
const labels = {
|
|
610
|
+
fiveHour: "5-hour",
|
|
611
|
+
weekly: "Weekly"
|
|
612
|
+
};
|
|
613
|
+
return limits.map((limit) => {
|
|
614
|
+
const used = limit.used.toFixed(2);
|
|
615
|
+
const cap = limit.cap.toFixed(2);
|
|
616
|
+
const percent = limit.cap > 0 ? Math.round(limit.used / limit.cap * 100) : 0;
|
|
617
|
+
const reset = limit.resetAt === null ? "" : ` (resets ${formatResetClock(limit.resetAt, now)})`;
|
|
618
|
+
return `${labels[limit.window]}: ${used} / ${cap} credits (${percent}% used)${reset}`;
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
function formatResetClock(resetAtSeconds, now) {
|
|
622
|
+
const date = new Date(resetAtSeconds * 1e3);
|
|
623
|
+
if (Number.isNaN(date.getTime())) return "unknown";
|
|
624
|
+
const diffMs = date.getTime() - now();
|
|
625
|
+
if (diffMs <= 0) return "soon";
|
|
626
|
+
const minutes = Math.ceil(diffMs / 6e4);
|
|
627
|
+
if (minutes < 60) return `in ${minutes}m`;
|
|
628
|
+
const hours = Math.floor(minutes / 60);
|
|
629
|
+
const remainingMinutes = minutes % 60;
|
|
630
|
+
if (hours < 24) {
|
|
631
|
+
return remainingMinutes > 0 ? `in ${hours}h ${remainingMinutes}m` : `in ${hours}h`;
|
|
632
|
+
}
|
|
633
|
+
const days = Math.floor(hours / 24);
|
|
634
|
+
return days === 1 ? "in 1 day" : `in ${days} days`;
|
|
635
|
+
}
|
|
636
|
+
function creditsDetail(credits) {
|
|
637
|
+
if (!credits) return void 0;
|
|
638
|
+
const parts = [
|
|
639
|
+
`monthly $${credits.monthlyCredits.toFixed(2)}`,
|
|
640
|
+
`purchased $${credits.purchasedCredits.toFixed(2)}`
|
|
641
|
+
];
|
|
642
|
+
if (credits.freeCredits > 0) parts.push(`free $${credits.freeCredits.toFixed(2)}`);
|
|
643
|
+
return `Sources: ${parts.join(" / ")}`;
|
|
644
|
+
}
|
|
645
|
+
function subscriptionLine(subscription) {
|
|
646
|
+
const plan = (subscription.planId ?? "Unknown").replace(/[_-]+/g, " ").trim();
|
|
647
|
+
const status = subscription.status ? ` (${subscription.status})` : "";
|
|
648
|
+
return `Plan: ${plan}${status}`;
|
|
649
|
+
}
|
|
650
|
+
function formatTokens(tokens) {
|
|
651
|
+
if (tokens >= 1e9) return `${(tokens / 1e9).toFixed(1)}B`;
|
|
652
|
+
if (tokens >= 1e6) return `${(tokens / 1e6).toFixed(1)}M`;
|
|
653
|
+
if (tokens >= 1e3) return `${(tokens / 1e3).toFixed(1)}k`;
|
|
654
|
+
return String(tokens);
|
|
655
|
+
}
|
|
656
|
+
function formatQuota(quota, now = Date.now) {
|
|
657
|
+
const lines = [];
|
|
658
|
+
const remaining = quota.credits?.remainingCredits ?? 0;
|
|
659
|
+
const spent = quota.summary?.totalCost ?? 0;
|
|
660
|
+
const pool = remaining + spent;
|
|
661
|
+
if (quota.credits || quota.summary) {
|
|
662
|
+
lines.push("Credits");
|
|
663
|
+
lines.push(` Remaining: $${remaining.toFixed(2)} of $${pool.toFixed(2)}`);
|
|
664
|
+
lines.push(` Used: $${spent.toFixed(2)}`);
|
|
665
|
+
lines.push(` ${pool > 0 ? Math.round(spent / pool * 100) : 0}% used`);
|
|
666
|
+
}
|
|
667
|
+
const detail = creditsDetail(quota.credits);
|
|
668
|
+
if (detail) lines.push(detail);
|
|
669
|
+
if (quota.subscription) lines.push(subscriptionLine(quota.subscription));
|
|
670
|
+
if (quota.summary) {
|
|
671
|
+
lines.push("");
|
|
672
|
+
lines.push(quota.subscription?.currentPeriodStart ? "Usage (billing period)" : "Usage");
|
|
673
|
+
lines.push(` Cost: $${quota.summary.totalCost.toFixed(2)}`);
|
|
674
|
+
lines.push(` Requests: ${quota.summary.totalCount.toLocaleString("en-US")}`);
|
|
675
|
+
if (quota.summary.totalTokens !== void 0) {
|
|
676
|
+
lines.push(` Tokens: ${formatTokens(quota.summary.totalTokens)}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
lines.push("");
|
|
680
|
+
lines.push("Account");
|
|
681
|
+
lines.push(` ${quota.account.keyName ?? quota.account.login}`);
|
|
682
|
+
const limits = quota.credits?.windowLimits ?? [];
|
|
683
|
+
if (limits.length > 0) {
|
|
684
|
+
lines.push("");
|
|
685
|
+
lines.push("Usage windows:");
|
|
686
|
+
lines.push(...formatWindowLimits(limits, now).map((line) => ` ${line}`));
|
|
687
|
+
}
|
|
688
|
+
if ((quota.unavailable?.length ?? 0) > 0) {
|
|
689
|
+
lines.push("");
|
|
690
|
+
lines.push(`Unavailable: ${quota.unavailable?.join(", ")}`);
|
|
691
|
+
}
|
|
692
|
+
lines.push("");
|
|
693
|
+
lines.push("Full detail: https://commandcode.ai/usage");
|
|
694
|
+
return lines.join("\n");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/quota-tool.ts
|
|
698
|
+
function createQuotaTool({ apiBase, extraHeaders, client, readKey }) {
|
|
699
|
+
const resolveKey = readKey ?? readCommandCodeKey;
|
|
700
|
+
const showToast = (message, variant) => {
|
|
701
|
+
client.tui.showToast({ body: { message, variant } }).catch(() => {
|
|
702
|
+
});
|
|
703
|
+
};
|
|
704
|
+
return {
|
|
705
|
+
commandcode_quota: tool({
|
|
706
|
+
description: "Show the user's Command Code account usage and quota: remaining and used credits, plan, billing-period usage, and usage windows.",
|
|
707
|
+
args: {},
|
|
708
|
+
async execute() {
|
|
709
|
+
const apiKey = resolveKey();
|
|
710
|
+
if (!apiKey) {
|
|
711
|
+
showToast(
|
|
712
|
+
"Command Code quota needs an API key. Run /connect and select Command Code.",
|
|
713
|
+
"error"
|
|
714
|
+
);
|
|
715
|
+
return {
|
|
716
|
+
title: "Command Code quota",
|
|
717
|
+
output: "No Command Code API key found. Run /connect and authenticate first."
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
const result = await fetchCommandCodeQuota({
|
|
721
|
+
apiKey,
|
|
722
|
+
baseUrl: apiBase,
|
|
723
|
+
extraHeaders
|
|
724
|
+
});
|
|
725
|
+
if (!result.ok) {
|
|
726
|
+
showToast(result.error.message, "error");
|
|
727
|
+
return { title: "Command Code quota", output: result.error.message };
|
|
728
|
+
}
|
|
729
|
+
const output = formatQuota(result.quota);
|
|
730
|
+
showToast("Command Code quota loaded.", "success");
|
|
731
|
+
return { title: "Command Code quota", output };
|
|
732
|
+
}
|
|
733
|
+
})
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/status-tool.ts
|
|
738
|
+
import { tool as tool2 } from "@opencode-ai/plugin";
|
|
739
|
+
|
|
740
|
+
// src/status.ts
|
|
741
|
+
function formatStatus(input) {
|
|
742
|
+
const lines = [
|
|
743
|
+
"Command Code plugin status",
|
|
744
|
+
` Source: ${input.source}`,
|
|
745
|
+
` Models: ${input.modelCount}`,
|
|
746
|
+
` Fetched: ${input.fetchedAt === void 0 ? "n/a" : new Date(input.fetchedAt).toISOString()}`,
|
|
747
|
+
` Endpoint: ${input.apiBase}`,
|
|
748
|
+
` Zero-data-retention: ${input.zdr ? "on" : "off"}`,
|
|
749
|
+
` Version: ${input.version}`
|
|
750
|
+
];
|
|
751
|
+
return lines.join("\n");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/status-tool.ts
|
|
755
|
+
function createStatusTool({
|
|
756
|
+
apiBase,
|
|
757
|
+
zdr,
|
|
758
|
+
version,
|
|
759
|
+
modelsInfo
|
|
760
|
+
}) {
|
|
761
|
+
return {
|
|
762
|
+
commandcode_status: tool2({
|
|
763
|
+
description: "Show Command Code plugin diagnostics: the model catalog source (live/cache/fallback), model count, cache time, endpoint, and zero-data-retention setting.",
|
|
764
|
+
args: {},
|
|
765
|
+
async execute() {
|
|
766
|
+
const output = formatStatus({
|
|
767
|
+
source: modelsInfo.source,
|
|
768
|
+
modelCount: modelsInfo.modelCount,
|
|
769
|
+
fetchedAt: modelsInfo.fetchedAt,
|
|
770
|
+
apiBase,
|
|
771
|
+
zdr,
|
|
772
|
+
version
|
|
773
|
+
});
|
|
774
|
+
return { title: "Command Code status", output };
|
|
775
|
+
}
|
|
776
|
+
})
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
236
780
|
// src/models.ts
|
|
781
|
+
import { mkdir as mkdir2, readFile as readFile2, rename, rm, writeFile as writeFile2 } from "fs/promises";
|
|
782
|
+
import { dirname } from "path";
|
|
237
783
|
var DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1";
|
|
238
784
|
var DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models`;
|
|
239
785
|
var DEFAULT_MODELS_TIMEOUT_MS = 1e4;
|
|
786
|
+
var DEFAULT_MODELS_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
787
|
+
var MODEL_CACHE_VERSION = 1;
|
|
240
788
|
var DEFAULT_MAX_OUTPUT_TOKENS = 65536;
|
|
241
789
|
var ZERO_MODEL_COST = {
|
|
242
790
|
input: 0,
|
|
@@ -327,10 +875,10 @@ var MODEL_COSTS = {
|
|
|
327
875
|
function modelCost(id) {
|
|
328
876
|
return MODEL_COSTS[id] ?? ZERO_MODEL_COST;
|
|
329
877
|
}
|
|
330
|
-
function
|
|
331
|
-
return
|
|
878
|
+
function isAnthropicModel(id) {
|
|
879
|
+
return id.startsWith("claude-");
|
|
332
880
|
}
|
|
333
|
-
function
|
|
881
|
+
function isRecord3(value) {
|
|
334
882
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
335
883
|
}
|
|
336
884
|
function stringField(record, key) {
|
|
@@ -348,11 +896,11 @@ function positiveNumberField(record, key) {
|
|
|
348
896
|
return value;
|
|
349
897
|
}
|
|
350
898
|
function commandCodeModelsFromApiResponse(value) {
|
|
351
|
-
if (!
|
|
899
|
+
if (!isRecord3(value)) throw new Error("Expected models response to be an object");
|
|
352
900
|
if (value.object !== "list") throw new Error("Expected models response object to be 'list'");
|
|
353
901
|
if (!Array.isArray(value.data)) throw new Error("Expected models response data to be an array");
|
|
354
902
|
const models = value.data.map((entry) => {
|
|
355
|
-
if (!
|
|
903
|
+
if (!isRecord3(entry)) throw new Error("Expected model entry to be an object");
|
|
356
904
|
return {
|
|
357
905
|
id: stringField(entry, "id"),
|
|
358
906
|
name: stringField(entry, "name"),
|
|
@@ -365,11 +913,11 @@ function commandCodeModelsFromApiResponse(value) {
|
|
|
365
913
|
function outputLimitForModel(id, contextLength) {
|
|
366
914
|
return Math.min(contextLength, DEFAULT_MAX_OUTPUT_TOKENS);
|
|
367
915
|
}
|
|
368
|
-
function toProviderModelMap(models) {
|
|
916
|
+
function toProviderModelMap(models, providerBaseUrl2 = DEFAULT_PROVIDER_API_BASE) {
|
|
369
917
|
const map = {};
|
|
370
918
|
for (const model of models) {
|
|
371
|
-
if (!isOpenAiCompatibleModel(model.id)) continue;
|
|
372
919
|
const cost = modelCost(model.id);
|
|
920
|
+
const anthropic = isAnthropicModel(model.id);
|
|
373
921
|
map[model.id] = {
|
|
374
922
|
name: model.name,
|
|
375
923
|
limit: {
|
|
@@ -381,11 +929,15 @@ function toProviderModelMap(models) {
|
|
|
381
929
|
output: cost.output,
|
|
382
930
|
cache_read: cost.cacheRead,
|
|
383
931
|
cache_write: cost.cacheWrite
|
|
384
|
-
}
|
|
932
|
+
},
|
|
933
|
+
...anthropic ? { reasoning: true, provider: { npm: "@ai-sdk/anthropic", api: providerBaseUrl2 } } : {}
|
|
385
934
|
};
|
|
386
935
|
}
|
|
387
936
|
return map;
|
|
388
937
|
}
|
|
938
|
+
function errorMessage2(error) {
|
|
939
|
+
return error instanceof Error ? error.message : String(error);
|
|
940
|
+
}
|
|
389
941
|
async function runWithTimeout(operation, timeoutMs, externalSignal) {
|
|
390
942
|
const controller = new AbortController();
|
|
391
943
|
let timer;
|
|
@@ -432,10 +984,110 @@ async function fetchCommandCodeModels(options = {}) {
|
|
|
432
984
|
);
|
|
433
985
|
return commandCodeModelsFromApiResponse(body);
|
|
434
986
|
}
|
|
987
|
+
function cacheIsFresh(cachedAt, now, ttlMs) {
|
|
988
|
+
return now - cachedAt < ttlMs;
|
|
989
|
+
}
|
|
990
|
+
function commandCodeModelsFromCache(value) {
|
|
991
|
+
if (!isRecord3(value)) throw new Error("Expected model cache to be an object");
|
|
992
|
+
if (value.version !== MODEL_CACHE_VERSION) {
|
|
993
|
+
throw new Error(`Expected model cache version ${MODEL_CACHE_VERSION}`);
|
|
994
|
+
}
|
|
995
|
+
if (!Array.isArray(value.models)) throw new Error("Expected cached models to be an array");
|
|
996
|
+
const models = value.models.map((entry) => {
|
|
997
|
+
if (!isRecord3(entry)) throw new Error("Expected cached model entry to be an object");
|
|
998
|
+
return {
|
|
999
|
+
id: stringField(entry, "id"),
|
|
1000
|
+
name: stringField(entry, "name"),
|
|
1001
|
+
contextLength: positiveNumberField(entry, "contextLength")
|
|
1002
|
+
};
|
|
1003
|
+
});
|
|
1004
|
+
if (models.length === 0) throw new Error("Expected cached models to be non-empty");
|
|
1005
|
+
return models;
|
|
1006
|
+
}
|
|
1007
|
+
async function readCommandCodeModelsCache(cachePath) {
|
|
1008
|
+
const contents = await readFile2(cachePath, "utf-8");
|
|
1009
|
+
return commandCodeModelsFromCache(JSON.parse(contents));
|
|
1010
|
+
}
|
|
1011
|
+
async function writeCommandCodeModelsCache(cachePath, models, fetchedAt = Date.now()) {
|
|
1012
|
+
await mkdir2(dirname(cachePath), { recursive: true });
|
|
1013
|
+
const temporaryPath = `${cachePath}.${process.pid}.tmp`;
|
|
1014
|
+
try {
|
|
1015
|
+
await writeFile2(
|
|
1016
|
+
temporaryPath,
|
|
1017
|
+
`${JSON.stringify({ version: MODEL_CACHE_VERSION, fetchedAt, models }, null, 2)}
|
|
1018
|
+
`,
|
|
1019
|
+
{ encoding: "utf-8", mode: 384 }
|
|
1020
|
+
);
|
|
1021
|
+
await rename(temporaryPath, cachePath);
|
|
1022
|
+
} finally {
|
|
1023
|
+
try {
|
|
1024
|
+
await rm(temporaryPath, { force: true });
|
|
1025
|
+
} catch {
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
async function loadCommandCodeModels(options) {
|
|
1030
|
+
const cachePath = options.cachePath;
|
|
1031
|
+
const ttlMs = options.ttlMs ?? DEFAULT_MODELS_TTL_MS;
|
|
1032
|
+
const now = options.now ?? Date.now;
|
|
1033
|
+
try {
|
|
1034
|
+
const models = await fetchCommandCodeModels(options);
|
|
1035
|
+
const fetchedAt = now();
|
|
1036
|
+
try {
|
|
1037
|
+
await writeCommandCodeModelsCache(cachePath, models, fetchedAt);
|
|
1038
|
+
return { models, source: "live", fetchedAt };
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
return {
|
|
1041
|
+
models,
|
|
1042
|
+
source: "live",
|
|
1043
|
+
fetchedAt,
|
|
1044
|
+
warning: `Loaded the live Command Code model catalog but could not write the cache at ${cachePath}: ${errorMessage2(error)}`
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
} catch (liveError) {
|
|
1048
|
+
if (options.signal?.aborted) throw liveError;
|
|
1049
|
+
try {
|
|
1050
|
+
const cachedAt = await cachedTimestamp(cachePath);
|
|
1051
|
+
const models = await readCommandCodeModelsCache(cachePath);
|
|
1052
|
+
const stale = cachedAt !== null && !cacheIsFresh(cachedAt, now(), ttlMs);
|
|
1053
|
+
return {
|
|
1054
|
+
models,
|
|
1055
|
+
source: "cache",
|
|
1056
|
+
fetchedAt: cachedAt ?? void 0,
|
|
1057
|
+
warning: stale ? `Could not refresh the Command Code model catalog (${errorMessage2(liveError)}). Using a stale cached catalog from ${cachePath}.` : `Could not refresh the Command Code model catalog (${errorMessage2(liveError)}). Using the cached catalog from ${cachePath}.`
|
|
1058
|
+
};
|
|
1059
|
+
} catch (cacheError) {
|
|
1060
|
+
const fallback = options.fallbackModels ?? [];
|
|
1061
|
+
return {
|
|
1062
|
+
models: fallback,
|
|
1063
|
+
source: "fallback",
|
|
1064
|
+
warning: `Could not refresh the Command Code model catalog (${errorMessage2(liveError)}) and no valid cached catalog is available at ${cachePath} (${errorMessage2(cacheError)}). Using the built-in fallback catalog.`
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function cachedTimestamp(cachePath) {
|
|
1070
|
+
try {
|
|
1071
|
+
const contents = await readFile2(cachePath, "utf-8");
|
|
1072
|
+
const parsed = JSON.parse(contents);
|
|
1073
|
+
if (!isRecord3(parsed)) return null;
|
|
1074
|
+
return typeof parsed.fetchedAt === "number" && Number.isFinite(parsed.fetchedAt) ? parsed.fetchedAt : null;
|
|
1075
|
+
} catch {
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
435
1079
|
|
|
436
1080
|
// src/index.ts
|
|
437
|
-
var
|
|
1081
|
+
var PROVIDER_ID2 = "commandcode";
|
|
1082
|
+
var PLUGIN_VERSION = "0.2.0";
|
|
438
1083
|
var FALLBACK_MODELS = [
|
|
1084
|
+
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", contextLength: 1e6 },
|
|
1085
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", contextLength: 1e6 },
|
|
1086
|
+
{ id: "claude-fable-5", name: "Claude Fable 5", contextLength: 1e6 },
|
|
1087
|
+
{ id: "claude-opus-5", name: "Claude Opus 5", contextLength: 1e6 },
|
|
1088
|
+
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", contextLength: 1e6 },
|
|
1089
|
+
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", contextLength: 1e6 },
|
|
1090
|
+
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", contextLength: 2e5 },
|
|
439
1091
|
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol", contextLength: 105e4 },
|
|
440
1092
|
{ id: "gpt-5.6-terra", name: "GPT-5.6 Terra", contextLength: 105e4 },
|
|
441
1093
|
{ id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextLength: 105e4 },
|
|
@@ -498,33 +1150,63 @@ function zeroDataRetentionHeaders() {
|
|
|
498
1150
|
}
|
|
499
1151
|
return void 0;
|
|
500
1152
|
}
|
|
501
|
-
async function loadProviderModels(modelsUrl, log) {
|
|
502
|
-
try {
|
|
503
|
-
const models = await fetchCommandCodeModels({ url: modelsUrl });
|
|
504
|
-
return toProviderModelMap(models);
|
|
505
|
-
} catch (error) {
|
|
506
|
-
const fallback = toProviderModelMap(FALLBACK_MODELS);
|
|
507
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
508
|
-
log(`Could not fetch the Command Code model catalog (${message}). Using the built-in fallback catalog.`);
|
|
509
|
-
return fallback;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
1153
|
function providerBaseUrl() {
|
|
513
1154
|
const configured = process.env.COMMANDCODE_API_BASE;
|
|
514
1155
|
if (configured && configured.length > 0) return configured;
|
|
515
1156
|
return DEFAULT_PROVIDER_API_BASE;
|
|
516
1157
|
}
|
|
517
|
-
|
|
1158
|
+
function modelsCachePath() {
|
|
1159
|
+
const configured = process.env.COMMANDCODE_MODELS_CACHE;
|
|
1160
|
+
if (configured && configured.length > 0) return configured;
|
|
1161
|
+
return join2(homedir(), ".cache", "opencode", "commandcode-models.json");
|
|
1162
|
+
}
|
|
1163
|
+
async function loadProviderModels(modelsUrl, cachePath, providerBaseUrl2, logWarn, logInfo) {
|
|
1164
|
+
const result = await loadCommandCodeModels({
|
|
1165
|
+
url: modelsUrl,
|
|
1166
|
+
cachePath,
|
|
1167
|
+
fallbackModels: FALLBACK_MODELS,
|
|
1168
|
+
timeoutMs: getModelsTimeoutMs()
|
|
1169
|
+
});
|
|
1170
|
+
if (result.warning) logWarn(result.warning);
|
|
1171
|
+
else logInfo(`Loaded ${result.models.length} Command Code models from ${result.source}`);
|
|
1172
|
+
return {
|
|
1173
|
+
modelMap: toProviderModelMap(result.models, providerBaseUrl2),
|
|
1174
|
+
modelsInfo: {
|
|
1175
|
+
source: result.source,
|
|
1176
|
+
modelCount: result.models.length,
|
|
1177
|
+
fetchedAt: result.fetchedAt
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
function getModelsTimeoutMs() {
|
|
1182
|
+
const raw = process.env.COMMANDCODE_MODELS_TIMEOUT_MS;
|
|
1183
|
+
if (!raw) return DEFAULT_MODELS_TIMEOUT_MS;
|
|
1184
|
+
const parsed = Number(raw);
|
|
1185
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MODELS_TIMEOUT_MS;
|
|
1186
|
+
}
|
|
1187
|
+
var CommandCodePlugin = async ({ client, directory }) => {
|
|
518
1188
|
const apiBase = providerBaseUrl();
|
|
519
1189
|
const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? `${apiBase}/models`;
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
});
|
|
1190
|
+
const cachePath = modelsCachePath();
|
|
1191
|
+
installCommandFiles(directory).catch(() => {
|
|
523
1192
|
});
|
|
1193
|
+
const { modelMap: models, modelsInfo } = await loadProviderModels(
|
|
1194
|
+
modelsUrl,
|
|
1195
|
+
cachePath,
|
|
1196
|
+
apiBase,
|
|
1197
|
+
(message) => {
|
|
1198
|
+
client.app.log({ body: { service: "opencode-commandcode", level: "warn", message } }).catch(() => {
|
|
1199
|
+
});
|
|
1200
|
+
},
|
|
1201
|
+
(message) => {
|
|
1202
|
+
client.app.log({ body: { service: "opencode-commandcode", level: "info", message } }).catch(() => {
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
);
|
|
524
1206
|
return {
|
|
525
1207
|
config: async (cfg) => {
|
|
526
1208
|
cfg.provider ??= {};
|
|
527
|
-
cfg.provider[
|
|
1209
|
+
cfg.provider[PROVIDER_ID2] = {
|
|
528
1210
|
npm: "@ai-sdk/openai-compatible",
|
|
529
1211
|
name: "Command Code",
|
|
530
1212
|
env: ["COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"],
|
|
@@ -534,8 +1216,34 @@ var CommandCodePlugin = async ({ client }) => {
|
|
|
534
1216
|
},
|
|
535
1217
|
models
|
|
536
1218
|
};
|
|
1219
|
+
cfg.command ??= {};
|
|
1220
|
+
cfg.command["commandcode-quota"] = {
|
|
1221
|
+
description: "Show Command Code account usage and quota (credits, plan, usage)",
|
|
1222
|
+
template: "Use the commandcode_quota tool to fetch and report the user's Command Code account usage and quota. Summarize credits remaining and used, the plan, billing-period usage (cost, requests, tokens), and the 5-hour and weekly usage windows."
|
|
1223
|
+
};
|
|
1224
|
+
cfg.command["commandcode-status"] = {
|
|
1225
|
+
description: "Show Command Code plugin diagnostics (catalog source, model count, endpoint)",
|
|
1226
|
+
template: "Use the commandcode_status tool to fetch and report the Command Code plugin diagnostics: catalog source (live/cache/fallback), model count, last fetch time, API endpoint, zero-data-retention setting, and plugin version."
|
|
1227
|
+
};
|
|
1228
|
+
client.app.log({
|
|
1229
|
+
body: {
|
|
1230
|
+
service: "opencode-commandcode",
|
|
1231
|
+
level: "info",
|
|
1232
|
+
message: `Registered Command Code provider with ${Object.keys(models).length} models (source: ${modelsInfo.source})`
|
|
1233
|
+
}
|
|
1234
|
+
}).catch(() => {
|
|
1235
|
+
});
|
|
537
1236
|
},
|
|
538
|
-
auth: createAuthHook()
|
|
1237
|
+
auth: createAuthHook(),
|
|
1238
|
+
tool: {
|
|
1239
|
+
...createQuotaTool({ apiBase, extraHeaders: zeroDataRetentionHeaders(), client }),
|
|
1240
|
+
...createStatusTool({
|
|
1241
|
+
apiBase,
|
|
1242
|
+
zdr: zeroDataRetentionHeaders() !== void 0,
|
|
1243
|
+
version: PLUGIN_VERSION,
|
|
1244
|
+
modelsInfo
|
|
1245
|
+
})
|
|
1246
|
+
}
|
|
539
1247
|
};
|
|
540
1248
|
};
|
|
541
1249
|
var index_default = CommandCodePlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gururea/opencode-commandcode-provider",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "OpenCode plugin that adds the Command Code (commandcode.ai) provider and its authentication to the interface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist",
|
|
16
|
+
"command",
|
|
16
17
|
"README.md",
|
|
17
18
|
"LICENSE"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
|
-
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist",
|
|
21
|
+
"build": "tsup src/index.ts --format esm --dts --clean --out-dir dist --external @opencode-ai/plugin --external zod",
|
|
21
22
|
"test": "node --import tsx --test tests/*.test.ts",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
23
24
|
"prepare": "npm run build"
|