@morlay/dsh-llm-openai-compatible 0.0.1 → 0.0.2

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,497 @@
1
+ import type { Context } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import {
4
+ LlmError,
5
+ RetryPolicySchema,
6
+ assertUsableApiKey,
7
+ resolveRetryPolicy,
8
+ } from "@deepseek-ai/dsh-llm";
9
+ import type { ModelModality, RetryPolicyConfig } from "@deepseek-ai/dsh-llm";
10
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
11
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
12
+ import { deepEqualJson } from "@deepseek-ai/dsh-util-values";
13
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
14
+ import { getOrCreateAnonymousUserId } from "@deepseek-ai/dsh-anonymous-user-id";
15
+ import {
16
+ DEFAULT_CONTEXT_WINDOW,
17
+ DEFAULT_MAX_REQUEST_IMAGE_BYTES,
18
+ DEFAULT_MAX_TOKENS,
19
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
20
+ OpenAICompatibleAdapter,
21
+ } from "./adapter.ts";
22
+ import type { ReasoningEffort, ResolvedModelProfile, ResolvedProviderProfile } from "./adapter.ts";
23
+
24
+ export { OpenAICompatibleAdapter } from "./adapter.ts";
25
+ export type {
26
+ OpenAICompatibleAdapterOptions,
27
+ ReasoningEffort,
28
+ ResolvedModelProfile,
29
+ ResolvedProviderProfile,
30
+ } from "./adapter.ts";
31
+ export {
32
+ DEFAULT_CONTEXT_WINDOW,
33
+ DEFAULT_MAX_REQUEST_IMAGE_BYTES,
34
+ DEFAULT_MAX_TOKENS,
35
+ DEFAULT_STREAM_IDLE_TIMEOUT_MS,
36
+ } from "./adapter.ts";
37
+
38
+ export const name = "llm-openai-compatible";
39
+ export const inject = ["llm"];
40
+ export const NS = "llm-openai-compatible";
41
+
42
+ export const REASONING_LEVELS = ["off", "low", "high", "max"] as const;
43
+
44
+ export const MODEL_MODALITIES = ["text", "image"] as const;
45
+
46
+ export interface ModelProfileSource {
47
+ id: string;
48
+ name?: string;
49
+ description?: string;
50
+ contextWindow?: number;
51
+ maxTokens?: number;
52
+ inputModalities?: ModelModality[];
53
+ reasoningEfforts?: false | Partial<Record<ReasoningEffort, string | null>>;
54
+ }
55
+
56
+ export interface ProviderProfileSource {
57
+ apiKeyEnv?: string;
58
+
59
+ displayName?: string;
60
+
61
+ baseURL: string;
62
+
63
+ headers?: Record<string, string>;
64
+ // === sampling defaults (request-level values win) ===
65
+ temperature?: number;
66
+ topP?: number;
67
+ topK?: number;
68
+ presencePenalty?: number;
69
+ frequencyPenalty?: number;
70
+ seed?: number;
71
+
72
+ reasoning?: ReasoningEffort;
73
+
74
+ models?: ModelProfileSource[];
75
+ defaultContextWindow?: number;
76
+ defaultMaxTokens?: number;
77
+ maxRequestImageBytes?: number;
78
+ streamIdleTimeoutMs?: number;
79
+
80
+ timeoutMs?: number;
81
+ retryPolicy?: RetryPolicyConfig;
82
+ }
83
+
84
+ export interface Config {
85
+ providers?: Record<string, ProviderProfileSource>;
86
+ }
87
+
88
+ const modelSchema = z.object({
89
+ id: z.string().required(),
90
+ name: z.string(),
91
+ description: z.string(),
92
+ contextWindow: z.number().step(1).min(1),
93
+ maxTokens: z.number().step(1).min(1),
94
+ inputModalities: z.array(z.union(MODEL_MODALITIES)).min(1).default(["text"]),
95
+ reasoningEfforts: z.union([z.const(false), z.dict(z.union([z.string(), z.const(null)]))]),
96
+ });
97
+
98
+ const providerSchema = z.object({
99
+ apiKeyEnv: z.string().role("credential-ref"),
100
+ displayName: z.string(),
101
+ baseURL: z.string().required(),
102
+ headers: z.dict(z.string()),
103
+ temperature: z.number().min(0).max(2),
104
+ topP: z.number().min(0).max(1),
105
+ topK: z.number().step(1).min(1),
106
+ presencePenalty: z.number().min(-2).max(2),
107
+ frequencyPenalty: z.number().min(-2).max(2),
108
+ seed: z.number().step(1).min(1),
109
+ reasoning: z.union(REASONING_LEVELS),
110
+ models: z.array(modelSchema),
111
+ defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
112
+ defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
113
+ maxRequestImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REQUEST_IMAGE_BYTES),
114
+ streamIdleTimeoutMs: z
115
+ .number()
116
+ .min(Number.MIN_VALUE)
117
+ .max(MAX_TIMER_DELAY_MS)
118
+ .default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
119
+ timeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS),
120
+ retryPolicy: RetryPolicySchema,
121
+ });
122
+
123
+ export const Config: z<Config> = z.object({
124
+ providers: z.dict(providerSchema).default({}),
125
+ });
126
+
127
+ function isReasoningEffort(value: string): value is ReasoningEffort {
128
+ return (REASONING_LEVELS as readonly string[]).includes(value);
129
+ }
130
+
131
+ function resolveReasoningEfforts(
132
+ provider: string,
133
+ modelId: string,
134
+ value: ModelProfileSource["reasoningEfforts"],
135
+ ): Pick<ResolvedModelProfile, "reasoningEfforts"> {
136
+ if (value === void 0) return {};
137
+ if (value === false) return { reasoningEfforts: false };
138
+ const declaration: Partial<Record<ReasoningEffort, string | null>> = {};
139
+ for (const [effort, wire] of Object.entries(value)) {
140
+ if (!isReasoningEffort(effort)) {
141
+ throw new Error(
142
+ `llm-openai-compatible: provider "${provider}" model "${modelId}" declares unknown reasoning effort "${effort}"`,
143
+ );
144
+ }
145
+ if (effort === "off") {
146
+ if (wire !== null) {
147
+ throw new Error(
148
+ `llm-openai-compatible: provider "${provider}" model "${modelId}" reasoning effort "off" must leave an empty wire spelling (null) to omit reasoning_effort`,
149
+ );
150
+ }
151
+ declaration.off = null;
152
+ continue;
153
+ }
154
+ if (wire === null || wire.length === 0) {
155
+ throw new Error(
156
+ `llm-openai-compatible: provider "${provider}" model "${modelId}" reasoning effort "${effort}" needs a non-empty wire spelling`,
157
+ );
158
+ }
159
+ declaration[effort] = wire;
160
+ }
161
+ return { reasoningEfforts: declaration };
162
+ }
163
+
164
+ function resolveModels(
165
+ provider: string,
166
+ models: readonly ModelProfileSource[] | undefined,
167
+ ): readonly ResolvedModelProfile[] {
168
+ if (models === void 0) return [];
169
+ const seen = new Set<string>();
170
+ return models.map((model) => {
171
+ if (model.id.length === 0)
172
+ throw new Error(
173
+ `llm-openai-compatible: provider "${provider}" catalog model ids must be non-empty`,
174
+ );
175
+ if (model.name !== void 0 && model.name.length === 0)
176
+ throw new Error(
177
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" has an empty name`,
178
+ );
179
+ if (
180
+ model.contextWindow !== void 0 &&
181
+ (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)
182
+ ) {
183
+ throw new Error(
184
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" contextWindow must be a positive integer`,
185
+ );
186
+ }
187
+ if (
188
+ model.maxTokens !== void 0 &&
189
+ (!Number.isInteger(model.maxTokens) || model.maxTokens <= 0)
190
+ ) {
191
+ throw new Error(
192
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" maxTokens must be a positive integer`,
193
+ );
194
+ }
195
+ const inputModalities = model.inputModalities ?? ["text"];
196
+ if (inputModalities.length === 0)
197
+ throw new Error(
198
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" inputModalities must not be empty`,
199
+ );
200
+ if (
201
+ inputModalities.some(
202
+ (modality) => !(MODEL_MODALITIES as readonly string[]).includes(modality),
203
+ )
204
+ ) {
205
+ throw new Error(
206
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" inputModalities must contain only "text" and "image"`,
207
+ );
208
+ }
209
+ if (new Set(inputModalities).size !== inputModalities.length) {
210
+ throw new Error(
211
+ `llm-openai-compatible: provider "${provider}" catalog model "${model.id}" inputModalities must not contain duplicates`,
212
+ );
213
+ }
214
+ if (seen.has(model.id))
215
+ throw new Error(
216
+ `llm-openai-compatible: provider "${provider}" has duplicate catalog model "${model.id}"`,
217
+ );
218
+ seen.add(model.id);
219
+ return {
220
+ id: model.id,
221
+ ...(model.name === void 0 ? {} : { name: model.name }),
222
+ ...(model.description === void 0 ? {} : { description: model.description }),
223
+ ...(model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow }),
224
+ ...(model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }),
225
+ inputModalities: [...inputModalities],
226
+ ...resolveReasoningEfforts(provider, model.id, model.reasoningEfforts),
227
+ };
228
+ });
229
+ }
230
+
231
+ function bounded(value: number | undefined, lo: number, hi: number): number | undefined {
232
+ if (value === void 0) return void 0;
233
+ if (!Number.isFinite(value) || value < lo || value > hi) return void 0;
234
+ return value;
235
+ }
236
+
237
+ export function resolveAdapterOptions(
238
+ provider: string,
239
+ source: ProviderProfileSource,
240
+ ): ResolvedProviderProfile {
241
+ if (provider.length === 0)
242
+ throw new Error("llm-openai-compatible: provider names must be non-empty");
243
+ if (source.baseURL === void 0 || source.baseURL.length === 0) {
244
+ throw new Error(`llm-openai-compatible: provider "${provider}" requires a non-empty baseURL`);
245
+ }
246
+ if (source.displayName !== void 0 && source.displayName.length === 0) {
247
+ throw new Error(`llm-openai-compatible: provider "${provider}" has an empty displayName`);
248
+ }
249
+ const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS;
250
+ if (
251
+ !Number.isFinite(streamIdleTimeoutMs) ||
252
+ streamIdleTimeoutMs <= 0 ||
253
+ streamIdleTimeoutMs > MAX_TIMER_DELAY_MS
254
+ ) {
255
+ throw new Error(
256
+ `llm-openai-compatible: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
257
+ );
258
+ }
259
+ const maxRequestImageBytes = source.maxRequestImageBytes ?? DEFAULT_MAX_REQUEST_IMAGE_BYTES;
260
+ if (!Number.isSafeInteger(maxRequestImageBytes) || maxRequestImageBytes <= 0) {
261
+ throw new Error(
262
+ `llm-openai-compatible: provider "${provider}" maxRequestImageBytes must be a positive safe integer`,
263
+ );
264
+ }
265
+ const defaultContextWindow = source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW;
266
+ if (!Number.isInteger(defaultContextWindow) || defaultContextWindow <= 0) {
267
+ throw new Error(
268
+ `llm-openai-compatible: provider "${provider}" defaultContextWindow must be a positive integer`,
269
+ );
270
+ }
271
+ const defaultMaxTokens = source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS;
272
+ if (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0) {
273
+ throw new Error(
274
+ `llm-openai-compatible: provider "${provider}" defaultMaxTokens must be a positive safe integer`,
275
+ );
276
+ }
277
+ const timeoutMs = bounded(source.timeoutMs, Number.MIN_VALUE, MAX_TIMER_DELAY_MS);
278
+ if (source.timeoutMs !== void 0 && timeoutMs === void 0) {
279
+ throw new Error(
280
+ `llm-openai-compatible: provider "${provider}" timeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
281
+ );
282
+ }
283
+ if (bounded(source.temperature, 0, 2) === void 0 && source.temperature !== void 0) {
284
+ throw new Error(
285
+ `llm-openai-compatible: provider "${provider}" temperature must be a finite number within 0..2`,
286
+ );
287
+ }
288
+ if (bounded(source.topP, 0, 1) === void 0 && source.topP !== void 0) {
289
+ throw new Error(
290
+ `llm-openai-compatible: provider "${provider}" topP must be a finite number within 0..1`,
291
+ );
292
+ }
293
+ if (source.topK !== void 0 && (!Number.isInteger(source.topK) || source.topK <= 0)) {
294
+ throw new Error(
295
+ `llm-openai-compatible: provider "${provider}" topK must be a positive integer`,
296
+ );
297
+ }
298
+ if (bounded(source.presencePenalty, -2, 2) === void 0 && source.presencePenalty !== void 0) {
299
+ throw new Error(
300
+ `llm-openai-compatible: provider "${provider}" presencePenalty must be a finite number within -2..2`,
301
+ );
302
+ }
303
+ if (bounded(source.frequencyPenalty, -2, 2) === void 0 && source.frequencyPenalty !== void 0) {
304
+ throw new Error(
305
+ `llm-openai-compatible: provider "${provider}" frequencyPenalty must be a finite number within -2..2`,
306
+ );
307
+ }
308
+ if (source.seed !== void 0 && (!Number.isInteger(source.seed) || source.seed <= 0)) {
309
+ throw new Error(
310
+ `llm-openai-compatible: provider "${provider}" seed must be a positive integer`,
311
+ );
312
+ }
313
+ if (source.reasoning !== void 0 && !isReasoningEffort(source.reasoning)) {
314
+ throw new Error(
315
+ `llm-openai-compatible: provider "${provider}" reasoning must be one of ${REASONING_LEVELS.join(", ")}`,
316
+ );
317
+ }
318
+ return {
319
+ provider,
320
+ displayName: source.displayName ?? provider,
321
+ ...(source.apiKeyEnv === void 0 ? {} : { apiKeyEnv: credentialRef(source.apiKeyEnv) }),
322
+ baseURL: source.baseURL,
323
+ ...(source.headers === void 0 ? {} : { headers: { ...source.headers } }),
324
+ ...(source.temperature === void 0 ? {} : { temperature: source.temperature }),
325
+ ...(source.topP === void 0 ? {} : { topP: source.topP }),
326
+ ...(source.topK === void 0 ? {} : { topK: source.topK }),
327
+ ...(source.presencePenalty === void 0 ? {} : { presencePenalty: source.presencePenalty }),
328
+ ...(source.frequencyPenalty === void 0 ? {} : { frequencyPenalty: source.frequencyPenalty }),
329
+ ...(source.seed === void 0 ? {} : { seed: source.seed }),
330
+ ...(source.reasoning === void 0 ? {} : { reasoning: source.reasoning }),
331
+ models: resolveModels(provider, source.models),
332
+ defaultContextWindow,
333
+ defaultMaxTokens,
334
+ maxRequestImageBytes,
335
+ streamIdleTimeoutMs,
336
+ ...(timeoutMs === void 0 ? {} : { timeoutMs }),
337
+ retryPolicy: resolveRetryPolicy(
338
+ source.retryPolicy,
339
+ `llm-openai-compatible: provider "${provider}" retryPolicy`,
340
+ ),
341
+ };
342
+ }
343
+
344
+ export function resolveProfiles(
345
+ providers: Readonly<Record<string, ProviderProfileSource>> | undefined,
346
+ ): Map<string, ResolvedProviderProfile> {
347
+ if (Array.isArray(providers))
348
+ throw new Error(
349
+ "llm-openai-compatible: providers is now a dict keyed by provider route, not an array of profiles",
350
+ );
351
+ const resolved = new Map<string, ResolvedProviderProfile>();
352
+ for (const [provider, source] of Object.entries(providers ?? {})) {
353
+ resolved.set(provider, resolveAdapterOptions(provider, source));
354
+ }
355
+ return resolved;
356
+ }
357
+
358
+ export function assertServiceable(config: Config): void {
359
+ resolveProfiles(config.providers);
360
+ }
361
+
362
+ function registrationFacts(profiles: ReadonlyMap<string, ResolvedProviderProfile>): unknown[] {
363
+ return [...profiles.entries()]
364
+ .map(([provider, profile]) => ({
365
+ provider,
366
+ displayName: profile.displayName,
367
+ retryPolicy: profile.retryPolicy,
368
+ }))
369
+ .sort((left, right) => left.provider.localeCompare(right.provider));
370
+ }
371
+
372
+ function directoryEntries(profiles: ReadonlyMap<string, ResolvedProviderProfile>): {
373
+ provider: string;
374
+ displayName: string;
375
+ settingsNs: typeof NS;
376
+ settingsPath: readonly string[];
377
+ declared: boolean;
378
+ }[] {
379
+ const entries = new Map<
380
+ string,
381
+ {
382
+ provider: string;
383
+ displayName: string;
384
+ settingsNs: typeof NS;
385
+ settingsPath: readonly string[];
386
+ declared: boolean;
387
+ }
388
+ >();
389
+ for (const [provider, profile] of profiles) {
390
+ entries.set(provider, {
391
+ provider,
392
+ displayName: profile.displayName,
393
+ settingsNs: NS,
394
+ settingsPath: ["providers", provider],
395
+ declared: true,
396
+ });
397
+ }
398
+ return [...entries.values()];
399
+ }
400
+
401
+ export function apply(ctx: Context, config: Config): void {
402
+ let current = () => config;
403
+ let lastRaw: Config | undefined;
404
+ let memoized: Map<string, ResolvedProviderProfile> | undefined;
405
+
406
+ const profiles = (): ReadonlyMap<string, ResolvedProviderProfile> => {
407
+ const raw = current();
408
+ if (raw === lastRaw && memoized !== void 0) return memoized;
409
+ const next = resolveProfiles(raw.providers);
410
+ lastRaw = raw;
411
+ memoized = next;
412
+ return next;
413
+ };
414
+ profiles();
415
+ const resolveApiKey = async (
416
+ provider: string,
417
+ profile: ResolvedProviderProfile,
418
+ ): Promise<string | undefined> => {
419
+ const ref = profile.apiKeyEnv;
420
+ if (ref === void 0) return void 0;
421
+ const credentials = ctx.get("credentials");
422
+ if (credentials !== void 0) {
423
+ const hit = await credentials.resolve(ref);
424
+ if (hit !== void 0) return assertUsableApiKey(hit.value, "llm-openai-compatible", ref);
425
+ } else {
426
+ const ambient = launchEnvironmentOf(ctx).get(ref);
427
+ if (ambient !== void 0 && ambient.value.length > 0)
428
+ return assertUsableApiKey(ambient.value, "llm-openai-compatible", ref);
429
+ }
430
+ throw new LlmError(
431
+ `llm-openai-compatible: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not set — store ${ref} through the credentials service (the web Models page writes it), or export ${ref} in the launching environment`,
432
+ "MISSING_CREDENTIAL",
433
+ );
434
+ };
435
+ let userId: string | undefined;
436
+ const resolveUserId = () => (userId ??= getOrCreateAnonymousUserId());
437
+ const adapter = new OpenAICompatibleAdapter({
438
+ profiles,
439
+ resolveApiKey,
440
+ resolveUserId,
441
+ resolveAttachments: () => ctx.get("attachments"),
442
+ });
443
+ let directory: ReturnType<typeof ctx.llm.registerConfigurableProviders> | undefined;
444
+ let directoryFacts: unknown[] | undefined;
445
+ const ensureDirectory = () => {
446
+ const entries = directoryEntries(profiles());
447
+ if (deepEqualJson(entries, directoryFacts)) return;
448
+ if (directory === void 0) directory = ctx.llm.registerConfigurableProviders(entries);
449
+ else directory.replace(entries);
450
+ directoryFacts = entries;
451
+ };
452
+ ensureDirectory();
453
+ let registration: ReturnType<typeof ctx.llm.registerAdapter> | undefined;
454
+ let registeredFacts: unknown[] | undefined;
455
+ const ensureRegistrationFacts = () => {
456
+ const facts = registrationFacts(profiles());
457
+ if (deepEqualJson(facts, registeredFacts)) return;
458
+ const routes = [...profiles().keys()];
459
+ if (registration === void 0) {
460
+ if (routes.length === 0) {
461
+ registeredFacts = facts;
462
+ return;
463
+ }
464
+ registration = ctx.llm.registerAdapter(routes, adapter);
465
+ } else {
466
+ registration.replace(routes);
467
+ }
468
+ registeredFacts = facts;
469
+ };
470
+ ensureRegistrationFacts();
471
+ ctx.inject(["settings"], (settingsCtx) => {
472
+ settingsCtx.settings.installSection(ctx, NS, Config, config, {
473
+ validate: assertServiceable,
474
+ setSource: (source) => {
475
+ current = source;
476
+ },
477
+ onChange: () => {
478
+ try {
479
+ ensureRegistrationFacts();
480
+ } catch (error) {
481
+ ctx.logger.error(
482
+ "llm-openai-compatible: keeping the previously registered routes after a refused update",
483
+ );
484
+ ctx.logger.error(error);
485
+ }
486
+ try {
487
+ ensureDirectory();
488
+ } catch (error) {
489
+ ctx.logger.error(
490
+ "llm-openai-compatible: keeping the previous configurable-provider directory after a refused update",
491
+ );
492
+ ctx.logger.error(error);
493
+ }
494
+ },
495
+ });
496
+ });
497
+ }