pi-provider-qoder-cn 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/README.md +23 -8
  2. package/dist/index.js +250 -46
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,14 +64,18 @@ export QODERCN_PERSONAL_ACCESS_TOKEN="<your PAT>"
64
64
 
65
65
  ### `/qoder-cn.usage`
66
66
 
67
- Shows the plan allowance and any purchased add-on credits.
67
+ Shows the plan allowance and any purchased add-on credits as one aligned grid.
68
+ In a terminal the gauge is coloured by how much of the allowance is gone —
69
+ green under 60%, yellow to 85%, red above that, and bold red once the quota is
70
+ exceeded.
68
71
 
69
72
  ```text
70
73
  Qoder CN Plan (personal_standard)
71
- [--------------------] 0% used
72
- [--------------------] Add-on quota: 0 credits / 700 credits used (0%) · 700 credits left
73
- Resets: never
74
- Manage: https://qoder.com.cn/account/usage
74
+ Overall [███░░░░░░░░░░░░░░░░░░] 12.5% used
75
+ Plan quota [███░░░░░░░░░░░░░░░░░░] 250 / 2000 credits 12.5% 1750 left
76
+ Add-on [░░░░░░░░░░░░░░░░░░░░░] 0 / 700 credits 0% 700 left
77
+ Resets never
78
+ Manage https://qoder.com.cn/account/usage
75
79
  ```
76
80
 
77
81
  Append `json` to print the untouched API payload — useful when a field you need
@@ -81,13 +85,24 @@ is not in the formatted view:
81
85
  /qoder-cn.usage json
82
86
  ```
83
87
 
88
+ Append `plain` for the same grid without the colour codes, e.g. when pasting it
89
+ into an issue:
90
+
91
+ ```text
92
+ /qoder-cn.usage plain
93
+ ```
94
+
84
95
  Notes:
85
96
 
86
- - The plan allowance (`Plan quota`) and top-up credits (`Add-on quota`) are
97
+ - The plan allowance (`Plan quota`) and top-up credits (`Add-on`) are
87
98
  independent buckets with independent expiry, so both are listed when present.
88
- - `Resets: never` means the API reported the year-9999 sentinel, i.e. the
99
+ Columns are sized to the rows that came back, so a single-bucket plan does
100
+ not reserve space for one it does not have.
101
+ - Colour is suppressed automatically for a `NO_COLOR` environment, a dumb
102
+ terminal, and any non-TTY output that is not the interactive UI.
103
+ - `Resets never` means the API reported the year-9999 sentinel, i.e. the
89
104
  allowance does not reset on a schedule.
90
- - `(plan quota is prorated)` appears after a mid-cycle plan change.
105
+ - `Note plan quota is prorated` appears after a mid-cycle plan change.
91
106
  - The command uses the same stored token as chat requests, refreshing it first,
92
107
  so it reports the same quota the model is actually billed against.
93
108
 
package/dist/index.js CHANGED
@@ -1719,11 +1719,37 @@ To reset: /qoder-endpoint default`,
1719
1719
 
1720
1720
  // src/commands/usage.ts
1721
1721
  init_region();
1722
- function usageBar(percentage, width = 20) {
1723
- if (typeof percentage !== "number" || !Number.isFinite(percentage)) return `[${"?".repeat(width)}]`;
1724
- const ratio = Math.max(0, Math.min(1, percentage / 100));
1725
- const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
1726
- return `[${"#".repeat(filled)}${"-".repeat(width - filled)}]`;
1722
+ var ANSI = {
1723
+ reset: "\x1B[0m",
1724
+ bold: "\x1B[1m",
1725
+ dim: "\x1B[2m",
1726
+ red: "\x1B[31m",
1727
+ green: "\x1B[32m",
1728
+ yellow: "\x1B[33m"
1729
+ };
1730
+ var FILLED_CHAR = "\u2588";
1731
+ var EMPTY_CHAR = "\u2591";
1732
+ var UNKNOWN_CHAR = "?";
1733
+ var BAR_WIDTH = 20;
1734
+ var COLUMN_GAP = " ";
1735
+ function paint(text, code) {
1736
+ return code && text ? `${code}${text}${ANSI.reset}` : text;
1737
+ }
1738
+ function usageColor(percentage, danger = false) {
1739
+ if (danger) return `${ANSI.red}${ANSI.bold}`;
1740
+ if (typeof percentage !== "number" || !Number.isFinite(percentage)) return void 0;
1741
+ if (percentage >= 85) return `${ANSI.red}${ANSI.bold}`;
1742
+ if (percentage >= 60) return ANSI.yellow;
1743
+ return ANSI.green;
1744
+ }
1745
+ function usageBar(percentage, options = {}) {
1746
+ const width = options.width ?? BAR_WIDTH;
1747
+ const known = typeof percentage === "number" && Number.isFinite(percentage);
1748
+ const filled = known ? Math.max(0, Math.min(width, Math.round(percentage / 100 * width))) : 0;
1749
+ const fill = known ? FILLED_CHAR.repeat(filled) : "";
1750
+ const rest = known ? EMPTY_CHAR.repeat(width - filled) : UNKNOWN_CHAR.repeat(width);
1751
+ if (!options.color) return `[${fill}${rest}]`;
1752
+ return paint("[", ANSI.dim) + paint(fill, usageColor(percentage, options.danger)) + paint(rest, ANSI.dim) + paint("]", ANSI.dim);
1727
1753
  }
1728
1754
  function formatAmount(value, unit) {
1729
1755
  if (typeof value !== "number" || !Number.isFinite(value)) return "?";
@@ -1748,41 +1774,82 @@ function formatResetTime(expiresAt, now = Date.now()) {
1748
1774
  const relative = days > 0 ? `${days}d ${hours}h` : hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
1749
1775
  return `${new Date(timestamp).toISOString().slice(0, 10)} (in ${relative})`;
1750
1776
  }
1751
- function formatBucketLine(label, bucket) {
1752
- const unit = bucket?.unit || "credits";
1777
+ function isBucketRow(row) {
1778
+ return typeof row.used === "number" || typeof row.total === "number";
1779
+ }
1780
+ function rightCell(text, width, code) {
1781
+ return " ".repeat(Math.max(0, width - text.length)) + paint(text, code);
1782
+ }
1783
+ function renderUsageRows(rows, notes, color) {
1784
+ const buckets = rows.filter(isBucketRow);
1785
+ const usedWidth = Math.max(0, ...buckets.map((row) => formatAmount(row.used).length));
1786
+ const totalWidth = Math.max(0, ...buckets.map((row) => formatAmount(row.total).length));
1787
+ const unitWidth = Math.max(0, ...buckets.map((row) => (row.unit ?? "").length));
1788
+ const remainWidth = Math.max(0, ...buckets.map((row) => formatAmount(row.remaining).length));
1789
+ const labelWidth = Math.max(0, ...rows.map((row) => row.label.length), ...notes.map((note) => note.label.length));
1790
+ const percentWidth = Math.max(0, ...rows.map((row) => row.percent === void 0 ? 1 : `${row.percent}%`.length));
1791
+ const amountWidth = buckets.length > 0 ? usedWidth + 3 + totalWidth + 1 + unitWidth : 0;
1792
+ const remainder = (row) => isBucketRow(row) ? `${formatAmount(row.remaining).padStart(remainWidth)} left` : row.tail ?? "";
1793
+ const remainderWidth = Math.max(0, ...rows.map((row) => remainder(row).length));
1794
+ const lines = rows.map((row) => {
1795
+ const amount = isBucketRow(row) ? `${formatAmount(row.used).padStart(usedWidth)} / ${formatAmount(row.total).padStart(totalWidth)} ${(row.unit ?? "").padEnd(unitWidth)}` : " ".repeat(amountWidth);
1796
+ const percent = row.percent === void 0 ? UNKNOWN_CHAR : `${row.percent}%`;
1797
+ return [
1798
+ row.label.padEnd(labelWidth),
1799
+ usageBar(row.percent, { color, danger: row.danger }),
1800
+ amount,
1801
+ rightCell(percent, percentWidth, color ? usageColor(row.percent, row.danger) : void 0),
1802
+ remainder(row).padEnd(remainderWidth)
1803
+ ].join(COLUMN_GAP);
1804
+ });
1805
+ for (const note of notes) {
1806
+ lines.push(`${note.label.padEnd(labelWidth)}${COLUMN_GAP}${color ? paint(note.value, note.color) : note.value}`);
1807
+ }
1808
+ return lines.map((line) => line.replace(/ +$/, ""));
1809
+ }
1810
+ function bucketRow(label, bucket, danger) {
1753
1811
  const rawPercent = bucket?.percentage;
1754
1812
  const derivedPercent = typeof bucket?.used === "number" && typeof bucket?.total === "number" && bucket.total > 0 ? bucket.used / bucket.total * 100 : void 0;
1755
1813
  const percent = formatPercent(
1756
1814
  typeof rawPercent === "number" && Number.isFinite(rawPercent) ? rawPercent : derivedPercent
1757
1815
  );
1758
- const percentText = percent === void 0 ? "?" : `${percent}%`;
1759
- return ` ${usageBar(percent)} ${label}: ${formatAmount(bucket?.used, unit)} / ${formatAmount(
1760
- bucket?.total,
1761
- unit
1762
- )} used (${percentText}) \xB7 ${formatAmount(bucket?.remaining, unit)} left`;
1816
+ return {
1817
+ label,
1818
+ percent,
1819
+ used: bucket?.used,
1820
+ total: bucket?.total,
1821
+ remaining: bucket?.remaining,
1822
+ unit: bucket?.unit || "credits",
1823
+ danger
1824
+ };
1763
1825
  }
1764
1826
  function hasBucket(bucket) {
1765
1827
  if (!bucket) return false;
1766
1828
  return (bucket.total ?? 0) > 0 || (bucket.used ?? 0) > 0;
1767
1829
  }
1768
- function formatQoderUsage(raw, mode, now = Date.now()) {
1830
+ function formatQoderUsage(raw, mode, now = Date.now(), options = {}) {
1831
+ const color = options.color ?? false;
1769
1832
  const region = getQoderRegionConfig(mode);
1770
- const lines = [];
1833
+ const danger = Boolean(raw?.isQuotaExceeded);
1771
1834
  const userType = raw?.userType ? ` (${raw.userType})` : "";
1772
1835
  const overallPercent = formatPercent(raw?.totalUsagePercentage);
1773
- lines.push(`${region.usageTitle}${userType}`);
1774
- lines.push(`${usageBar(overallPercent)} ${overallPercent === void 0 ? "?" : `${overallPercent}%`} used`);
1775
- if (raw?.isQuotaExceeded) lines.push(" ! quota exceeded");
1836
+ const rows = [{ label: "Overall", percent: overallPercent, tail: "used", danger }];
1776
1837
  const showUserQuota = hasBucket(raw?.userQuota);
1777
1838
  const showAddOnQuota = hasBucket(raw?.addOnQuota);
1778
- if (showUserQuota) lines.push(formatBucketLine("Plan quota", raw.userQuota));
1779
- if (showAddOnQuota) lines.push(formatBucketLine("Add-on quota", raw.addOnQuota));
1780
- if (!showUserQuota && !showAddOnQuota) lines.push(" No quota buckets returned.");
1781
- if (raw?.isPlanQuotaProrated) lines.push(" (plan quota is prorated)");
1782
- lines.push(`Resets: ${formatResetTime(raw?.expiresAt, now)}`);
1839
+ if (showUserQuota) rows.push(bucketRow("Plan quota", raw.userQuota, danger));
1840
+ if (showAddOnQuota) rows.push(bucketRow("Add-on", raw.addOnQuota, danger));
1841
+ const notes = [];
1842
+ if (danger) notes.push({ label: "Status", value: "quota exceeded", color: ANSI.red });
1843
+ if (!showUserQuota && !showAddOnQuota) notes.push({ label: "Buckets", value: "none returned" });
1844
+ if (raw?.isPlanQuotaProrated) notes.push({ label: "Note", value: "plan quota is prorated" });
1845
+ notes.push({ label: "Resets", value: formatResetTime(raw?.expiresAt, now) });
1783
1846
  const manageUrl = raw?.addOnQuota?.detailUrl || raw?.upgradeUrl || region.manageUrl;
1784
- if (manageUrl) lines.push(`Manage: ${manageUrl}`);
1785
- return { title: region.usageTitle, lines };
1847
+ if (manageUrl) notes.push({ label: "Manage", value: manageUrl });
1848
+ const title = `${region.usageTitle}${userType}`;
1849
+ return {
1850
+ title: region.usageTitle,
1851
+ lines: [paint(title, color ? ANSI.bold : void 0), ...renderUsageRows(rows, notes, color)]
1852
+ };
1786
1853
  }
1787
1854
  async function fetchQoderQuota(accessToken, mode) {
1788
1855
  const response = await fetch(getQoderUsageURL(mode), {
@@ -1819,6 +1886,14 @@ async function resolveAccessToken(providerID, ctx) {
1819
1886
  return getCachedCredentials2("", providerID)?.access || void 0;
1820
1887
  }
1821
1888
  var FORMAT_ARGS = /* @__PURE__ */ new Set(["json", "raw", "debug"]);
1889
+ var PLAIN_ARGS = /* @__PURE__ */ new Set(["plain", "no-color", "nocolor"]);
1890
+ function shouldColorize(args, options = {}) {
1891
+ if (PLAIN_ARGS.has((args || "").trim().toLowerCase())) return false;
1892
+ const env = options.env ?? process.env;
1893
+ if (env.NO_COLOR || env.TERM === "dumb" || env.FORCE_COLOR === "0") return false;
1894
+ if (options.hasUI) return true;
1895
+ return options.isTTY ?? Boolean(process.stdout?.isTTY);
1896
+ }
1822
1897
  async function runUsageCommand(mode, args, ctx) {
1823
1898
  const region = getQoderRegionConfig(mode);
1824
1899
  const providerID = region.providerID;
@@ -1830,7 +1905,8 @@ async function runUsageCommand(mode, args, ctx) {
1830
1905
  return;
1831
1906
  }
1832
1907
  const raw = await fetchQoderQuota(accessToken, mode);
1833
- const output = wantsRaw ? JSON.stringify(raw, null, 2) : formatQoderUsage(raw, mode).lines.join("\n");
1908
+ const color = !wantsRaw && shouldColorize(args, { hasUI: Boolean(ctx?.ui) });
1909
+ const output = wantsRaw ? JSON.stringify(raw, null, 2) : formatQoderUsage(raw, mode, Date.now(), { color }).lines.join("\n");
1834
1910
  ctx?.ui?.notify(output, "info");
1835
1911
  if (!ctx?.ui) console.log(output);
1836
1912
  } catch (error) {
@@ -1880,6 +1956,68 @@ function qoderEncodeBody(plaintext) {
1880
1956
  return out;
1881
1957
  }
1882
1958
 
1959
+ // src/protocol/queue.ts
1960
+ var MAX_QUEUE_RETRIES = 3;
1961
+ var MAX_WAIT_MS = 12e4;
1962
+ var DEFAULT_RETRY_SECONDS = 30;
1963
+ function digForQueue(node, depth) {
1964
+ if (depth > 6) return null;
1965
+ if (typeof node === "string") {
1966
+ const trimmed = node.trim();
1967
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return null;
1968
+ try {
1969
+ return digForQueue(JSON.parse(trimmed), depth + 1);
1970
+ } catch {
1971
+ return null;
1972
+ }
1973
+ }
1974
+ if (node === null || typeof node !== "object") return null;
1975
+ if (Array.isArray(node)) {
1976
+ for (const item of node) {
1977
+ const found = digForQueue(item, depth + 1);
1978
+ if (found) return found;
1979
+ }
1980
+ return null;
1981
+ }
1982
+ const record = node;
1983
+ if (record.isQueued === true) return record;
1984
+ for (const value of Object.values(record)) {
1985
+ const found = digForQueue(value, depth + 1);
1986
+ if (found) return found;
1987
+ }
1988
+ return null;
1989
+ }
1990
+ function positiveNumber(value, fallback) {
1991
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
1992
+ }
1993
+ function parseQueueNotice(envelope) {
1994
+ const payload = digForQueue(envelope, 0);
1995
+ if (!payload) return null;
1996
+ return {
1997
+ retryAfterMs: Math.min(positiveNumber(payload.retryAfterSeconds, DEFAULT_RETRY_SECONDS) * 1e3, MAX_WAIT_MS),
1998
+ waitTimeSeconds: positiveNumber(payload.waitTime, 0),
1999
+ queueCount: positiveNumber(payload.queueCount, 0)
2000
+ };
2001
+ }
2002
+ function sleep(ms, signal) {
2003
+ return new Promise((resolve, reject) => {
2004
+ if (signal?.aborted) {
2005
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2006
+ return;
2007
+ }
2008
+ let timer;
2009
+ const onAbort = () => {
2010
+ clearTimeout(timer);
2011
+ reject(new Error("Aborted while waiting for the Qoder queue."));
2012
+ };
2013
+ timer = setTimeout(() => {
2014
+ signal?.removeEventListener("abort", onAbort);
2015
+ resolve();
2016
+ }, ms);
2017
+ signal?.addEventListener("abort", onAbort, { once: true });
2018
+ });
2019
+ }
2020
+
1883
2021
  // src/protocol/thinking.ts
1884
2022
  var THINKING_TAG_VARIANTS = [
1885
2023
  { open: "<thinking>", close: "</thinking>" },
@@ -2270,6 +2408,49 @@ function contentToText(content) {
2270
2408
  }
2271
2409
  return "";
2272
2410
  }
2411
+ var PeekableReader = class {
2412
+ constructor(inner) {
2413
+ this.inner = inner;
2414
+ }
2415
+ inner;
2416
+ pending = [];
2417
+ text = "";
2418
+ /** Read one chunk, replaying peeked bytes first.
2419
+ */
2420
+ async read() {
2421
+ const buffered = this.pending.shift();
2422
+ if (buffered) return { done: false, value: buffered };
2423
+ return await this.inner.read();
2424
+ }
2425
+ /**
2426
+ * Buffer chunks until the first complete line is available, and return the
2427
+ * text seen so far. Every byte read here is replayed by later `read()` calls.
2428
+ */
2429
+ async peekLine(decoder) {
2430
+ while (!this.text.includes("\n")) {
2431
+ const { done, value } = await this.inner.read();
2432
+ if (done) break;
2433
+ this.pending.push(value);
2434
+ this.text += decoder.decode(value, { stream: true });
2435
+ }
2436
+ return this.text;
2437
+ }
2438
+ cancel(reason) {
2439
+ return this.inner.cancel(reason);
2440
+ }
2441
+ };
2442
+ async function peekQueueNotice(reader, decoder) {
2443
+ const text = await reader.peekLine(decoder);
2444
+ const firstLine = text.split("\n")[0]?.trim() ?? "";
2445
+ if (!firstLine.startsWith("data:")) return null;
2446
+ const payload = firstLine.slice(5).trim();
2447
+ if (!payload || payload === "[DONE]") return null;
2448
+ try {
2449
+ return parseQueueNotice(JSON.parse(payload));
2450
+ } catch {
2451
+ return null;
2452
+ }
2453
+ }
2273
2454
  function resolveRequestContext(piAi, context) {
2274
2455
  const {
2275
2456
  collapseSystemMessages,
@@ -2431,29 +2612,52 @@ function streamQoder(model, context, options) {
2431
2612
  machineID
2432
2613
  });
2433
2614
  const modelSource = modelConfig.source || "system";
2434
- const response = await fetch(chatURL, {
2435
- method: "POST",
2436
- headers: {
2437
- "Content-Type": "application/json",
2438
- Accept: "text/event-stream",
2439
- "Cache-Control": "no-cache",
2440
- "Accept-Encoding": "identity",
2441
- "X-Model-Key": qoderModel,
2442
- "X-Model-Source": modelSource,
2443
- ...headers
2444
- },
2445
- body: encodedBytes,
2446
- signal: options?.signal
2447
- });
2448
- if (!response.ok) {
2449
- const errText = await response.text();
2450
- throw new Error(`Qoder API request failed: ${response.status} ${response.statusText}. Response: ${errText}`);
2451
- }
2452
- const reader = response.body?.getReader();
2453
- if (!reader) throw new Error("No response body");
2454
- const decoder = new TextDecoder();
2615
+ let queuedAttempt = 0;
2616
+ let reader;
2617
+ let decoder;
2455
2618
  let buffer = "";
2456
2619
  let bufferStart = 0;
2620
+ for (; ; ) {
2621
+ const response = await fetch(chatURL, {
2622
+ method: "POST",
2623
+ headers: {
2624
+ "Content-Type": "application/json",
2625
+ Accept: "text/event-stream",
2626
+ "Cache-Control": "no-cache",
2627
+ "Accept-Encoding": "identity",
2628
+ "X-Model-Key": qoderModel,
2629
+ "X-Model-Source": modelSource,
2630
+ ...headers
2631
+ },
2632
+ body: encodedBytes,
2633
+ signal: options?.signal
2634
+ });
2635
+ if (!response.ok) {
2636
+ const errText = await response.text();
2637
+ throw new Error(`Qoder API request failed: ${response.status} ${response.statusText}. Response: ${errText}`);
2638
+ }
2639
+ const body = response.body;
2640
+ if (!body) throw new Error("No response body");
2641
+ reader = new PeekableReader(body.getReader());
2642
+ decoder = new TextDecoder();
2643
+ buffer = "";
2644
+ bufferStart = 0;
2645
+ const queue = await peekQueueNotice(reader, decoder);
2646
+ if (queue) {
2647
+ await reader.cancel().catch(() => {
2648
+ });
2649
+ if (queuedAttempt >= MAX_QUEUE_RETRIES) {
2650
+ throw new Error(
2651
+ `Qoder is at capacity: the request stayed queued after ${queuedAttempt + 1} attempts (~${queue.waitTimeSeconds}s wait reported). Try again shortly.`
2652
+ );
2653
+ }
2654
+ queuedAttempt++;
2655
+ await sleep(queue.retryAfterMs, options?.signal);
2656
+ continue;
2657
+ }
2658
+ break;
2659
+ }
2660
+ if (!reader || !decoder) throw new Error("No response body");
2457
2661
  let contentBlockIndex = -1;
2458
2662
  let thinkingBlockIndex = -1;
2459
2663
  let rawReasoningTail = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-provider-qoder-cn",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Pi extension for Qoder CN — PAT/OAuth auth, /qoder-cn.usage quota command, and enterprise VPC endpoint support",
5
5
  "type": "module",
6
6
  "license": "MIT",