@cyrilmarin/dsh-lemonade 0.2.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.
package/src/index.ts ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * ✨ Lemonade Server LLM provider plugin for the DeepSeek Harness.
3
+ *
4
+ * Registers a `LemonadeAdapter` for the `lemonade` provider route on
5
+ * `ctx.llm`, speaking the OpenAI-compatible Chat Completions API at a local
6
+ * (or remote) Lemonade Server. Connection facts resolve per request instead of
7
+ * at load: the plugin layers its `cordis.yml` entry config under the optional
8
+ * `llm-lemonade` user-settings section (`ctx.settings`) and resolves the
9
+ * optional API key through the credential seam (`ctx.credentials`) or the
10
+ * launch environment, so a changed base URL, catalog, or key reaches the next
11
+ * request without a restart, while an in-flight stream keeps the facts it
12
+ * started with. The one registration-captured fact — the retry policy —
13
+ * re-registers the route in place when it changes.
14
+ *
15
+ * @module dsh-lemonade-provider
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis';
18
+ import {
19
+ INVALID_CREDENTIAL_CODE,
20
+ LlmError,
21
+ RetryPolicySchema,
22
+ assertUsableApiKey,
23
+ normalizeApiKey,
24
+ resolveRetryPolicy,
25
+ } from '@deepseek-ai/dsh-llm';
26
+ import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm';
27
+ import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm';
28
+ import { credentialRef } from '@deepseek-ai/dsh-credentials';
29
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
30
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
31
+ import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment';
32
+ import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings';
33
+ import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout';
34
+ import z from '@deepseek-ai/schemastery';
35
+ import {
36
+ DEFAULT_BASE_URL,
37
+ DEFAULT_CONTEXT_WINDOW,
38
+ DEFAULT_MAX_TOKENS,
39
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
40
+ LemonadeAdapter,
41
+ discoverModels,
42
+ } from './adapter.js';
43
+ import type { LemonadeCatalogModel, LemonadeOptions } from './adapter.js';
44
+ import { API_ROUTE, createLemonadeApiHandler } from './server-api.js';
45
+
46
+ /** Short plugin name used in logs and configuration surfaces. */
47
+ export const name = 'llm-lemonade';
48
+ /** The provider route this plugin registers. */
49
+ export const PROVIDER = 'lemonade';
50
+ /** Hard service dependency: the LLM registry seat. */
51
+ export const inject = ['llm'];
52
+ /** Settings namespace this plugin owns. */
53
+ export const NS = settingsNamespace('llm-lemonade');
54
+
55
+ /** Environment variable naming this server's endpoint, honored from trusted layers only. */
56
+ export const BASE_URL_ENV = 'LEMONADE_BASE_URL';
57
+ /** Default environment variable naming the optional API key. */
58
+ export const DEFAULT_API_KEY_ENV = 'LEMONADE_API_KEY';
59
+ /** Default environment variable naming the optional admin API key (internal endpoints). */
60
+ export const DEFAULT_ADMIN_API_KEY_ENV = 'LEMONADE_ADMIN_API_KEY';
61
+
62
+ /** The static advisory model catalog schema (user-pinned entries). */
63
+ export const catalogModel = z.object({
64
+ id: z.string().required(),
65
+ name: z.string(),
66
+ description: z.string(),
67
+ contextWindow: z.number().step(1).min(1),
68
+ maxTokens: z.number().step(1).min(1),
69
+ vision: z.boolean(),
70
+ });
71
+
72
+ /**
73
+ * Resolved plugin configuration schema. All fields except `baseURL` have
74
+ * defaults; `baseURL` itself can come from `LEMONADE_BASE_URL` when unset.
75
+ */
76
+ export const Config: z<LemonadeResolvedConfig> = z.object({
77
+ baseURL: z.string(),
78
+ apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
79
+ adminApiKeyEnv: z.string().role('credential-ref').default(DEFAULT_ADMIN_API_KEY_ENV),
80
+ requireAuth: z.boolean().default(false),
81
+ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
82
+ maxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
83
+ models: z.array(catalogModel).default([]),
84
+ streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
85
+ retryPolicy: RetryPolicySchema,
86
+ });
87
+
88
+ /** Resolved plugin configuration (what the schema produces). */
89
+ export interface LemonadeResolvedConfig {
90
+ baseURL: string;
91
+ apiKeyEnv: string;
92
+ adminApiKeyEnv: string;
93
+ requireAuth: boolean;
94
+ defaultContextWindow: number;
95
+ maxTokens: number;
96
+ models: LemonadeCatalogModel[];
97
+ streamIdleTimeoutMs: number;
98
+ retryPolicy?: RetryPolicyConfig;
99
+ }
100
+
101
+ /** Raw composition entry: every field optional (schema defaults apply on resolution). */
102
+ export type LemonadeRawConfig = Partial<LemonadeResolvedConfig>;
103
+
104
+ /** Validate and detach the advisory model catalog. */
105
+ export function resolveModels(models: readonly LemonadeCatalogModel[] | undefined): LemonadeCatalogModel[] {
106
+ const seen = new Set<string>();
107
+ return (models ?? []).map((model) => {
108
+ if (model.id.length === 0) throw new Error('llm-lemonade: catalog model ids must be non-empty');
109
+ if (model.name !== undefined && model.name.length === 0) {
110
+ throw new Error(`llm-lemonade: catalog model "${model.id}" has an empty name`);
111
+ }
112
+ if (model.contextWindow !== undefined && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) {
113
+ throw new Error(`llm-lemonade: catalog model "${model.id}" contextWindow must be a positive integer`);
114
+ }
115
+ if (model.maxTokens !== undefined && (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)) {
116
+ throw new Error(`llm-lemonade: catalog model "${model.id}" maxTokens must be a positive integer`);
117
+ }
118
+ if (seen.has(model.id)) throw new Error(`llm-lemonade: duplicate catalog model "${model.id}"`);
119
+ seen.add(model.id);
120
+ return {
121
+ id: model.id,
122
+ ...(model.name !== undefined ? { name: model.name } : {}),
123
+ ...(model.description !== undefined ? { description: model.description } : {}),
124
+ ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
125
+ ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}),
126
+ ...(model.vision !== undefined ? { vision: model.vision } : {}),
127
+ };
128
+ });
129
+ }
130
+
131
+ function normalizeBaseURL(raw: string): string {
132
+ const trimmed = raw.trim();
133
+ if (trimmed.length === 0) throw new Error('llm-lemonade: baseURL must not be empty');
134
+ let url: URL;
135
+ try {
136
+ url = new URL(trimmed);
137
+ } catch {
138
+ throw new Error(`llm-lemonade: baseURL cannot be parsed: "${trimmed}"`);
139
+ }
140
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
141
+ throw new Error(`llm-lemonade: baseURL must use http or https, got "${trimmed}"`);
142
+ }
143
+ // The path prefix is ALWAYS /api: any configured path (including a
144
+ // root base or a legacy trailing /v1) is normalized to scheme://host/api,
145
+ // and every endpoint builder appends exactly one /v1 on top.
146
+ return url.protocol + '//' + url.host + '/api';
147
+ }
148
+
149
+ /**
150
+ * The explicit step from raw config to validated connection facts.
151
+ * Programmatic construction may bypass Schemastery normalization, so every
152
+ * default and bound is re-judged here — for the composition entry at load
153
+ * (fail loud) and for each settings snapshot at its first use.
154
+ *
155
+ * @param config - raw plugin config or resolved settings snapshot.
156
+ * @param environment - this run's environment layers, when the product CLI provided them.
157
+ * @returns validated connection facts plus the credential reference.
158
+ */
159
+ export function resolveAdapterOptions(
160
+ config: LemonadeRawConfig,
161
+ environment?: LaunchEnvironmentSnapshot,
162
+ ): LemonadeOptions {
163
+ if (config.defaultContextWindow !== undefined && (!Number.isInteger(config.defaultContextWindow) || config.defaultContextWindow <= 0)) {
164
+ throw new Error('llm-lemonade: defaultContextWindow must be a positive integer');
165
+ }
166
+ if (config.maxTokens !== undefined && (!Number.isSafeInteger(config.maxTokens) || config.maxTokens <= 0)) {
167
+ throw new Error('llm-lemonade: maxTokens must be a positive safe integer');
168
+ }
169
+ const streamIdleTimeoutMs = config.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
170
+ if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
171
+ throw new Error(`llm-lemonade: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
172
+ }
173
+ const rawBase = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? DEFAULT_BASE_URL;
174
+ return {
175
+ apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
176
+ adminApiKeyEnv: credentialRef(config.adminApiKeyEnv ?? DEFAULT_ADMIN_API_KEY_ENV),
177
+ baseURL: normalizeBaseURL(rawBase),
178
+ requireAuth: config.requireAuth === true,
179
+ defaultContextWindow: config.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
180
+ maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
181
+ models: resolveModels(config.models),
182
+ streamIdleTimeoutMs,
183
+ retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-lemonade: retryPolicy'),
184
+ };
185
+ }
186
+
187
+ /** Resolve the optional bearer token, enforcing `requireAuth` when configured. */
188
+ function makeResolveApiKey(ctx: Context, options: () => LemonadeOptions): () => Promise<string | undefined> {
189
+ return async () => {
190
+ const connection = options();
191
+ const ref = connection.apiKeyEnv;
192
+ let value: string | undefined;
193
+ const credentials = ctx.get('credentials');
194
+ if (credentials !== undefined) value = (await credentials.resolve(ref))?.value;
195
+ if (value === undefined) value = launchEnvironmentOf(ctx).get(ref)?.value;
196
+ if (value === undefined || value.length === 0) {
197
+ if (connection.requireAuth) {
198
+ throw new LlmError(
199
+ `llm-lemonade: no API key for provider route "${PROVIDER}"; store ${String(ref)} through the credentials service (the web Models page writes it), or export ${String(ref)} in the launching environment`,
200
+ 'MISSING_CREDENTIAL',
201
+ );
202
+ }
203
+ return undefined;
204
+ }
205
+ return assertUsableApiKey(value, 'llm-lemonade', String(ref));
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Register a {@link LemonadeAdapter} for the `lemonade` provider route.
211
+ * See the module header for the layering story.
212
+ */
213
+ export function apply(ctx: Context, config: LemonadeRawConfig): void {
214
+ let current = () => config;
215
+ let lastRaw: unknown;
216
+ let lastGood: LemonadeOptions | undefined;
217
+ const options = (): LemonadeOptions => {
218
+ const raw = current();
219
+ if (raw === lastRaw && lastGood !== undefined) return lastGood;
220
+ try {
221
+ const next = resolveAdapterOptions(raw as LemonadeRawConfig, launchEnvironmentOf(ctx));
222
+ lastRaw = raw;
223
+ lastGood = next;
224
+ return next;
225
+ } catch (error) {
226
+ if (lastGood === undefined) throw error;
227
+ lastRaw = raw;
228
+ ctx.logger.error('llm-lemonade: keeping the last good configuration after an invalid settings section');
229
+ ctx.logger.error(error);
230
+ return lastGood;
231
+ }
232
+ };
233
+ options();
234
+
235
+ const resolveApiKey = makeResolveApiKey(ctx, options);
236
+ const adapter = new LemonadeAdapter({
237
+ options,
238
+ resolveApiKey,
239
+ resolveAttachments: () => ctx.get('attachments'),
240
+ });
241
+
242
+ ctx.llm.registerConfigurableProviders([
243
+ { provider: PROVIDER, displayName: 'Lemonade', settingsNs: NS, settingsPath: [] },
244
+ ]);
245
+
246
+ const registration = ctx.llm.registerAdapter([PROVIDER], adapter);
247
+ let registeredPolicy = options().retryPolicy;
248
+ const ensureRegistrationFacts = (): void => {
249
+ const policy = options().retryPolicy;
250
+ if (deepEqualJson(policy, registeredPolicy)) return;
251
+ registration.replace([PROVIDER]);
252
+ registeredPolicy = policy;
253
+ };
254
+
255
+ ctx.llm.registerModelDiscovery(NS, async (request: LlmModelDiscoveryRequest): Promise<readonly LlmDiscoveredModel[]> => {
256
+ const baseURL = request.baseURL !== undefined && request.baseURL.length > 0 ? normalizeBaseURL(request.baseURL) : options().baseURL;
257
+ let apiKey: string | undefined;
258
+ if (request.apiKey !== undefined) {
259
+ const checked = normalizeApiKey(request.apiKey);
260
+ if (!checked.ok) {
261
+ throw new LlmError(
262
+ checked.reason === 'empty'
263
+ ? 'this server\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated'
264
+ : 'this server\'s API key contains characters no HTTP header can carry; paste the raw key only',
265
+ INVALID_CREDENTIAL_CODE,
266
+ );
267
+ }
268
+ apiKey = checked.value;
269
+ } else {
270
+ apiKey = await resolveApiKey();
271
+ }
272
+ return discoverModels(baseURL, apiKey, request.signal);
273
+ });
274
+
275
+ // Lemonade-specific API proxy: browser client half calls these routes
276
+ // same-origin; the keys are resolved host-side and never reach the browser.
277
+ // Per-endpoint key selection (regular vs admin) lives in server-api.ts.
278
+ const resolveKey = async (ref: CredentialRef): Promise<string | undefined> => {
279
+ let value: string | undefined;
280
+ const credentials = ctx.get('credentials');
281
+ if (credentials !== undefined) value = (await credentials.resolve(ref))?.value;
282
+ if (value === undefined) value = launchEnvironmentOf(ctx).get(ref)?.value;
283
+ if (value === undefined || value.length === 0) return undefined;
284
+ return assertUsableApiKey(value, 'llm-lemonade', String(ref));
285
+ };
286
+ const apiCfg = {
287
+ baseURL: () => options().baseURL,
288
+ requireAuth: () => options().requireAuth,
289
+ apiKeyRef: () => options().apiKeyEnv,
290
+ adminApiKeyRef: () => options().adminApiKeyEnv,
291
+ resolveKey,
292
+ };
293
+ // Register the proxy route once the webServer service is available. It is
294
+ // mounted late (after the llm service this plugin depends on), so a strict
295
+ // ctx.get() at apply time is usually undefined; instead grab it eagerly
296
+ // (non-strict) and fall back to binding the route when 'webServer' is
297
+ // provided. Absent webServer (headless profile), nothing is registered and
298
+ // the host half keeps working as a plain model provider.
299
+ let routesRegistered = false;
300
+ const registerRoutes = (): void => {
301
+ if (routesRegistered) return;
302
+ const server = ctx.get('webServer', false);
303
+ if (server === undefined) return;
304
+ routesRegistered = true;
305
+ ctx.effect(() => server.register({ kind: 'prefix', path: API_ROUTE, handler: createLemonadeApiHandler(apiCfg) }));
306
+ };
307
+ registerRoutes();
308
+ if (!routesRegistered) {
309
+ ctx.on('internal/service', (name: string | symbol) => {
310
+ if (name === 'webServer') registerRoutes();
311
+ });
312
+ }
313
+
314
+ installSettingsSection(ctx, NS, Config, config as LemonadeResolvedConfig, {
315
+ setSource: (source) => {
316
+ current = source;
317
+ },
318
+ onChange: ensureRegistrationFacts,
319
+ });
320
+ }
321
+
322
+ export { LemonadeAdapter };
323
+ export type { LemonadeOptions } from './adapter.js';
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Serialize harness messages into Lemonade's OpenAI-compatible chat
3
+ * completions wire format.
4
+ *
5
+ * User text is joined; user image blocks become OpenAI `image_url` data-URL
6
+ * parts (Lemonade serves vision models through the same endpoint); assistant
7
+ * text becomes `content`, tool calls become `tool_calls`, and tool results
8
+ * become standalone `{role: 'tool'}` messages. Reasoning blocks are not
9
+ * replayed on the wire: Lemonade's OpenAI route has no reasoning passback
10
+ * field (unlike DeepSeek's `reasoning_content`), and re-sending thinking
11
+ * text as plain content would corrupt the conversation.
12
+ *
13
+ * @module dsh-lemonade-provider/serialize
14
+ */
15
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
16
+ import type { ContentBlock, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm';
17
+ import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm';
18
+
19
+ /** One OpenAI function tool call as sent on the wire. */
20
+ export interface WireToolCall {
21
+ id: string;
22
+ type: 'function';
23
+ function: { name: string; arguments: string };
24
+ }
25
+
26
+ /** OpenAI content parts for user messages that carry images. */
27
+ export type WireContentPart =
28
+ | { type: 'text'; text: string }
29
+ | { type: 'image_url'; image_url: { url: string } };
30
+
31
+ /** One OpenAI chat-completions wire message. */
32
+ export type WireMessage =
33
+ | { role: 'system'; content: string }
34
+ | { role: 'user'; content: string | WireContentPart[] }
35
+ | { role: 'assistant'; content: string | null; tool_calls?: WireToolCall[] }
36
+ | { role: 'tool'; tool_call_id: string; content: string };
37
+
38
+ /**
39
+ * Resolve one durable image reference into the data: URL sent as an
40
+ * `image_url` part. Provided by the adapter from the attachment service.
41
+ */
42
+ export type ResolveImage = (attachment: ImageAttachmentRef, signal?: AbortSignal) => Promise<string>;
43
+
44
+ /** Join the text blocks of a message (user and tool-result content). */
45
+ function flattenText(blocks: readonly ContentBlock[]): string {
46
+ return blocks
47
+ .filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
48
+ .map((block) => block.text)
49
+ .join('');
50
+ }
51
+
52
+ /**
53
+ * Serialize a user message's content. Text-only messages stay a plain string;
54
+ * a message carrying one or more image blocks becomes an array of parts that
55
+ * preserves block order.
56
+ */
57
+ async function serializeUserContent(
58
+ blocks: readonly ContentBlock[],
59
+ resolveImage: ResolveImage,
60
+ signal: AbortSignal | undefined,
61
+ ): Promise<string | WireContentPart[]> {
62
+ const parts: WireContentPart[] = [];
63
+ let hasImage = false;
64
+ for (const block of blocks) {
65
+ if (block.type === 'text') {
66
+ parts.push({ type: 'text', text: block.text });
67
+ } else if (block.type === 'image') {
68
+ hasImage = true;
69
+ parts.push({ type: 'image_url', image_url: { url: await resolveImage(block.attachment, signal) } });
70
+ }
71
+ // tool-result blocks are expanded into standalone tool messages by the caller.
72
+ }
73
+ if (!hasImage) return parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
74
+ return parts;
75
+ }
76
+
77
+ /** Serialize one assistant message (text + tool calls; reasoning dropped). */
78
+ function serializeAssistant(message: Message): WireMessage {
79
+ const text = flattenText(message.content);
80
+ const toolCalls: WireToolCall[] = message.content
81
+ .filter((block): block is Extract<ContentBlock, { type: 'tool-call' }> => block.type === 'tool-call')
82
+ .map((block) => ({
83
+ id: block.id,
84
+ type: 'function' as const,
85
+ function: { name: block.name, arguments: block.arguments },
86
+ }));
87
+ if (toolCalls.length === 0) return { role: 'assistant', content: text };
88
+ // OpenAI canonical form: content is null when the turn is tool calls only.
89
+ return { role: 'assistant', content: text.length > 0 ? text : null, tool_calls: toolCalls };
90
+ }
91
+
92
+ /**
93
+ * Serialize the conversation in order. A mixed user message contributes its
94
+ * text/image content first and each tool result as a standalone wire message
95
+ * after it.
96
+ *
97
+ * @param messages - the harness conversation, in order.
98
+ * @param resolveImage - resolves image blocks; required whenever one is present.
99
+ * @param signal - cancellation forwarded to attachment reads.
100
+ */
101
+ async function serializeMessages(
102
+ messages: readonly Message[],
103
+ resolveImage: ResolveImage,
104
+ signal: AbortSignal | undefined,
105
+ ): Promise<WireMessage[]> {
106
+ const wire: WireMessage[] = [];
107
+ for (const message of messages) {
108
+ if (message.role === 'system') {
109
+ wire.push({ role: 'system', content: flattenText(message.content) });
110
+ continue;
111
+ }
112
+ if (message.role === 'assistant') {
113
+ wire.push(serializeAssistant(message));
114
+ continue;
115
+ }
116
+ const toolResults = message.content.filter(
117
+ (block): block is Extract<ContentBlock, { type: 'tool-result' }> => block.type === 'tool-result',
118
+ );
119
+ const content = await serializeUserContent(message.content, resolveImage, signal);
120
+ const hasText = typeof content === 'string' ? content.length > 0 : true;
121
+ if (hasText || toolResults.length === 0) wire.push({ role: 'user', content });
122
+ for (const result of toolResults) {
123
+ wire.push({ role: 'tool', tool_call_id: result.toolCallId, content: flattenText(result.content) || '(no output)' });
124
+ }
125
+ }
126
+ return wire;
127
+ }
128
+
129
+ /**
130
+ * Build the full wire request body. Always streaming with usage reporting on
131
+ * (Lemonade's llamacpp backends honor `stream_options.include_usage`; servers
132
+ * that ignore it simply never send a usage chunk, and the translate step
133
+ * tolerates that). Optional sampling fields are omitted rather than sent as
134
+ * null so provider defaults apply.
135
+ *
136
+ * @param options - the harness request (model, history, system, tools, sampling).
137
+ * @param resolveImage - resolves image blocks; `undefined` when no attachment
138
+ * service is available, in which case image content is refused up front.
139
+ */
140
+ export async function serializeRequest(
141
+ options: GenerateOptions,
142
+ resolveImage: ResolveImage | undefined,
143
+ ): Promise<Record<string, unknown>> {
144
+ if (resolveImage === undefined && options.messages.some((message) => contentHasImage(message.content))) {
145
+ throw new LlmError('The Lemonade adapter requires the attachment service to send image content.', 'UNSUPPORTED_CONTENT');
146
+ }
147
+ const messages: WireMessage[] = [];
148
+ if (options.system !== undefined) messages.push({ role: 'system', content: options.system });
149
+ messages.push(...(await serializeMessages(
150
+ options.messages,
151
+ resolveImage ?? (() => { throw new LlmError('no image resolver', 'UNSUPPORTED_CONTENT'); }),
152
+ options.signal,
153
+ )));
154
+ const tools: { type: 'function'; function: ToolSchema & { description: string } }[] | undefined =
155
+ options.tools?.length
156
+ ? options.tools.map((tool) => ({
157
+ type: 'function' as const,
158
+ function: { name: tool.name, description: tool.description, parameters: tool.parameters },
159
+ }))
160
+ : undefined;
161
+ return {
162
+ model: options.model,
163
+ messages,
164
+ stream: true,
165
+ stream_options: { include_usage: true },
166
+ ...(tools !== undefined ? { tools } : {}),
167
+ ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
168
+ ...(options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens }),
169
+ ...(options.stop !== undefined ? { stop: options.stop } : {}),
170
+ };
171
+ }