scalix 1.4.1 → 1.5.1

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