clixad 0.0.1-beta.19 → 0.0.1-beta.20

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 (2) hide show
  1. package/dist/clixad.mjs +155 -16
  2. package/package.json +1 -1
package/dist/clixad.mjs CHANGED
@@ -56,6 +56,68 @@ function edgeBlockMessage(block, outcome) {
56
56
  Request id (for support): ${block.rayId}` : "";
57
57
  return head + tail + id;
58
58
  }
59
+ function isOutageStatus(status) {
60
+ return OUTAGE_STATUSES.has(status);
61
+ }
62
+ function detectEdgeOutage(status, headers, body) {
63
+ if (!OUTAGE_STATUSES.has(status)) return void 0;
64
+ if (isGatewayError(body)) return void 0;
65
+ const kind = status === 502 || status === 504 ? "interrupted" : "unreachable";
66
+ const via = headers.get("server") ?? void 0;
67
+ const ray = rayId(headers, body);
68
+ const wait = retryAfterMs(headers);
69
+ return {
70
+ status,
71
+ kind,
72
+ ...ray ? { rayId: ray } : {},
73
+ ...via ? { via } : {},
74
+ ...wait !== void 0 ? { retryAfterMs: wait } : {}
75
+ };
76
+ }
77
+ function retryAfterMs(headers) {
78
+ const raw = headers.get("retry-after");
79
+ if (!raw) return void 0;
80
+ const seconds = Number(raw.trim());
81
+ if (!Number.isFinite(seconds) || seconds < 0) return void 0;
82
+ return Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS);
83
+ }
84
+ function outageBackoffMs(attempt) {
85
+ return 1e3 * 2 ** attempt;
86
+ }
87
+ function outageWaitMs(attempt, outage) {
88
+ return Math.max(outage.retryAfterMs ?? 0, outageBackoffMs(attempt));
89
+ }
90
+ function outageWaitNotice(outage) {
91
+ return ` The gateway did not answer that request (${edgeOutageSummary(outage)}).
92
+ Nothing reached Clixad and nothing was charged. Waiting for it to come
93
+ back and resending \u2014 esc stops.`;
94
+ }
95
+ function edgeOutageSummary(outage) {
96
+ const parts = [`${outage.status} from ${outage.via ?? "an intermediary"}`];
97
+ if (outage.rayId) parts.push(`ray ${outage.rayId}`);
98
+ return parts.join(", ");
99
+ }
100
+ function edgeOutageMessage(outage, resends, healthUrl) {
101
+ if (outage.kind === "interrupted") {
102
+ return `Something in front of the gateway ended that request (${outage.status}) before an
103
+ answer came back. Unlike a 503 it may have reached Clixad first, so the turn
104
+ may have been generated and charged even though nothing arrived \u2014 that is why
105
+ it was not resent automatically.
106
+ Check /wallet, then send the message again. The conversation is intact.` + raySuffix(outage);
107
+ }
108
+ const tried = resends.count > 0 ? `
109
+ It was resent ${resends.count} time${resends.count === 1 ? "" : "s"} over about ${Math.round(resends.waitedMs / 1e3)}s and answered the same way.` : "";
110
+ const check = healthUrl ? `
111
+ ${healthUrl} says whether it is up.` : "";
112
+ return `The gateway did not answer that request (${outage.status}), so it never reached
113
+ Clixad. Nothing was sent to a model and nothing was charged.${tried}
114
+ This is the gateway being restarted or unreachable, not anything about the
115
+ conversation \u2014 so do not /clear it. Send the message again in a minute.${check}` + raySuffix(outage);
116
+ }
117
+ function raySuffix(outage) {
118
+ return outage.rayId ? `
119
+ Request id (for support): ${outage.rayId}` : "";
120
+ }
59
121
  function safeDetail(body, limit = DETAIL_LIMIT) {
60
122
  if (!body) return "";
61
123
  const title = /<title[^>]*>([^<]*)<\/title>/i.exec(body)?.[1];
@@ -68,10 +130,23 @@ async function peek(res) {
68
130
  const body = await res.text().catch(() => "");
69
131
  return { body, res: new Response(body, { status: res.status, headers: res.headers }) };
70
132
  }
71
- var EdgeBlockedError, DETAIL_LIMIT;
133
+ var OUTAGE_STATUSES, MAX_RETRY_AFTER_MS, OUTAGE_RETRIES, EdgeOutageError, EdgeBlockedError, DETAIL_LIMIT;
72
134
  var init_edge = __esm({
73
135
  "src/edge.ts"() {
74
136
  "use strict";
137
+ OUTAGE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
138
+ MAX_RETRY_AFTER_MS = 3e4;
139
+ OUTAGE_RETRIES = 5;
140
+ EdgeOutageError = class extends Error {
141
+ constructor(outage, resends, healthUrl) {
142
+ super(edgeOutageMessage(outage, resends, healthUrl));
143
+ this.outage = outage;
144
+ this.resends = resends;
145
+ this.name = "EdgeOutageError";
146
+ }
147
+ outage;
148
+ resends;
149
+ };
75
150
  EdgeBlockedError = class extends Error {
76
151
  constructor(block, outcome) {
77
152
  super(edgeBlockMessage(block, outcome));
@@ -207,6 +282,22 @@ async function httpFailure(res, what) {
207
282
  const detail = safeDetail(await res.text().catch(() => ""));
208
283
  return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
209
284
  }
285
+ function sleep(ms, signal) {
286
+ return new Promise((resolve2) => {
287
+ if (!signal) {
288
+ setTimeout(resolve2, ms);
289
+ return;
290
+ }
291
+ if (signal.aborted) return resolve2();
292
+ const done = () => {
293
+ clearTimeout(timer);
294
+ signal.removeEventListener("abort", done);
295
+ resolve2();
296
+ };
297
+ const timer = setTimeout(done, ms);
298
+ signal.addEventListener("abort", done, { once: true });
299
+ });
300
+ }
210
301
  function freeMeta(meta) {
211
302
  if (meta.free !== true) return {};
212
303
  return {
@@ -719,7 +810,50 @@ var init_client = __esm({
719
810
  });
720
811
  }
721
812
  /**
722
- * POST a chat request, and get past the edge if the edge is in the way.
813
+ * POST a chat request, and keep asking while the answer is the edge's rather
814
+ * than the gateway's.
815
+ *
816
+ * Two failures live in front of this route and they are opposites, which is
817
+ * why they are two layers rather than one. The WAF's 403 is *deterministic* —
818
+ * the same bytes are blocked identically forever — so `postPastTheFilter`
819
+ * below changes the bytes exactly once and never merely asks again. An
820
+ * outage is the reverse: nothing about the request caused it, so nothing
821
+ * about the request can avoid it, and asking again is the only move there is.
822
+ *
823
+ * Which outages may be asked again is `EdgeOutage.kind`'s decision, not this
824
+ * loop's: `unreachable` never reached the gateway, `interrupted` may have
825
+ * been generated and billed into a connection that had already gone.
826
+ *
827
+ * The two notices are the price of a locked keyboard. A turn that sits silent
828
+ * for half a minute is indistinguishable from a hung one, so the wait is
829
+ * announced before it starts and its end is announced when the answer finally
830
+ * comes from the gateway.
831
+ */
832
+ async postChat(payload, signal) {
833
+ const resends = { count: 0, waitedMs: 0 };
834
+ for (; ; ) {
835
+ const res = await this.postPastTheFilter(payload, signal);
836
+ if (!isOutageStatus(res.status)) {
837
+ if (resends.count > 0) {
838
+ this.onNotice?.(" The gateway is answering again \u2014 the request went through.");
839
+ }
840
+ return res;
841
+ }
842
+ const peeked = await peek(res);
843
+ const outage = detectEdgeOutage(res.status, res.headers, peeked.body);
844
+ if (!outage) return peeked.res;
845
+ const health = `${this.config.gatewayUrl}/healthz`;
846
+ if (outage.kind === "interrupted") throw new EdgeOutageError(outage, resends, health);
847
+ if (resends.count >= OUTAGE_RETRIES) throw new EdgeOutageError(outage, resends, health);
848
+ if (resends.count === 0) this.onNotice?.(outageWaitNotice(outage));
849
+ const wait = outageWaitMs(resends.count, outage);
850
+ await sleep(wait, signal);
851
+ resends.count++;
852
+ resends.waitedMs += wait;
853
+ }
854
+ }
855
+ /**
856
+ * One POST, past the security filter if the filter is in the way.
723
857
  *
724
858
  * Plain JSON first, always, on every fresh process. That is the OpenAI-compatible
725
859
  * contract this route serves — Kimi CLI's kosong client speaks it, and so does
@@ -731,15 +865,19 @@ var init_client = __esm({
731
865
  * The retry is not a general "try again": a 403 from a WAF is deterministic,
732
866
  * and resending the identical bytes would be blocked identically. It is the
733
867
  * *same* request in a form the filter has nothing to match on, which is why it
734
- * is worth exactly one attempt and no more.
868
+ * is worth exactly one attempt and no more. General "try again" is `postChat`
869
+ * above, around this, for the failure where it is the right answer.
735
870
  *
736
- * Three outcomes, and each has to be distinguishable or the user is left
871
+ * Four outcomes, and each has to be distinguishable or the user is left
737
872
  * guessing: the retry works (stick with it, say so once), the gateway answers
738
873
  * 415 because it predates this (say that — the fix is a deploy, not a
739
- * `/clear`), or it is blocked again (say that too, and say that clearing the
740
- * history is the way out, because the history is what keeps triggering it).
874
+ * `/clear`), it is blocked again (say that too, and say that clearing the
875
+ * history is the way out, because the history is what keeps triggering it), or
876
+ * the edge answers instead of the gateway — in which case nothing is claimed
877
+ * and nothing is remembered, because a 503 from a router says nothing at all
878
+ * about whether the filter would have let that body through.
741
879
  */
742
- async postChat(payload, signal) {
880
+ async postPastTheFilter(payload, signal) {
743
881
  const already = this.chatTransport === "encoded";
744
882
  const first = await this.sendChat(payload, already, signal);
745
883
  if (first.status !== 403) return first;
@@ -753,6 +891,7 @@ var init_client = __esm({
753
891
  if (second.block) throw new EdgeBlockedError(second.block, "retried");
754
892
  return second.res;
755
893
  }
894
+ if (isOutageStatus(retry.status)) return retry;
756
895
  this.chatTransport = "encoded";
757
896
  this.onNotice?.(
758
897
  ` A security filter in front of the gateway blocked that request (${edgeBlockSummary(block)}).
@@ -1063,7 +1202,7 @@ async function githubLogin(client, config, start, invite, report, signal) {
1063
1202
  const deadline = Date.now() + start.expires_in * 1e3;
1064
1203
  let interval = Math.max(start.interval, 1);
1065
1204
  while (Date.now() < deadline) {
1066
- await sleep(interval * 1e3, signal);
1205
+ await sleep2(interval * 1e3, signal);
1067
1206
  if (signal?.aborted) return { status: "aborted" };
1068
1207
  report.tick?.();
1069
1208
  const poll = await client.devicePoll(start.session, invite);
@@ -1114,7 +1253,7 @@ async function devLogin(client, config, email, invite) {
1114
1253
  saveConfig(config);
1115
1254
  return { status: "ok", account: { email: res.email, balance: res.balance, created: true } };
1116
1255
  }
1117
- function sleep(ms, signal) {
1256
+ function sleep2(ms, signal) {
1118
1257
  return new Promise((resolve2) => {
1119
1258
  if (!signal) {
1120
1259
  setTimeout(resolve2, ms);
@@ -2659,7 +2798,7 @@ function isBinary(bytes) {
2659
2798
  }
2660
2799
  return head.length > 0 && control / head.length > 0.15;
2661
2800
  }
2662
- function sleep2(ms, signal) {
2801
+ function sleep3(ms, signal) {
2663
2802
  return new Promise((done) => {
2664
2803
  if (signal?.aborted) return done();
2665
2804
  const finish = () => {
@@ -3132,7 +3271,7 @@ ${body2}` + (pdf.text.length > MAX_FILE_BYTES ? "\n\u2026 [truncated]" : "");
3132
3271
  } catch (err) {
3133
3272
  asToolError(err);
3134
3273
  }
3135
- await sleep2(BACKGROUND_SETTLE_MS, ctx.signal);
3274
+ await sleep3(BACKGROUND_SETTLE_MS, ctx.signal);
3136
3275
  const first = registry.read(started.id);
3137
3276
  ctx.onOutput?.(first.output);
3138
3277
  return `Started in the background as ${started.id}.
@@ -5883,7 +6022,7 @@ ${NO_SEARCH_INSTRUCTION}`),
5883
6022
  await runBusy(WAITING_FOR_REWARD, async (signal) => {
5884
6023
  const deadline = Date.now() + 5 * 6e4;
5885
6024
  while (Date.now() < deadline && !credited && !signal.aborted) {
5886
- await sleep3(3e3, signal);
6025
+ await sleep4(3e3, signal);
5887
6026
  if (signal.aborted) break;
5888
6027
  const w = await client.wallet(signal).catch(() => void 0);
5889
6028
  if (w && w.balance > before) {
@@ -6705,7 +6844,7 @@ ${dropped.map((l) => ` \xB7 ${l}`).join("\n")}`
6705
6844
  ] })
6706
6845
  ] });
6707
6846
  }
6708
- function sleep3(ms, signal) {
6847
+ function sleep4(ms, signal) {
6709
6848
  return new Promise((resolve2) => {
6710
6849
  if (signal.aborted) return resolve2();
6711
6850
  const done = () => {
@@ -7093,7 +7232,7 @@ function wrapPlain(text, width) {
7093
7232
  if (line2) lines.push(line2);
7094
7233
  return lines;
7095
7234
  }
7096
- var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
7235
+ var sleep5 = (ms) => new Promise((r) => setTimeout(r, ms));
7097
7236
  var credits = (n) => n.toLocaleString("en-US");
7098
7237
  function logout(config) {
7099
7238
  for (const key of IDENTITY_KEYS) delete config[key];
@@ -7183,7 +7322,7 @@ async function earn(client, config) {
7183
7322
  if (c.enabled) process.stdout.write(c.dim(" waiting for an offer to clear"));
7184
7323
  const deadline = Date.now() + 5 * 60 * 1e3;
7185
7324
  while (Date.now() < deadline) {
7186
- await sleep4(3e3);
7325
+ await sleep5(3e3);
7187
7326
  if (c.enabled) process.stdout.write(c.dim("."));
7188
7327
  const balance = (await safeWallet(client))?.balance ?? before;
7189
7328
  if (balance > before) {
@@ -7272,7 +7411,7 @@ async function buyCmd(client, config, pack) {
7272
7411
  if (c.enabled) process.stdout.write(c.dim(" waiting for payment to clear"));
7273
7412
  const deadline = Date.now() + 5 * 60 * 1e3;
7274
7413
  while (Date.now() < deadline) {
7275
- await sleep4(3e3);
7414
+ await sleep5(3e3);
7276
7415
  if (c.enabled) process.stdout.write(c.dim("."));
7277
7416
  const balance = (await safeWallet(client))?.balance ?? before;
7278
7417
  if (balance > before) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clixad",
3
- "version": "0.0.1-beta.19",
3
+ "version": "0.0.1-beta.20",
4
4
  "description": "Free AI coding agent in your terminal, funded by rewarded ads.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",