clixad 0.0.1-beta.10 → 0.0.1-beta.11
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/clixad.mjs +192 -20
- package/package.json +1 -1
package/dist/clixad.mjs
CHANGED
|
@@ -14,7 +14,83 @@ var __export = (target, all) => {
|
|
|
14
14
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
// src/edge.ts
|
|
18
|
+
function detectEdgeBlock(status, headers, body) {
|
|
19
|
+
if (status !== 403) return void 0;
|
|
20
|
+
if (isGatewayError(body)) return void 0;
|
|
21
|
+
const via = headers.get("server") ?? void 0;
|
|
22
|
+
const ray = rayId(headers, body);
|
|
23
|
+
return { status, ...ray ? { rayId: ray } : {}, ...via ? { via } : {} };
|
|
24
|
+
}
|
|
25
|
+
function isGatewayError(body) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(body);
|
|
28
|
+
return typeof parsed?.error === "string";
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function rayId(headers, body) {
|
|
34
|
+
const header = headers.get("cf-ray");
|
|
35
|
+
if (header) return header.split("-")[0];
|
|
36
|
+
const text = body.replace(/<[^>]*>/g, " ");
|
|
37
|
+
return /ray\s*id:?\s*([0-9a-f]{16})/i.exec(text)?.[1];
|
|
38
|
+
}
|
|
39
|
+
function edgeBlockSummary(block) {
|
|
40
|
+
const parts = [`${block.status} from ${block.via ?? "an intermediary"}`];
|
|
41
|
+
if (block.rayId) parts.push(`ray ${block.rayId}`);
|
|
42
|
+
return parts.join(", ");
|
|
43
|
+
}
|
|
44
|
+
function edgeBlockMessage(block, outcome) {
|
|
45
|
+
const head = `A security filter in front of the gateway rejected this request (${block.status}), so it
|
|
46
|
+
never reached Clixad. Nothing was sent to a model and nothing was charged.
|
|
47
|
+
Filters like that match on request contents, and a conversation about path
|
|
48
|
+
traversal, SQL or shell commands looks like an attack payload to one.`;
|
|
49
|
+
const tail = outcome === "retried" ? `
|
|
50
|
+
The compressed body this CLI falls back to was blocked as well.
|
|
51
|
+
Every turn resends the whole conversation, so the next one will be blocked
|
|
52
|
+
too \u2014 use /clear, or start a new session, to get past it.` : `
|
|
53
|
+
This gateway is too old to accept the compressed body that works around it.
|
|
54
|
+
Update the gateway, or use /clear to drop the history that trips the filter.`;
|
|
55
|
+
const id = block.rayId ? `
|
|
56
|
+
Request id (for support): ${block.rayId}` : "";
|
|
57
|
+
return head + tail + id;
|
|
58
|
+
}
|
|
59
|
+
function safeDetail(body, limit = DETAIL_LIMIT) {
|
|
60
|
+
if (!body) return "";
|
|
61
|
+
const title = /<title[^>]*>([^<]*)<\/title>/i.exec(body)?.[1];
|
|
62
|
+
const source = title ?? body;
|
|
63
|
+
const text = source.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)?/g, " ").replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, " ").replace(/<[^>]*>/g, " ").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
|
|
64
|
+
if (!text) return "";
|
|
65
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}\u2026` : text;
|
|
66
|
+
}
|
|
67
|
+
async function peek(res) {
|
|
68
|
+
const body = await res.text().catch(() => "");
|
|
69
|
+
return { body, res: new Response(body, { status: res.status, headers: res.headers }) };
|
|
70
|
+
}
|
|
71
|
+
var EdgeBlockedError, DETAIL_LIMIT;
|
|
72
|
+
var init_edge = __esm({
|
|
73
|
+
"src/edge.ts"() {
|
|
74
|
+
"use strict";
|
|
75
|
+
EdgeBlockedError = class extends Error {
|
|
76
|
+
constructor(block, outcome) {
|
|
77
|
+
super(edgeBlockMessage(block, outcome));
|
|
78
|
+
this.block = block;
|
|
79
|
+
this.outcome = outcome;
|
|
80
|
+
this.name = "EdgeBlockedError";
|
|
81
|
+
}
|
|
82
|
+
block;
|
|
83
|
+
outcome;
|
|
84
|
+
};
|
|
85
|
+
DETAIL_LIMIT = 200;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
17
89
|
// src/client.ts
|
|
90
|
+
import { gzipSync } from "node:zlib";
|
|
91
|
+
function encodeChatBody(json) {
|
|
92
|
+
return gzipSync(Buffer.from(json, "utf8"));
|
|
93
|
+
}
|
|
18
94
|
function adsPerTaskLabel(estAdsPerTask) {
|
|
19
95
|
const effort = estAdsPerTask >= 1 ? `~${estAdsPerTask} ads/task` : "<1 ad/task";
|
|
20
96
|
return `${effort} on completion`;
|
|
@@ -76,6 +152,15 @@ async function authFailure(res, what) {
|
|
|
76
152
|
`${what}: the gateway returned 401 without a Clixad error body. The gateway URL may be pointing at something else \u2014 a login/SSO page, a proxy, or a stale deployment. Check it, or set CLIXAD_GATEWAY_URL.`
|
|
77
153
|
);
|
|
78
154
|
}
|
|
155
|
+
async function triage(res) {
|
|
156
|
+
const peeked = await peek(res);
|
|
157
|
+
const block = detectEdgeBlock(res.status, res.headers, peeked.body);
|
|
158
|
+
return { ...block ? { block } : {}, res: peeked.res };
|
|
159
|
+
}
|
|
160
|
+
async function httpFailure(res, what) {
|
|
161
|
+
const detail = safeDetail(await res.text().catch(() => ""));
|
|
162
|
+
return detail ? `${what} failed: ${res.status} \u2014 ${detail}` : `${what} failed: ${res.status}`;
|
|
163
|
+
}
|
|
79
164
|
function accumulateToolCall(calls, part, fallbackIndex) {
|
|
80
165
|
const index = part.index ?? fallbackIndex;
|
|
81
166
|
const current = calls.get(index) ?? { id: "", type: "function", function: { name: "", arguments: "" } };
|
|
@@ -102,10 +187,12 @@ async function* parseSSE(body) {
|
|
|
102
187
|
}
|
|
103
188
|
}
|
|
104
189
|
}
|
|
105
|
-
var LEDGER_REASONS, SignupClosedError, PaywallError, AuthError, GatewayClient;
|
|
190
|
+
var CHAT_ENCODED_CONTENT_TYPE, LEDGER_REASONS, SignupClosedError, PaywallError, AuthError, GatewayClient;
|
|
106
191
|
var init_client = __esm({
|
|
107
192
|
"src/client.ts"() {
|
|
108
193
|
"use strict";
|
|
194
|
+
init_edge();
|
|
195
|
+
CHAT_ENCODED_CONTENT_TYPE = "application/vnd.clixad.chat+gzip";
|
|
109
196
|
LEDGER_REASONS = {
|
|
110
197
|
signup_bonus: "signup bonus",
|
|
111
198
|
ad_reward: "offer reward",
|
|
@@ -148,6 +235,25 @@ var init_client = __esm({
|
|
|
148
235
|
this.config = config;
|
|
149
236
|
}
|
|
150
237
|
config;
|
|
238
|
+
/**
|
|
239
|
+
* Told when the client had to change how it talks to the gateway.
|
|
240
|
+
*
|
|
241
|
+
* Set by the REPL, unset everywhere else. A transport that silently changes
|
|
242
|
+
* itself is a thing the user should be able to see — it explains the pause,
|
|
243
|
+
* and it is the difference between "the tool is being slow" and "something in
|
|
244
|
+
* front of the gateway rejected that request". Left unset for `clixad -p` and
|
|
245
|
+
* the one-shot commands, whose stdout exists to be piped.
|
|
246
|
+
*/
|
|
247
|
+
onNotice;
|
|
248
|
+
/**
|
|
249
|
+
* How chat bodies are being sent, for the rest of this process.
|
|
250
|
+
*
|
|
251
|
+
* Sticky once it moves. The conversation is resent in full every turn, so the
|
|
252
|
+
* string that got the first request blocked is in every request after it —
|
|
253
|
+
* going back to plain JSON would mean paying a blocked round-trip per turn to
|
|
254
|
+
* rediscover the same thing.
|
|
255
|
+
*/
|
|
256
|
+
chatTransport = "json";
|
|
151
257
|
headers(auth = true) {
|
|
152
258
|
const h = { "Content-Type": "application/json" };
|
|
153
259
|
if (auth) {
|
|
@@ -179,7 +285,7 @@ var init_client = __esm({
|
|
|
179
285
|
headers: this.headers(false)
|
|
180
286
|
});
|
|
181
287
|
if (res.status === 501) return null;
|
|
182
|
-
if (!res.ok) throw new Error(
|
|
288
|
+
if (!res.ok) throw new Error(await httpFailure(res, "device start"));
|
|
183
289
|
return res.json();
|
|
184
290
|
}
|
|
185
291
|
/** Poll once for device-flow completion. `invite` unlocks a gated signup. */
|
|
@@ -229,7 +335,7 @@ var init_client = __esm({
|
|
|
229
335
|
});
|
|
230
336
|
if (res.status === 401) throw await authFailure(res, "checkout");
|
|
231
337
|
if (res.status === 501) throw new Error("Buying credits isn't enabled (Stripe not configured on the gateway).");
|
|
232
|
-
if (!res.ok) throw new Error(
|
|
338
|
+
if (!res.ok) throw new Error(await httpFailure(res, "checkout"));
|
|
233
339
|
return res.json();
|
|
234
340
|
}
|
|
235
341
|
// Both take an optional signal for the REPL's sake: they are awaited while the
|
|
@@ -292,7 +398,14 @@ var init_client = __esm({
|
|
|
292
398
|
throw new Error(body.message ?? "Too many searches \u2014 wait a moment and try again.");
|
|
293
399
|
}
|
|
294
400
|
if (!res.ok) {
|
|
295
|
-
const
|
|
401
|
+
const peeked = await peek(res);
|
|
402
|
+
const block = detectEdgeBlock(res.status, res.headers, peeked.body);
|
|
403
|
+
if (block) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
`The search was rejected by a security filter in front of the gateway (${edgeBlockSummary(block)}); the query reads as an attack payload to it. Rephrase it without the literal payload, or use web_fetch on a URL you know.`
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
const body = await peeked.res.json().catch(() => ({}));
|
|
296
409
|
throw new Error(body.message ?? `web search failed: ${res.status}`);
|
|
297
410
|
}
|
|
298
411
|
const data = await res.json();
|
|
@@ -306,19 +419,70 @@ var init_client = __esm({
|
|
|
306
419
|
// wall, and the reward arrives as a signed postback from the network to the
|
|
307
420
|
// gateway. Nothing comes back to the CLI; a rising balance is the only signal,
|
|
308
421
|
// which is why both callers poll `wallet()`.
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
const
|
|
422
|
+
/** One POST to the chat route, in whichever transport is currently in use. */
|
|
423
|
+
sendChat(payload, encoded, signal) {
|
|
424
|
+
const json = JSON.stringify(payload);
|
|
425
|
+
return fetch(`${this.config.gatewayUrl}/v1/chat/completions`, {
|
|
312
426
|
method: "POST",
|
|
313
|
-
headers: this.headers(),
|
|
314
|
-
body:
|
|
427
|
+
headers: encoded ? { ...this.headers(), "Content-Type": CHAT_ENCODED_CONTENT_TYPE } : this.headers(),
|
|
428
|
+
body: encoded ? encodeChatBody(json) : json,
|
|
315
429
|
signal
|
|
316
430
|
});
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* POST a chat request, and get past the edge if the edge is in the way.
|
|
434
|
+
*
|
|
435
|
+
* Plain JSON first, always, on every fresh process. That is the OpenAI-compatible
|
|
436
|
+
* contract this route serves — Kimi CLI's kosong client speaks it, and so does
|
|
437
|
+
* anything else somebody points at `CLIXAD_GATEWAY_URL` — and a gateway that
|
|
438
|
+
* has never heard of the encoded form must keep working. The compressed body
|
|
439
|
+
* is a workaround for one specific failure, so it engages only once that
|
|
440
|
+
* failure has actually happened.
|
|
441
|
+
*
|
|
442
|
+
* The retry is not a general "try again": a 403 from a WAF is deterministic,
|
|
443
|
+
* and resending the identical bytes would be blocked identically. It is the
|
|
444
|
+
* *same* request in a form the filter has nothing to match on, which is why it
|
|
445
|
+
* is worth exactly one attempt and no more.
|
|
446
|
+
*
|
|
447
|
+
* Three outcomes, and each has to be distinguishable or the user is left
|
|
448
|
+
* guessing: the retry works (stick with it, say so once), the gateway answers
|
|
449
|
+
* 415 because it predates this (say that — the fix is a deploy, not a
|
|
450
|
+
* `/clear`), or it is blocked again (say that too, and say that clearing the
|
|
451
|
+
* history is the way out, because the history is what keeps triggering it).
|
|
452
|
+
*/
|
|
453
|
+
async postChat(payload, signal) {
|
|
454
|
+
const already = this.chatTransport === "encoded";
|
|
455
|
+
const first = await this.sendChat(payload, already, signal);
|
|
456
|
+
if (first.status !== 403) return first;
|
|
457
|
+
const { block, res } = await triage(first);
|
|
458
|
+
if (!block) return res;
|
|
459
|
+
if (already) throw new EdgeBlockedError(block, "retried");
|
|
460
|
+
const retry = await this.sendChat(payload, true, signal);
|
|
461
|
+
if (retry.status === 415) throw new EdgeBlockedError(block, "unsupported");
|
|
462
|
+
if (retry.status === 403) {
|
|
463
|
+
const second = await triage(retry);
|
|
464
|
+
if (second.block) throw new EdgeBlockedError(second.block, "retried");
|
|
465
|
+
return second.res;
|
|
466
|
+
}
|
|
467
|
+
this.chatTransport = "encoded";
|
|
468
|
+
this.onNotice?.(
|
|
469
|
+
` A security filter in front of the gateway blocked that request (${edgeBlockSummary(block)}).
|
|
470
|
+
Resent with a compressed body, which got through. Nothing was lost, and the
|
|
471
|
+
rest of this session will use the same encoding.`
|
|
472
|
+
);
|
|
473
|
+
return retry;
|
|
474
|
+
}
|
|
475
|
+
/** Non-streaming chat. Kept for scripted/one-shot paths; the REPL streams. */
|
|
476
|
+
async chat(messages, model, tools, signal) {
|
|
477
|
+
const res = await this.postChat(
|
|
478
|
+
{ model, messages, stream: false, ...tools ? { tools } : {} },
|
|
479
|
+
signal
|
|
480
|
+
);
|
|
317
481
|
if (res.status === 401) throw await authFailure(res, "chat");
|
|
318
482
|
if (res.status === 402) {
|
|
319
483
|
throw paywallError(await res.json());
|
|
320
484
|
}
|
|
321
|
-
if (!res.ok) throw new Error(
|
|
485
|
+
if (!res.ok) throw new Error(await httpFailure(res, "chat"));
|
|
322
486
|
const data = await res.json();
|
|
323
487
|
const choice = data.choices[0];
|
|
324
488
|
if (!choice) throw new Error("chat: no choices in response");
|
|
@@ -336,17 +500,15 @@ var init_client = __esm({
|
|
|
336
500
|
* the agent loop can stream *and* use tools in the same request.
|
|
337
501
|
*/
|
|
338
502
|
async chatStream(messages, model, opts = {}) {
|
|
339
|
-
const res = await
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
signal: opts.signal
|
|
344
|
-
});
|
|
503
|
+
const res = await this.postChat(
|
|
504
|
+
{ model, messages, stream: true, ...opts.tools ? { tools: opts.tools } : {} },
|
|
505
|
+
opts.signal
|
|
506
|
+
);
|
|
345
507
|
if (res.status === 401) throw await authFailure(res, "chat");
|
|
346
508
|
if (res.status === 402) {
|
|
347
509
|
throw paywallError(await res.json());
|
|
348
510
|
}
|
|
349
|
-
if (!res.ok || !res.body) throw new Error(
|
|
511
|
+
if (!res.ok || !res.body) throw new Error(await httpFailure(res, "chat"));
|
|
350
512
|
let content = "";
|
|
351
513
|
let finishReason = "stop";
|
|
352
514
|
let creditsCharged = 0;
|
|
@@ -4279,6 +4441,13 @@ function headRows(text, cols, max) {
|
|
|
4279
4441
|
}
|
|
4280
4442
|
return kept.join("\n");
|
|
4281
4443
|
}
|
|
4444
|
+
function errorNotice(message, rows = ERROR_NOTICE_ROWS) {
|
|
4445
|
+
const clean = message.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)?/g, " ").replace(/\u001b\[[0-9;?]*[ -\/]*[@-~]/g, " ").replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, " ");
|
|
4446
|
+
const lines = clean.split("\n");
|
|
4447
|
+
const kept = lines.slice(0, rows).map((l) => l.length > ERROR_NOTICE_COLS ? `${l.slice(0, ERROR_NOTICE_COLS - 1)}\u2026` : l);
|
|
4448
|
+
if (lines.length > rows) kept.push(` \u2026 +${lines.length - rows} more lines`);
|
|
4449
|
+
return kept.join("\n");
|
|
4450
|
+
}
|
|
4282
4451
|
function entryHeight(entry, cols) {
|
|
4283
4452
|
const margin = entry.kind === "banner" ? 0 : 1;
|
|
4284
4453
|
switch (entry.kind) {
|
|
@@ -4325,7 +4494,7 @@ function EntryView({ entry }) {
|
|
|
4325
4494
|
) });
|
|
4326
4495
|
}
|
|
4327
4496
|
}
|
|
4328
|
-
var MANGO3, MANGO_BRIGHT, SLATE, MODE_STYLE;
|
|
4497
|
+
var MANGO3, MANGO_BRIGHT, SLATE, MODE_STYLE, ERROR_NOTICE_ROWS, ERROR_NOTICE_COLS;
|
|
4329
4498
|
var init_views = __esm({
|
|
4330
4499
|
"src/tui/views.tsx"() {
|
|
4331
4500
|
"use strict";
|
|
@@ -4339,6 +4508,8 @@ var init_views = __esm({
|
|
|
4339
4508
|
acceptEdits: { color: "#7ee787", glyph: "\u23F5\u23F5" },
|
|
4340
4509
|
plan: { color: "#79c0ff", glyph: "\u23F8" }
|
|
4341
4510
|
};
|
|
4511
|
+
ERROR_NOTICE_ROWS = 12;
|
|
4512
|
+
ERROR_NOTICE_COLS = 200;
|
|
4342
4513
|
}
|
|
4343
4514
|
});
|
|
4344
4515
|
|
|
@@ -4473,6 +4644,7 @@ function App({ client, config, wallet, session, initialTask }) {
|
|
|
4473
4644
|
}, []);
|
|
4474
4645
|
useEffect(() => dropDelta, [dropDelta]);
|
|
4475
4646
|
useEffect(() => {
|
|
4647
|
+
client.onNotice = (text2) => push({ kind: "notice", tone: "warn", text: text2 });
|
|
4476
4648
|
const files = contextRef.current.files;
|
|
4477
4649
|
if (files.length) push({ kind: "notice", text: ` context: ${files.join(", ")}` });
|
|
4478
4650
|
if (session?.messages.length) {
|
|
@@ -4760,7 +4932,7 @@ ${NO_SEARCH_INSTRUCTION}`),
|
|
|
4760
4932
|
} else if (err instanceof AuthError) {
|
|
4761
4933
|
push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
|
|
4762
4934
|
} else {
|
|
4763
|
-
push({ kind: "notice", tone: "error", text: ` error: ${err.message}` });
|
|
4935
|
+
push({ kind: "notice", tone: "error", text: ` error: ${errorNotice(err.message)}` });
|
|
4764
4936
|
}
|
|
4765
4937
|
} finally {
|
|
4766
4938
|
abortRef.current = null;
|
|
@@ -4801,7 +4973,7 @@ ${NO_SEARCH_INSTRUCTION}`),
|
|
|
4801
4973
|
} catch (err) {
|
|
4802
4974
|
if (ac.signal.aborted) push({ kind: "notice", tone: "warn", text: " (stopped)" });
|
|
4803
4975
|
else if (err instanceof AuthError) push({ kind: "notice", tone: "error", text: NOT_SIGNED_IN });
|
|
4804
|
-
else push({ kind: "notice", tone: "error", text: ` ${err.message}` });
|
|
4976
|
+
else push({ kind: "notice", tone: "error", text: ` ${errorNotice(err.message)}` });
|
|
4805
4977
|
} finally {
|
|
4806
4978
|
busyAbortRef.current = null;
|
|
4807
4979
|
setBusyLabel(WORKING);
|