scalix 1.4.1 → 1.5.0
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/CHANGELOG.md +36 -0
- package/README.md +29 -10
- package/dist/agent-replay.js +2 -1
- package/dist/agent.js +207 -15
- package/dist/api-client.js +150 -25
- package/dist/cli.js +516 -213
- package/dist/constants.js +61 -6
- package/dist/cost-tracker.js +43 -5
- package/dist/headless.js +24 -14
- package/dist/ipc-server.js +26 -8
- package/dist/keychain.js +46 -22
- package/dist/markdown.js +18 -1
- package/dist/providers/index.js +31 -0
- package/dist/providers/openai.js +149 -0
- package/dist/settings.js +86 -8
- package/dist/slash-commands.js +148 -0
- package/dist/sub-agent.js +1 -0
- package/package.json +13 -3
- package/PARITY-PLAN.md +0 -116
- package/docs/DISTRIBUTION.md +0 -301
package/dist/api-client.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
|
-
import { USER_AGENT, aiEndpointUrl } from './constants.js';
|
|
1
|
+
import { USER_AGENT, aiEndpointUrl, AI_MAX_OUTPUT_TOKENS, AI_LEGACY_MAX_OUTPUT_TOKENS } from './constants.js';
|
|
2
2
|
export class ApiError extends Error {
|
|
3
3
|
status;
|
|
4
4
|
body;
|
|
5
|
-
|
|
5
|
+
/** Parsed Retry-After header (ms) on 429/503 responses, when the server sent one. */
|
|
6
|
+
retryAfterMs;
|
|
7
|
+
constructor(status, statusText, body, retryAfterMs) {
|
|
6
8
|
super(`API request failed: ${status} ${statusText}${body ? ` — ${body.slice(0, 200)}` : ''}`);
|
|
7
9
|
this.name = 'ApiError';
|
|
8
10
|
this.status = status;
|
|
9
11
|
this.body = body;
|
|
12
|
+
this.retryAfterMs = retryAfterMs;
|
|
10
13
|
}
|
|
11
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Parse a Retry-After header value into milliseconds. Accepts both forms from
|
|
17
|
+
* RFC 9110: delta-seconds ("120") and HTTP-date. Returns undefined for absent
|
|
18
|
+
* or unparseable values, and clamps negatives to 0.
|
|
19
|
+
*/
|
|
20
|
+
export function parseRetryAfter(value) {
|
|
21
|
+
if (!value)
|
|
22
|
+
return undefined;
|
|
23
|
+
const v = value.trim();
|
|
24
|
+
if (/^\d+$/.test(v))
|
|
25
|
+
return parseInt(v, 10) * 1000;
|
|
26
|
+
const date = Date.parse(v);
|
|
27
|
+
if (!Number.isNaN(date))
|
|
28
|
+
return Math.max(0, date - Date.now());
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
12
31
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
32
|
+
// The capability probe only decides whether a turn streams. It must never be
|
|
33
|
+
// the reason a turn is slow to start, so it gives up quickly and falls back to
|
|
34
|
+
// the conservative answer.
|
|
35
|
+
const CAPABILITY_PROBE_TIMEOUT_MS = 5_000;
|
|
13
36
|
export class ScalixClient {
|
|
14
37
|
baseURL;
|
|
15
38
|
apiKey;
|
|
@@ -19,31 +42,133 @@ export class ScalixClient {
|
|
|
19
42
|
this.apiKey = apiKey;
|
|
20
43
|
this.timeoutMs = timeoutMs;
|
|
21
44
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
/** URL of the chat-completions endpoint. The Scalix gateway nests AI under
|
|
46
|
+
* /v1/ai (aiEndpointUrl also tolerates a legacy base ending in /v1);
|
|
47
|
+
* provider adapters override this with their own composition. */
|
|
48
|
+
chatUrl() {
|
|
49
|
+
return aiEndpointUrl(this.baseURL, 'chat/completions');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Set once an endpoint has rejected `stream_options`, so adapters stop
|
|
53
|
+
* asking for streamed usage for the rest of the session. See the retry in
|
|
54
|
+
* chatComplete below.
|
|
55
|
+
*/
|
|
56
|
+
streamUsageUnsupported = false;
|
|
57
|
+
/**
|
|
58
|
+
* Session ceiling for `max_tokens` on the Scalix path. Starts at the current
|
|
59
|
+
* platform cap and drops to the legacy one the first time an endpoint
|
|
60
|
+
* rejects the larger value — a published CLI cannot assume which router
|
|
61
|
+
* version it is talking to, and a hard 422 mid-turn is a much worse outcome
|
|
62
|
+
* than one wasted round trip.
|
|
63
|
+
*/
|
|
64
|
+
maxTokensCeiling = AI_MAX_OUTPUT_TOKENS;
|
|
65
|
+
/** Whether `maxTokensCeiling` can still be lowered (one step, once). */
|
|
66
|
+
canLowerMaxTokens() {
|
|
67
|
+
return this.maxTokensCeiling > AI_LEGACY_MAX_OUTPUT_TOKENS;
|
|
68
|
+
}
|
|
69
|
+
/** URL of the model-list endpoint, which also carries the capability block. */
|
|
70
|
+
modelsUrl() {
|
|
71
|
+
return aiEndpointUrl(this.baseURL, 'models');
|
|
72
|
+
}
|
|
73
|
+
capabilityProbe;
|
|
74
|
+
/**
|
|
75
|
+
* Capabilities the endpoint advertises on /models. There is no version header
|
|
76
|
+
* on the inference path, and a published CLI does not choose which deployment
|
|
77
|
+
* it is pointed at, so it asks rather than assuming. An older endpoint sends
|
|
78
|
+
* no capability block and an unreachable one must not fail a turn — both come
|
|
79
|
+
* back as `{}`, meaning "assume nothing". Probed once per client.
|
|
80
|
+
*/
|
|
81
|
+
async endpointCapabilities() {
|
|
82
|
+
this.capabilityProbe ??= this.probeCapabilities();
|
|
83
|
+
return this.capabilityProbe;
|
|
84
|
+
}
|
|
85
|
+
async probeCapabilities() {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(this.modelsUrl(), {
|
|
88
|
+
headers: { 'Authorization': `Bearer ${this.apiKey}`, 'User-Agent': USER_AGENT },
|
|
89
|
+
signal: AbortSignal.timeout(CAPABILITY_PROBE_TIMEOUT_MS),
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
return {};
|
|
93
|
+
const body = await response.json();
|
|
94
|
+
return body.capabilities ?? {};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Adapter hook: last-mile request rewrite before serialization (e.g. drop
|
|
101
|
+
* fields a third-party endpoint would reject). Default for the Scalix path:
|
|
102
|
+
* hold max_tokens under the session ceiling. BYOK adapters deliberately do
|
|
103
|
+
* NOT inherit this — their ceiling is the user's own model, not ours. */
|
|
104
|
+
prepareRequest(req) {
|
|
105
|
+
if (req.max_tokens != null && req.max_tokens > this.maxTokensCeiling) {
|
|
106
|
+
return { ...req, max_tokens: this.maxTokensCeiling };
|
|
42
107
|
}
|
|
43
|
-
|
|
44
|
-
|
|
108
|
+
return req;
|
|
109
|
+
}
|
|
110
|
+
async chatComplete(req, signal) {
|
|
111
|
+
const url = this.chatUrl();
|
|
112
|
+
// Looped rather than straight-line so a request the endpoint won't accept
|
|
113
|
+
// can be narrowed and re-sent — see the !response.ok branch below. Each
|
|
114
|
+
// narrowing is one-way and latched on the client, so the loop can only run
|
|
115
|
+
// as many times as there are narrowings left.
|
|
116
|
+
for (;;) {
|
|
117
|
+
const prepared = this.prepareRequest(req);
|
|
118
|
+
const body = JSON.stringify(prepared);
|
|
119
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
120
|
+
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
121
|
+
const response = await fetch(url, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
126
|
+
'User-Agent': USER_AGENT,
|
|
127
|
+
'x-client-type': 'cli',
|
|
128
|
+
},
|
|
129
|
+
body,
|
|
130
|
+
signal: combined,
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
const text = await response.text().catch(() => '');
|
|
134
|
+
// `stream_options` is a late addition to the OpenAI wire — Azure only
|
|
135
|
+
// accepts it on recent api-versions, and a few compat servers validate
|
|
136
|
+
// strictly enough to 400 on it. Failing the turn to protect a token
|
|
137
|
+
// count is the wrong trade: drop the field for the rest of the session
|
|
138
|
+
// and send the request again.
|
|
139
|
+
if (!this.streamUsageUnsupported && response.status === 400 && prepared.stream_options && /stream_options/i.test(text)) {
|
|
140
|
+
this.streamUsageUnsupported = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// The platform's own output cap was raised; older deployments still
|
|
144
|
+
// hard-reject (422) anything over the legacy ceiling, and a published
|
|
145
|
+
// CLI does not get to choose which one it is pointed at. Take the
|
|
146
|
+
// endpoint's word for it, lower the ceiling for the session, and re-send
|
|
147
|
+
// rather than failing a turn over a number we can simply reduce.
|
|
148
|
+
if (this.canLowerMaxTokens() && (response.status === 422 || response.status === 400)
|
|
149
|
+
&& prepared.max_tokens != null && prepared.max_tokens > AI_LEGACY_MAX_OUTPUT_TOKENS
|
|
150
|
+
&& /max_tokens/i.test(text)) {
|
|
151
|
+
this.maxTokensCeiling = AI_LEGACY_MAX_OUTPUT_TOKENS;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
throw new ApiError(response.status, response.statusText, text, parseRetryAfter(response.headers.get('retry-after')));
|
|
155
|
+
}
|
|
156
|
+
if (req.stream) {
|
|
157
|
+
return this.parseSSEStream(response);
|
|
158
|
+
}
|
|
159
|
+
const result = await response.json();
|
|
160
|
+
// Non-streaming inference responses carry the platform's cost estimate as a
|
|
161
|
+
// header (x-scalix-cost-cents). Attach it to usage so cost tracking shows
|
|
162
|
+
// REAL platform-derived figures — never fabricated client-side prices. The
|
|
163
|
+
// body's own usage.cost (if the platform ever sends one) takes precedence.
|
|
164
|
+
if (result.usage && result.usage.cost == null) {
|
|
165
|
+
const cents = parseFloat(response.headers.get('x-scalix-cost-cents') || '');
|
|
166
|
+
if (Number.isFinite(cents) && cents > 0) {
|
|
167
|
+
result.usage.cost = cents / 100;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
45
171
|
}
|
|
46
|
-
return await response.json();
|
|
47
172
|
}
|
|
48
173
|
async *parseSSEStream(response) {
|
|
49
174
|
const reader = response.body?.getReader();
|