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.
@@ -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
- 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
  }
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
- 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);
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
- if (req.stream) {
44
- return this.parseSSEStream(response);
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();