jaitus 0.3.12 → 0.3.13

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/src/api.js CHANGED
@@ -1,140 +1,174 @@
1
- // ─── Talking to Jaitus ──────────────────────────────────────────────────────────
2
- //
3
- // The CLI is the only client of the gateway. Every failure it gets back is turned into
4
- // a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
5
- // print — see errors.js for why that matters.
6
-
7
- import { endpoints } from './config.js';
8
- import { signedIn, withAuth } from './session.js';
9
- import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
-
11
- export class NotSignedIn extends Error {}
12
-
13
- // fetch() has no timeout of its own, and the gateway's worst case is minutes of
14
- // serial failover across provider keys — so a sick window used to pin the CLI on
15
- // "Working..." forever. Every call below carries its own ceiling: slow becomes a
16
- // `timeout` JaitusError (retryable by the agent loop, shown with a request id)
17
- // instead of eternity. The caller's signal (Esc) still wins: it aborts through the
18
- // same controller, and only a genuine user abort reads as one.
19
- const MESSAGES_TIMEOUT_MS = 10 * 60 * 1000; // streamed turns can legitimately run minutes
20
- const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
21
-
22
- // Combine an optional caller signal with a timeout into one signal.
23
- // Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
24
- function withTimeout(signal, ms) {
25
- const ctrl = new AbortController();
26
- let timedOut = false;
27
- const timer = setTimeout(() => {
28
- timedOut = true;
29
- try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
30
- catch { try { ctrl.abort(); } catch { /* already gone */ } }
31
- }, ms);
32
- if (timer.unref) timer.unref();
33
- const onAbort = () => {
34
- clearTimeout(timer);
35
- try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
36
- };
37
- if (signal) {
38
- if (signal.aborted) onAbort();
39
- else signal.addEventListener('abort', onAbort, { once: true });
40
- }
41
- return {
42
- signal: ctrl.signal,
43
- // True only when OUR timer fired. A user abort also lands here as an AbortError,
44
- // but signal.aborted is set then — never mistake Esc for a timeout.
45
- timedOut: () => timedOut && !signal?.aborted,
46
- cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
47
- };
48
- }
49
-
50
- function timeoutError() {
51
- return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
52
- }
53
-
54
- // The context window the gateway last advertised, or null before it has been asked.
55
- let contextWindowSeen = null;
56
- export const advertisedContextWindow = () => contextWindowSeen;
57
-
58
- export function createClient() {
59
- const { baseUrl } = endpoints();
60
- if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
61
-
62
- // x-jaitus-features: what this build understands, so the gateway can use newer error
63
- // codes (a used-up usage window) without an older CLI reading them as "went wrong".
64
- const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli', 'x-jaitus-features': 'usage-window' };
65
- const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
66
-
67
- return {
68
- baseUrl,
69
-
70
- /** POST /v1/messages. Returns the raw Response so the caller can stream it. */
71
- async messages(body, { signal } = {}) {
72
- // The timer is deliberately NOT cleared when headers arrive: a stall can happen
73
- // mid-body too (headers fast, tokens never come), and only the stream reader ends
74
- // that. Aborting a finished request is harmless; the timer never holds the
75
- // process open (unref). 10 minutes: streamed agentic turns legitimately run long.
76
- const t = withTimeout(signal, MESSAGES_TIMEOUT_MS);
77
- try {
78
- return await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
79
- method: 'POST',
80
- headers: headers(token),
81
- body: JSON.stringify(body),
82
- signal: t.signal,
83
- }));
84
- } catch (cause) {
85
- if (cause?.name === 'JaitusError') throw cause;
86
- if (t.timedOut()) throw timeoutError();
87
- if (cause?.name === 'AbortError') throw cause;
88
- throw fromNetwork(cause);
89
- }
90
- },
91
-
92
- /** A dashboard API call, returning parsed JSON. */
93
- async api(pathname, init = {}) {
94
- const t = withTimeout(init.signal, API_TIMEOUT_MS);
95
- let res;
96
- try {
97
- res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
98
- ...init,
99
- headers: { ...headers(token), ...(init.headers || {}) },
100
- signal: t.signal,
101
- }));
102
- const body = await res.json().catch(() => ({}));
103
- return { ok: res.ok, status: res.status, body };
104
- } catch (cause) {
105
- if (cause?.name === 'JaitusError') throw cause;
106
- if (t.timedOut()) throw timeoutError();
107
- if (cause?.name === 'AbortError') throw cause;
108
- throw fromNetwork(cause);
109
- } finally {
110
- t.cleanup();
111
- }
112
- },
113
-
114
- /** Model ids the gateway advertises. */
115
- async models() {
116
- const t = withTimeout(undefined, API_TIMEOUT_MS);
117
- let res;
118
- try {
119
- res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
120
- } catch (cause) {
121
- t.cleanup();
122
- if (cause?.name === 'JaitusError') throw cause;
123
- if (t.timedOut()) throw timeoutError();
124
- if (cause?.name === 'AbortError') throw cause;
125
- throw fromNetwork(cause);
126
- }
127
- const body = await res.json().catch(() => ({}));
128
- t.cleanup();
129
- if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
130
- const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
131
- // How much conversation fits, as the gateway computed it for this account.
132
- const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
133
- if (ctx) contextWindowSeen = ctx;
134
- // An empty list is a service fault, not something the user can fix — and naming
135
- // the gateway's model map to them would be naming our internals.
136
- if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
137
- return models;
138
- },
139
- };
140
- }
1
+ // ─── Talking to Jaitus ──────────────────────────────────────────────────────────
2
+ //
3
+ // The CLI is the only client of the gateway. Every failure it gets back is turned into
4
+ // a JaitusError here, at the edge, so no caller ever has an HTTP body in its hands to
5
+ // print — see errors.js for why that matters.
6
+
7
+ import { endpoints } from './config.js';
8
+ import { signedIn, withAuth } from './session.js';
9
+ import { fromGatewayBody, fromNetwork, JaitusError } from './errors.js';
10
+
11
+ export class NotSignedIn extends Error {}
12
+
13
+ // fetch() has no timeout of its own, so a request the gateway never answered used to
14
+ // pin the CLI on "Working..." forever. Every call below carries a deadline: slow
15
+ // becomes a `timeout` JaitusError (retryable by the agent loop, shown with a request
16
+ // id) instead of eternity. The caller's signal (Esc) still wins: it aborts through the
17
+ // same controller, and only a genuine user abort reads as one.
18
+ //
19
+ // A turn is bounded by SILENCE, not by its length. Until the reply starts, the gateway
20
+ // may be failing over between provider keys (each gets a 20-45 s budget), so the first
21
+ // byte gets a few minutes. Once it starts, the gateway writes a heartbeat at least every
22
+ // 10 s for as long as the provider is working, so 90 s without a single byte means the
23
+ // connection is dead — while a long, healthy answer streams for as long as it needs.
24
+ // (A fixed 10-minute ceiling on the whole request used to cut long answers off and
25
+ // start them again from nothing.)
26
+ // (Overridable only so the tests can drive a stall in under a second.)
27
+ const FIRST_BYTE_TIMEOUT_MS = Number(process.env.JAITUS_FIRST_BYTE_MS) || 3 * 60 * 1000;
28
+ const STREAM_IDLE_TIMEOUT_MS = Number(process.env.JAITUS_STREAM_IDLE_MS) || 90 * 1000;
29
+ const API_TIMEOUT_MS = 30 * 1000; // dashboard calls are small JSON
30
+
31
+ // Combine an optional caller signal with a restartable deadline into one signal.
32
+ // Manual combine (not AbortSignal.any): .any needs Node 20.3+, this works everywhere.
33
+ function withTimeout(signal, ms) {
34
+ const ctrl = new AbortController();
35
+ let timedOut = false;
36
+ let timer = null;
37
+ const arm = (after) => {
38
+ clearTimeout(timer);
39
+ timer = setTimeout(() => {
40
+ timedOut = true;
41
+ try { ctrl.abort(new DOMException('Request timed out', 'TimeoutError')); }
42
+ catch { try { ctrl.abort(); } catch { /* already gone */ } }
43
+ }, after);
44
+ if (timer.unref) timer.unref();
45
+ };
46
+ arm(ms);
47
+ const onAbort = () => {
48
+ clearTimeout(timer);
49
+ try { ctrl.abort(signal.reason); } catch { try { ctrl.abort(); } catch { /* already gone */ } }
50
+ };
51
+ if (signal) {
52
+ if (signal.aborted) onAbort();
53
+ else signal.addEventListener('abort', onAbort, { once: true });
54
+ }
55
+ return {
56
+ signal: ctrl.signal,
57
+ // True only when OUR timer fired. A user abort also lands here as an AbortError,
58
+ // but signal.aborted is set then — never mistake Esc for a timeout.
59
+ timedOut: () => timedOut && !signal?.aborted,
60
+ /** Start the deadline again from now (with a new length). */
61
+ restart: (after) => { if (!ctrl.signal.aborted) arm(after); },
62
+ cleanup: () => { clearTimeout(timer); signal?.removeEventListener?.('abort', onAbort); },
63
+ };
64
+ }
65
+
66
+ /**
67
+ * The response with its body watched for silence: every chunk restarts the idle
68
+ * deadline, and the end of the body (or the reader letting go of it) stops the timer
69
+ * and drops the caller's abort listener, so nothing outlives the request.
70
+ */
71
+ function watchedResponse(res, t, idleMs) {
72
+ if (!res.body) { t.cleanup(); return res; }
73
+ t.restart(idleMs);
74
+ const body = res.body.pipeThrough(new TransformStream({
75
+ transform(chunk, controller) { t.restart(idleMs); controller.enqueue(chunk); },
76
+ flush() { t.cleanup(); },
77
+ cancel() { t.cleanup(); },
78
+ }));
79
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
80
+ }
81
+
82
+ function timeoutError() {
83
+ return new JaitusError({ code: 'timeout', status: 504, message: 'Jaitus took too long to respond. Try again.' });
84
+ }
85
+
86
+ // The context window the gateway last advertised, or null before it has been asked.
87
+ let contextWindowSeen = null;
88
+ export const advertisedContextWindow = () => contextWindowSeen;
89
+
90
+ export function createClient() {
91
+ const { baseUrl } = endpoints();
92
+ if (!signedIn()) throw new NotSignedIn('Not signed in. Run `jaitus login` first.');
93
+
94
+ // x-jaitus-features: what this build understands, so the gateway can use newer error
95
+ // codes (a used-up usage window) without an older CLI reading them as "went wrong".
96
+ const base = { 'Content-Type': 'application/json', 'user-agent': 'jaitus-cli', 'x-jaitus-features': 'usage-window' };
97
+ const headers = (token) => ({ ...base, authorization: `Bearer ${token}` });
98
+
99
+ return {
100
+ baseUrl,
101
+
102
+ /** POST /v1/messages. Returns the raw Response so the caller can stream it. */
103
+ async messages(body, { signal } = {}) {
104
+ const t = withTimeout(signal, FIRST_BYTE_TIMEOUT_MS);
105
+ let res;
106
+ try {
107
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/messages`, {
108
+ method: 'POST',
109
+ headers: headers(token),
110
+ body: JSON.stringify(body),
111
+ signal: t.signal,
112
+ }));
113
+ } catch (cause) {
114
+ t.cleanup();
115
+ if (cause?.name === 'JaitusError') throw cause;
116
+ if (t.timedOut()) throw timeoutError();
117
+ if (cause?.name === 'AbortError') throw cause;
118
+ throw fromNetwork(cause);
119
+ }
120
+ // A mid-body stall (headers fast, then nothing) aborts the body; the reader sees
121
+ // an AbortError while the caller's signal is NOT aborted, and turns it into a
122
+ // timeout — keeping whatever already arrived (agent/loop.js).
123
+ return watchedResponse(res, t, STREAM_IDLE_TIMEOUT_MS);
124
+ },
125
+
126
+ /** A dashboard API call, returning parsed JSON. */
127
+ async api(pathname, init = {}) {
128
+ const t = withTimeout(init.signal, API_TIMEOUT_MS);
129
+ let res;
130
+ try {
131
+ res = await withAuth((token) => fetch(`${baseUrl}/api${pathname}`, {
132
+ ...init,
133
+ headers: { ...headers(token), ...(init.headers || {}) },
134
+ signal: t.signal,
135
+ }));
136
+ const body = await res.json().catch(() => ({}));
137
+ return { ok: res.ok, status: res.status, body };
138
+ } catch (cause) {
139
+ if (cause?.name === 'JaitusError') throw cause;
140
+ if (t.timedOut()) throw timeoutError();
141
+ if (cause?.name === 'AbortError') throw cause;
142
+ throw fromNetwork(cause);
143
+ } finally {
144
+ t.cleanup();
145
+ }
146
+ },
147
+
148
+ /** Model ids the gateway advertises. */
149
+ async models() {
150
+ const t = withTimeout(undefined, API_TIMEOUT_MS);
151
+ let res;
152
+ try {
153
+ res = await withAuth((token) => fetch(`${baseUrl}/v1/models`, { headers: headers(token), signal: t.signal }));
154
+ } catch (cause) {
155
+ t.cleanup();
156
+ if (cause?.name === 'JaitusError') throw cause;
157
+ if (t.timedOut()) throw timeoutError();
158
+ if (cause?.name === 'AbortError') throw cause;
159
+ throw fromNetwork(cause);
160
+ }
161
+ const body = await res.json().catch(() => ({}));
162
+ t.cleanup();
163
+ if (!res.ok) throw fromGatewayBody(res.status, body, { requestId: res.headers.get('x-request-id') || '' });
164
+ const models = (body?.data || []).map((m) => m?.id).filter(Boolean);
165
+ // How much conversation fits, as the gateway computed it for this account.
166
+ const ctx = (body?.data || []).map((m) => Number(m?.context_window)).find((n) => n > 0);
167
+ if (ctx) contextWindowSeen = ctx;
168
+ // An empty list is a service fault, not something the user can fix — and naming
169
+ // the gateway's model map to them would be naming our internals.
170
+ if (!models.length) throw new JaitusError({ code: 'unavailable', status: 502 });
171
+ return models;
172
+ },
173
+ };
174
+ }
package/src/errors.js CHANGED
@@ -1,106 +1,120 @@
1
- // ─── What the CLI says when something fails ─────────────────────────────────────
2
- //
3
- // The gateway answers every failure with a `jaitus_*` code from one catalog. This file
4
- // is the CLI's half of that contract: a code maps to a title and a hint written here,
5
- // and anything WITHOUT a known code — an HTML page from a proxy, a body in some other
6
- // shape, a code this build has never heard of — becomes "something went wrong in
7
- // Jaitus" plus the request id. The CLI never prints an error body it does not
8
- // recognise, because the only text that is safe to show is text we wrote.
9
-
10
- const CATALOG = {
11
- auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
12
- session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
13
- account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
14
- email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
15
- free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
16
- free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
17
- plan_limit_reached: { title: 'Monthly allowance used', hint: 'It resets at the start of your next period, or upgrade on the website. /usage shows where you are.' },
18
- rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
19
- context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
20
- request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
21
- daily_limit_reached: { title: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
22
- window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
23
- token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
24
- refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
25
- content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
26
- bad_request: { title: 'Request not accepted', hint: 'Try again; if it repeats, run /debug and contact support.' },
27
- model_unavailable: { title: 'Model unavailable', hint: 'Run /model to pick another.' },
28
- busy: { title: 'Jaitus is busy', hint: 'Try again in a minute.' },
29
- unavailable: { title: 'Jaitus is unavailable', hint: 'Try again shortly. `jaitus doctor` checks your side.' },
30
- maintenance: { title: 'Down for maintenance', hint: 'Try again shortly.' },
31
- timeout: { title: 'Timed out', hint: 'Try again.' },
32
- interrupted: { title: 'Response interrupted', hint: 'Try again.' },
33
- internal: { title: 'Something went wrong', hint: 'Try again; if it keeps happening, contact support with the request id.' },
34
- };
35
-
36
- const GENERIC = 'Something went wrong in Jaitus.';
37
-
38
- export class JaitusError extends Error {
39
- constructor({ code = 'internal', message, status = 0, requestId = '', retryAfter = null } = {}) {
40
- const known = Object.prototype.hasOwnProperty.call(CATALOG, code) ? code : 'internal';
41
- super(message || GENERIC);
42
- this.name = 'JaitusError';
43
- this.code = known;
44
- this.status = status;
45
- this.requestId = requestId;
46
- this.retryAfter = retryAfter;
47
- this.title = CATALOG[known].title;
48
- this.hint = CATALOG[known].hint;
49
- }
50
- }
51
-
52
- /**
53
- * An error body from the gateway → a JaitusError.
54
- *
55
- * The gateway's message is shown ONLY when the body carries a recognised `jaitus_`
56
- * code, because only then is it guaranteed to be our wording (it may carry specifics,
57
- * like how many free prompts there were). Otherwise the message is ours, generic.
58
- */
59
- export function fromGatewayBody(status, body, { requestId = '', retryAfter = null } = {}) {
60
- let parsed = body;
61
- if (typeof body === 'string') {
62
- try { parsed = JSON.parse(body); } catch { parsed = null; }
63
- }
64
- const err = parsed?.error || null;
65
- const rawCode = String(err?.code || '');
66
- const code = rawCode.startsWith('jaitus_') ? rawCode.slice(7) : '';
67
- const known = code && Object.prototype.hasOwnProperty.call(CATALOG, code);
68
- const id = String(parsed?.request_id || requestId || '');
69
- return new JaitusError({
70
- code: known ? code : 'internal',
71
- message: known && typeof err?.message === 'string' && err.message.trim() ? err.message.trim() : GENERIC,
72
- status,
73
- requestId: id,
74
- retryAfter,
75
- });
76
- }
77
-
78
- /** A failed fetch Response → a JaitusError, reading the body once. */
79
- export async function fromResponse(res) {
80
- const text = await res.text().catch(() => '');
81
- const requestId = res.headers?.get?.('x-request-id') || '';
82
- const retryAfter = Number(res.headers?.get?.('retry-after')) || null;
83
- return fromGatewayBody(res.status, text, { requestId, retryAfter });
84
- }
85
-
86
- /**
87
- * fetch() threw before any response — the network, not the service. This is the one
88
- * failure where the true cause is on the user's side and worth naming plainly.
89
- */
90
- export function fromNetwork(cause) {
91
- if (cause?.name === 'AbortError') return cause;
92
- const error = new JaitusError({
93
- code: 'unavailable',
94
- message: 'Could not reach Jaitus. Check your internet connection.',
95
- });
96
- error.title = 'Cannot reach Jaitus';
97
- error.hint = 'Check your connection, then run `jaitus doctor`.';
98
- error.cause = cause;
99
- return error;
100
- }
101
-
102
- export function isJaitusError(e) {
103
- return e instanceof JaitusError || e?.name === 'JaitusError';
104
- }
105
-
106
- export const ERROR_CODES = Object.freeze(Object.keys(CATALOG));
1
+ // ─── What the CLI says when something fails ─────────────────────────────────────
2
+ //
3
+ // The gateway answers every failure with a `jaitus_*` code from one catalog. This file
4
+ // is the CLI's half of that contract: a code maps to a title and a hint written here,
5
+ // and anything WITHOUT a known code — an HTML page from a proxy, a body in some other
6
+ // shape, a code this build has never heard of — becomes "something went wrong in
7
+ // Jaitus" plus the request id. The CLI never prints an error body it does not
8
+ // recognise, because the only text that is safe to show is text we wrote.
9
+
10
+ const CATALOG = {
11
+ auth_required: { title: 'Not signed in', hint: 'Type /login to sign in (or run `jaitus login`).' },
12
+ session_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
13
+ account_suspended: { title: 'Account suspended', hint: 'Contact support if you think this is a mistake.' },
14
+ email_unverified: { title: 'Email not verified', hint: 'Open the verification link in your inbox, then retry.' },
15
+ free_limit_reached: { title: 'Free prompts used up', hint: 'Subscribe on the website, then retry this turn.' },
16
+ free_request_too_large: { title: 'Too large for the free plan', hint: 'Run /compact or start a new session, or subscribe.' },
17
+ plan_limit_reached: { title: 'Monthly allowance used', hint: 'It resets at the start of your next period, or upgrade on the website. /usage shows where you are.' },
18
+ rate_limited: { title: 'Slow down', hint: 'Wait a moment, then retry.' },
19
+ context_too_long: { title: 'Conversation too long', hint: 'Run /compact, or start a new session.' },
20
+ request_too_large: { title: 'Request too large', hint: 'Usually images: remove some, or run /compact to drop old ones. Images over 3.5 MB are shrunk automatically.' },
21
+ daily_limit_reached: { title: 'Usage limit reached', hint: 'It refills automatically. /usage shows where you are.' },
22
+ window_limit_reached: { title: 'This usage window is used up', hint: 'It refills when the window ends — the rest of your month is still there. /usage shows when.' },
23
+ token_expired: { title: 'Signed out', hint: 'Type /login to sign in again (or run `jaitus login`).' },
24
+ refresh_race: { title: 'Sign-in was being renewed', hint: 'Try again.' },
25
+ content_blocked: { title: 'Request refused', hint: 'Rephrase the request and try again.' },
26
+ bad_request: { title: 'Request not accepted', hint: 'Try again; if it repeats, run /debug and contact support.' },
27
+ model_unavailable: { title: 'Model unavailable', hint: 'Run /model to pick another.' },
28
+ busy: { title: 'Jaitus is busy', hint: 'Try again in a minute.' },
29
+ unavailable: { title: 'Jaitus is unavailable', hint: 'Try again shortly. `jaitus doctor` checks your side.' },
30
+ maintenance: { title: 'Down for maintenance', hint: 'Try again shortly.' },
31
+ timeout: { title: 'Timed out', hint: 'Try again.' },
32
+ interrupted: { title: 'Response interrupted', hint: 'Try again.' },
33
+ internal: { title: 'Something went wrong', hint: 'Try again; if it keeps happening, contact support with the request id.' },
34
+ };
35
+
36
+ const GENERIC = 'Something went wrong in Jaitus.';
37
+
38
+ export class JaitusError extends Error {
39
+ constructor({ code = 'internal', message, status = 0, requestId = '', retryAfter = null } = {}) {
40
+ const known = Object.prototype.hasOwnProperty.call(CATALOG, code) ? code : 'internal';
41
+ super(message || GENERIC);
42
+ this.name = 'JaitusError';
43
+ this.code = known;
44
+ this.status = status;
45
+ this.requestId = requestId;
46
+ this.retryAfter = retryAfter;
47
+ this.title = CATALOG[known].title;
48
+ this.hint = CATALOG[known].hint;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * An error body from the gateway → a JaitusError.
54
+ *
55
+ * The gateway's message is shown ONLY when the body carries a recognised `jaitus_`
56
+ * code, because only then is it guaranteed to be our wording (it may carry specifics,
57
+ * like how many free prompts there were). Otherwise the message is ours, generic.
58
+ */
59
+ export function fromGatewayBody(status, body, { requestId = '', retryAfter = null } = {}) {
60
+ let parsed = body;
61
+ if (typeof body === 'string') {
62
+ try { parsed = JSON.parse(body); } catch { parsed = null; }
63
+ }
64
+ const err = parsed?.error || null;
65
+ const rawCode = String(err?.code || '');
66
+ const code = rawCode.startsWith('jaitus_') ? rawCode.slice(7) : '';
67
+ const known = code && Object.prototype.hasOwnProperty.call(CATALOG, code);
68
+ const id = String(parsed?.request_id || requestId || '');
69
+ // No Jaitus code on a gateway-class status: the answer came from the proxy in front of
70
+ // the gateway (its timeout page, a 502 while a deploy restarts the server), not from
71
+ // the gateway. Those are "not right now" — said plainly and retried — never the
72
+ // dead-end "Something went wrong" they used to read as.
73
+ if (!known && (status === 504 || status === 524)) {
74
+ return new JaitusError({ code: 'timeout', status, requestId: id, retryAfter, message: 'Jaitus took too long to answer and the connection was closed on the way. Try again.' });
75
+ }
76
+ if (!known && (status === 502 || status === 503 || (status >= 520 && status <= 530))) {
77
+ return new JaitusError({ code: 'unavailable', status, requestId: id, retryAfter, message: 'Jaitus could not be reached just now. Try again in a moment.' });
78
+ }
79
+ return new JaitusError({
80
+ code: known ? code : 'internal',
81
+ message: known && typeof err?.message === 'string' && err.message.trim() ? err.message.trim() : GENERIC,
82
+ status,
83
+ requestId: id,
84
+ retryAfter,
85
+ });
86
+ }
87
+
88
+ /** A failed fetch Response → a JaitusError, reading the body once. */
89
+ export async function fromResponse(res) {
90
+ const text = await res.text().catch(() => '');
91
+ const requestId = res.headers?.get?.('x-request-id') || '';
92
+ const retryAfter = Number(res.headers?.get?.('retry-after')) || null;
93
+ const error = fromGatewayBody(res.status, text, { requestId, retryAfter });
94
+ // The gateway says trying again cannot help yet (e.g. no provider is switched on):
95
+ // report it at once instead of retrying behind "Working…".
96
+ if (res.headers?.get?.('x-jaitus-retry') === 'no') error.noRetry = true;
97
+ return error;
98
+ }
99
+
100
+ /**
101
+ * fetch() threw before any response — the network, not the service. This is the one
102
+ * failure where the true cause is on the user's side and worth naming plainly.
103
+ */
104
+ export function fromNetwork(cause) {
105
+ if (cause?.name === 'AbortError') return cause;
106
+ const error = new JaitusError({
107
+ code: 'unavailable',
108
+ message: 'Could not reach Jaitus. Check your internet connection.',
109
+ });
110
+ error.title = 'Cannot reach Jaitus';
111
+ error.hint = 'Check your connection, then run `jaitus doctor`.';
112
+ error.cause = cause;
113
+ return error;
114
+ }
115
+
116
+ export function isJaitusError(e) {
117
+ return e instanceof JaitusError || e?.name === 'JaitusError';
118
+ }
119
+
120
+ export const ERROR_CODES = Object.freeze(Object.keys(CATALOG));