@maci0/dsh-chatjimmy 0.11.4
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/API.md +145 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/cordis.patch.yml +13 -0
- package/icon.svg +6 -0
- package/lib/adapter.js +421 -0
- package/lib/client.js +398 -0
- package/lib/host.js +21 -0
- package/lib/index.js +172 -0
- package/lib/protocol.js +172 -0
- package/lib/types/adapter.d.ts +78 -0
- package/lib/types/host.d.ts +177 -0
- package/lib/types/index.d.ts +125 -0
- package/lib/types/protocol.d.ts +101 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +95 -0
package/lib/adapter.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider adapter for the chatjimmy.ai chat API (see `API.md` at the
|
|
3
|
+
* repository root for the reconstructed wire contract).
|
|
4
|
+
*
|
|
5
|
+
* The service is text-in/text-out: it accepts no tool schemas, no images, no
|
|
6
|
+
* sampling parameters, and returns one plain-text stream. This adapter is
|
|
7
|
+
* therefore honest about being a text-only route: it advertises
|
|
8
|
+
* `inputModalities: ['text']` so `LlmRuntime` projects files and images to
|
|
9
|
+
* placeholder text before dispatch, and it ignores every tool field rather than
|
|
10
|
+
* pretending the model can call them.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-chatjimmy/adapter
|
|
13
|
+
*/
|
|
14
|
+
import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
|
|
15
|
+
import { buildChatRequest, isContextLimitReason, mapUsage, StatsStreamFilter, } from './protocol.js';
|
|
16
|
+
/** Stable failure used when the backend answers with a zero-byte stream body. */
|
|
17
|
+
export { CONTEXT_WINDOW_EXCEEDED_CODE };
|
|
18
|
+
/**
|
|
19
|
+
* Metadata every appearance of the configured model carries: the backend serves
|
|
20
|
+
* one model, so its wording and modalities live here rather than in two object
|
|
21
|
+
* literals.
|
|
22
|
+
*/
|
|
23
|
+
const CONFIGURED_MODEL = {
|
|
24
|
+
name: 'Llama 3.1 8B (Chat Jimmy)',
|
|
25
|
+
description: 'Taalas-hosted Llama 3.1 8B served by chatjimmy.ai.'
|
|
26
|
+
+ ' Text only: no tool calls, no images, 6144-token total context.',
|
|
27
|
+
inputModalities: ['text'],
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Maps an HTTP status onto a provider-neutral failure code. The service emits
|
|
31
|
+
* `{"success":false,"error":"…"}` for every rejection, so the body is the
|
|
32
|
+
* specific part and the status is only the class.
|
|
33
|
+
*/
|
|
34
|
+
function failureForStatus(status, detail, headers) {
|
|
35
|
+
const code = status === 400 || status === 422
|
|
36
|
+
? 'INVALID_REQUEST'
|
|
37
|
+
: status === 401 || status === 403
|
|
38
|
+
? 'AUTH'
|
|
39
|
+
: status === 429
|
|
40
|
+
? 'RATE_LIMIT'
|
|
41
|
+
: status >= 500
|
|
42
|
+
? 'SERVER'
|
|
43
|
+
: 'TRANSPORT';
|
|
44
|
+
const retry = headers.get('retry-after')?.trim();
|
|
45
|
+
const delay = retry === undefined ? NaN : /^\d+(?:\.\d+)?$/u.test(retry)
|
|
46
|
+
? Number(retry) * 1000 : /^[A-Za-z]/u.test(retry) ? Date.parse(retry) - Date.now() : NaN;
|
|
47
|
+
return {
|
|
48
|
+
message: `chatjimmy: HTTP ${status}${detail.length > 0 ? `: ${detail}` : ''}`, code, status,
|
|
49
|
+
...(Number.isFinite(delay) && delay > 0 ? { providerRetryAfterMs: delay } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** The one failure the backend's own context-limit refusal produces. */
|
|
53
|
+
function contextLimitFailure(reason) {
|
|
54
|
+
return { message: `chatjimmy: ${String(reason)}`, code: CONTEXT_WINDOW_EXCEEDED_CODE };
|
|
55
|
+
}
|
|
56
|
+
/** Terminal error finish carrying one failure. */
|
|
57
|
+
function errorFinish(failure) {
|
|
58
|
+
return { type: 'finish', reason: { kind: 'error', failure } };
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Terminal abort finish. Caller cancellation is not a provider failure, so it
|
|
62
|
+
* uses the `aborted` reason the protocol reserves for it.
|
|
63
|
+
*/
|
|
64
|
+
function abortFinish() {
|
|
65
|
+
return { type: 'finish', reason: { kind: 'aborted', failure: { message: 'chatjimmy: request aborted by caller', code: 'ABORTED' } } };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* A `GenerateOptions` field the service has no wire slot for. Stop sequences
|
|
69
|
+
* change generation semantics, so they are refused rather than dropped; the
|
|
70
|
+
* text-only capability limits (tools, temperature, maxTokens) stay documented
|
|
71
|
+
* in `README.md` and are ignored, since every agent request carries tools.
|
|
72
|
+
* @param options - the request being prepared.
|
|
73
|
+
* @returns the failure to end the stream with, or `undefined` when the request is servable.
|
|
74
|
+
*/
|
|
75
|
+
function unsupportedOptionFailure(options) {
|
|
76
|
+
if (options.stop !== undefined && options.stop.length > 0) {
|
|
77
|
+
return {
|
|
78
|
+
message: 'chatjimmy accepts no stop sequences; remove the stop list or use a route that supports it',
|
|
79
|
+
code: 'UNSUPPORTED_OPTION',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
/** Read a failed response body as the service's JSON error envelope. */
|
|
85
|
+
async function errorDetail(response) {
|
|
86
|
+
try {
|
|
87
|
+
const text = await response.text();
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(text);
|
|
90
|
+
if (typeof parsed.error === 'string')
|
|
91
|
+
return parsed.error;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Not the JSON envelope; the raw text is all there is.
|
|
95
|
+
}
|
|
96
|
+
return text.slice(0, 300);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return '';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Map the stats block's own stop reason onto a harness finish reason.
|
|
104
|
+
* @param stats - parsed stats, when the stream carried them.
|
|
105
|
+
*/
|
|
106
|
+
function finishReasonFor(stats) {
|
|
107
|
+
if (isContextLimitReason(stats?.reason)) {
|
|
108
|
+
return { kind: 'error', failure: contextLimitFailure(stats?.reason) };
|
|
109
|
+
}
|
|
110
|
+
if (stats?.done_reason === 'length')
|
|
111
|
+
return { kind: 'max-tokens' };
|
|
112
|
+
return { kind: 'stop' };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Duck-typed adapter over `POST /api/chat`.
|
|
116
|
+
*
|
|
117
|
+
* `LlmRuntime` reaches adapters through plain method calls, so this object
|
|
118
|
+
* needs no harness base class. The plugin's only runtime dependency on
|
|
119
|
+
* `@deepseek-ai/*` is `@deepseek-ai/dsh-llm`'s pure helpers
|
|
120
|
+
* (`attributionHeaders()` and `resolveRetryPolicy()`), never its error classes
|
|
121
|
+
* or adapter base class.
|
|
122
|
+
*/
|
|
123
|
+
export class ChatJimmyAdapter {
|
|
124
|
+
#options;
|
|
125
|
+
#fetch;
|
|
126
|
+
/**
|
|
127
|
+
* @param config - the resolved configuration this adapter serves, or a
|
|
128
|
+
* provider that resolves it on every read. The plugin passes the live
|
|
129
|
+
* provider, so a settings edit (the Plugins card, `/alias`-style patch
|
|
130
|
+
* writes) reaches the next request without remounting the adapter; a plain
|
|
131
|
+
* value keeps the snapshot behavior for direct callers and tests.
|
|
132
|
+
* @param fetchImpl - transport override for tests.
|
|
133
|
+
*/
|
|
134
|
+
constructor(config, fetchImpl = globalThis.fetch) {
|
|
135
|
+
this.#options = typeof config === 'function' ? config : () => config;
|
|
136
|
+
this.#fetch = fetchImpl;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The configuration as it stands for this read. Resolving per access is what
|
|
140
|
+
* makes a live settings edit observable; `resolveConfig` is pure and cheap,
|
|
141
|
+
* and a read that cannot be resolved throws the same loud error the loader
|
|
142
|
+
* would have raised at mount.
|
|
143
|
+
*/
|
|
144
|
+
get #config() {
|
|
145
|
+
return this.#options();
|
|
146
|
+
}
|
|
147
|
+
/** {@inheritDoc LlmAdapterLike.providerInfo} */
|
|
148
|
+
providerInfo(provider) {
|
|
149
|
+
return { id: provider, name: 'Chat Jimmy (Taalas)' };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The provider-owned retry policy from configuration, already resolved, or
|
|
153
|
+
* `undefined` to leave the harness's normal defaults in place.
|
|
154
|
+
*
|
|
155
|
+
* This and {@link imageRequestPricing} exist because `LlmRuntime` calls them
|
|
156
|
+
* on every dispatch. A harness `LlmAdapter` subclass inherits them; a
|
|
157
|
+
* duck-typed adapter must supply them or the very first registration throws
|
|
158
|
+
* `adapter.providerRetryPolicy is not a function`.
|
|
159
|
+
*/
|
|
160
|
+
providerRetryPolicy(_provider) {
|
|
161
|
+
const policy = this.#config.retryPolicy;
|
|
162
|
+
return policy === undefined ? undefined : resolveRetryPolicy(policy, 'chatjimmy: retryPolicy');
|
|
163
|
+
}
|
|
164
|
+
/** No route charges visual tokens: this adapter is text-only. */
|
|
165
|
+
imageRequestPricing(_provider, _model) {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The advertised catalog. `/api/models` serves exactly one entry, so it is
|
|
170
|
+
* mirrored from configuration instead of costing a round trip on every model
|
|
171
|
+
* picker open. The id stays advisory: any id is accepted on the wire.
|
|
172
|
+
*/
|
|
173
|
+
listModels(provider) {
|
|
174
|
+
return Promise.resolve([{ provider, id: this.#config.model, ...CONFIGURED_MODEL }]);
|
|
175
|
+
}
|
|
176
|
+
/** {@inheritDoc LlmAdapterLike.resolveModel} */
|
|
177
|
+
resolveModel(provider, model, _signal) {
|
|
178
|
+
return Promise.resolve({
|
|
179
|
+
provider,
|
|
180
|
+
id: model,
|
|
181
|
+
...CONFIGURED_MODEL,
|
|
182
|
+
name: model === this.#config.model ? CONFIGURED_MODEL.name : model,
|
|
183
|
+
context: { contextWindow: this.#config.contextWindow },
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/** {@inheritDoc LlmAdapterLike.prepareCall} */
|
|
187
|
+
async prepareCall(provider, model, signal) {
|
|
188
|
+
return {
|
|
189
|
+
model: await this.resolveModel(provider, model, signal),
|
|
190
|
+
stream: (options) => this.stream(options),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Stream one completion. Everything the service sends is completion text up
|
|
195
|
+
* to the trailing stats block, which {@link StatsStreamFilter} removes; the
|
|
196
|
+
* block is then reported as harness usage.
|
|
197
|
+
*
|
|
198
|
+
* A zero-byte body with HTTP 200 is the service's signature for a request
|
|
199
|
+
* that overflowed the context window: the response headers are already
|
|
200
|
+
* committed as `text/event-stream`, so the backend's refusal cannot be
|
|
201
|
+
* delivered as an error status. That case is reported as
|
|
202
|
+
* `CONTEXT_WINDOW_EXCEEDED` rather than as an empty completion.
|
|
203
|
+
*/
|
|
204
|
+
async *stream(options) {
|
|
205
|
+
const config = this.#config;
|
|
206
|
+
const unsupported = unsupportedOptionFailure(options);
|
|
207
|
+
if (unsupported !== undefined) {
|
|
208
|
+
yield errorFinish(unsupported);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// The backend answers an empty `messages` list with HTTP 500, which the
|
|
212
|
+
// harness would retry as SERVER; the same request can never succeed, so it
|
|
213
|
+
// is refused here with a code the default retry policy leaves alone.
|
|
214
|
+
const body = buildChatRequest(options, config);
|
|
215
|
+
if (body.messages.length === 0) {
|
|
216
|
+
yield errorFinish({
|
|
217
|
+
message: 'chatjimmy: the request has no message text to send (the service needs at least one user or assistant turn)',
|
|
218
|
+
code: 'INVALID_REQUEST',
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
// The idle watchdog owns its own controller so a stalled body read can be
|
|
223
|
+
// torn down; the caller's signal is combined with it when present.
|
|
224
|
+
const consumer = new AbortController();
|
|
225
|
+
const signal = options.signal === undefined
|
|
226
|
+
? consumer.signal
|
|
227
|
+
: AbortSignal.any([options.signal, consumer.signal]);
|
|
228
|
+
let idleTimedOut = false;
|
|
229
|
+
let idleTimer;
|
|
230
|
+
/** True while the stream is expected to produce something. */
|
|
231
|
+
let idleWatching = false;
|
|
232
|
+
let idleStartedAt = 0;
|
|
233
|
+
const idleTimeoutMs = config.streamIdleTimeoutMs;
|
|
234
|
+
/**
|
|
235
|
+
* One timer wakes when the wait it covers would have outlived the bound and
|
|
236
|
+
* re-arms itself for the remainder otherwise, so a stream costs one timer
|
|
237
|
+
* instead of a `setTimeout`/`clearTimeout` pair per transport read.
|
|
238
|
+
*/
|
|
239
|
+
const checkIdle = () => {
|
|
240
|
+
idleTimer = undefined;
|
|
241
|
+
// Nothing outstanding: leave the timer unarmed; the next arm creates one.
|
|
242
|
+
if (!idleWatching)
|
|
243
|
+
return;
|
|
244
|
+
const elapsed = performance.now() - idleStartedAt;
|
|
245
|
+
if (elapsed >= idleTimeoutMs) {
|
|
246
|
+
idleTimedOut = true;
|
|
247
|
+
consumer.abort('chatjimmy: stream idle timeout');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
// The wake-up preceded the wait it covers: sleep the remainder.
|
|
251
|
+
idleTimer = setTimeout(checkIdle, idleTimeoutMs - elapsed);
|
|
252
|
+
};
|
|
253
|
+
const armIdle = () => {
|
|
254
|
+
idleWatching = true;
|
|
255
|
+
idleStartedAt = performance.now();
|
|
256
|
+
idleTimer ??= setTimeout(checkIdle, idleTimeoutMs);
|
|
257
|
+
};
|
|
258
|
+
/** The read resolved; consumer processing and backpressure are not provider idle time. */
|
|
259
|
+
const clearIdle = () => { idleWatching = false; };
|
|
260
|
+
/** No read or chunk is outstanding any more: leave no timer behind. */
|
|
261
|
+
const disarmIdle = () => {
|
|
262
|
+
idleWatching = false;
|
|
263
|
+
if (idleTimer !== undefined)
|
|
264
|
+
clearTimeout(idleTimer);
|
|
265
|
+
idleTimer = undefined;
|
|
266
|
+
};
|
|
267
|
+
const headers = {
|
|
268
|
+
'content-type': 'application/json',
|
|
269
|
+
'accept': 'text/event-stream',
|
|
270
|
+
...attributionHeaders(),
|
|
271
|
+
};
|
|
272
|
+
let response;
|
|
273
|
+
armIdle();
|
|
274
|
+
try {
|
|
275
|
+
response = await this.#fetch(`${config.baseUrl}/api/chat`, {
|
|
276
|
+
method: 'POST',
|
|
277
|
+
headers,
|
|
278
|
+
body: JSON.stringify(body),
|
|
279
|
+
signal,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
if (idleTimedOut) {
|
|
284
|
+
yield errorFinish(idleTimeoutFailure(idleTimeoutMs));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (options.signal?.aborted === true) {
|
|
288
|
+
yield abortFinish();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
yield errorFinish({
|
|
292
|
+
message: `chatjimmy: ${error instanceof Error ? error.message : String(error)}`,
|
|
293
|
+
code: 'TRANSPORT',
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
disarmIdle();
|
|
299
|
+
}
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
let detail;
|
|
302
|
+
armIdle();
|
|
303
|
+
try {
|
|
304
|
+
detail = await errorDetail(response);
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
disarmIdle();
|
|
308
|
+
}
|
|
309
|
+
yield idleTimedOut ? errorFinish(idleTimeoutFailure(idleTimeoutMs))
|
|
310
|
+
: options.signal?.aborted === true ? abortFinish()
|
|
311
|
+
: errorFinish(failureForStatus(response.status, detail, response.headers));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (response.body === null) {
|
|
315
|
+
yield errorFinish({ message: 'chatjimmy: response carried no body', code: 'TRANSPORT' });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const filter = new StatsStreamFilter();
|
|
319
|
+
const decoder = new TextDecoder();
|
|
320
|
+
// The block is opened lazily: a stream that yields no text must not
|
|
321
|
+
// publish an empty text block.
|
|
322
|
+
const index = 0;
|
|
323
|
+
let open = false;
|
|
324
|
+
let assembled = '';
|
|
325
|
+
try {
|
|
326
|
+
armIdle();
|
|
327
|
+
for await (const chunk of response.body) {
|
|
328
|
+
clearIdle();
|
|
329
|
+
const text = filter.push(decoder.decode(chunk, { stream: true }));
|
|
330
|
+
if (text.length > 0) {
|
|
331
|
+
if (!open) {
|
|
332
|
+
yield { type: 'block-start', index, blockType: 'text' };
|
|
333
|
+
open = true;
|
|
334
|
+
}
|
|
335
|
+
assembled += text;
|
|
336
|
+
yield { type: 'text-delta', index, text };
|
|
337
|
+
}
|
|
338
|
+
armIdle();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
if (idleTimedOut) {
|
|
343
|
+
yield errorFinish(idleTimeoutFailure(idleTimeoutMs));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (options.signal?.aborted === true) {
|
|
347
|
+
yield abortFinish();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
yield errorFinish({
|
|
351
|
+
message: `chatjimmy: stream ended abnormally: ${error instanceof Error ? error.message : String(error)}`,
|
|
352
|
+
code: 'TRANSPORT',
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
disarmIdle();
|
|
358
|
+
}
|
|
359
|
+
const tail = filter.push(decoder.decode()) + filter.flush();
|
|
360
|
+
if (tail.length > 0) {
|
|
361
|
+
if (!open) {
|
|
362
|
+
yield { type: 'block-start', index, blockType: 'text' };
|
|
363
|
+
open = true;
|
|
364
|
+
}
|
|
365
|
+
assembled += tail;
|
|
366
|
+
yield { type: 'text-delta', index, text: tail };
|
|
367
|
+
}
|
|
368
|
+
const stats = filter.stats;
|
|
369
|
+
if (!open) {
|
|
370
|
+
yield emptyStreamFinish(stats, config.contextWindow);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
yield { type: 'block-end', index, block: { type: 'text', text: assembled } };
|
|
374
|
+
// Usage is reported only when the provider reported it; a synthesized zero
|
|
375
|
+
// would claim a measurement that never happened.
|
|
376
|
+
const usage = mapUsage(stats);
|
|
377
|
+
if (usage !== undefined)
|
|
378
|
+
yield { type: 'usage', usage };
|
|
379
|
+
yield { type: 'finish', reason: finishReasonFor(stats) };
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* The failure for a stream that produced nothing within the configured bound.
|
|
384
|
+
* @param idleTimeoutMs - the configured per-read bound.
|
|
385
|
+
* @returns the terminal failure, coded `TIMEOUT`.
|
|
386
|
+
*/
|
|
387
|
+
function idleTimeoutFailure(idleTimeoutMs) {
|
|
388
|
+
return {
|
|
389
|
+
message: `chatjimmy: no stream data for ${idleTimeoutMs}ms (streamIdleTimeoutMs); the request was abandoned`,
|
|
390
|
+
code: 'TIMEOUT',
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Classify a stream that produced no text at all.
|
|
395
|
+
*
|
|
396
|
+
* The stats block is classified once, by {@link finishReasonFor}: a stream that
|
|
397
|
+
* carries the backend's context-limit reason ends as that failure, a completed
|
|
398
|
+
* one is an `EMPTY_RESPONSE`. Only a stream with no stats at all falls back to
|
|
399
|
+
* the zero-byte overflow signature.
|
|
400
|
+
*
|
|
401
|
+
* @param stats - stats the stream carried, if any.
|
|
402
|
+
* @param contextWindow - capacity reported to the harness, named in the message.
|
|
403
|
+
* @returns the terminal failure chunk.
|
|
404
|
+
*/
|
|
405
|
+
function emptyStreamFinish(stats, contextWindow) {
|
|
406
|
+
if (stats === undefined) {
|
|
407
|
+
return errorFinish({
|
|
408
|
+
message: `chatjimmy: the backend returned an empty stream. This is its signature for a request that overflowed`
|
|
409
|
+
+ ` the ${contextWindow}-token total context (prompt + completion); shorten the conversation and retry.`,
|
|
410
|
+
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const reason = finishReasonFor(stats);
|
|
414
|
+
if (reason.kind === 'error')
|
|
415
|
+
return errorFinish(reason.failure);
|
|
416
|
+
return errorFinish({
|
|
417
|
+
message: `chatjimmy: the model returned a completed response with no content`
|
|
418
|
+
+ ` (reason: ${String(stats.reason ?? 'unknown')})`,
|
|
419
|
+
code: 'EMPTY_RESPONSE',
|
|
420
|
+
});
|
|
421
|
+
}
|