mohdel 0.90.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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +377 -0
  3. package/config/benchmarks.json +39 -0
  4. package/js/client/call.js +75 -0
  5. package/js/client/call_image.js +82 -0
  6. package/js/client/gate-binary.js +72 -0
  7. package/js/client/index.js +16 -0
  8. package/js/client/ndjson.js +29 -0
  9. package/js/client/transport.js +48 -0
  10. package/js/core/envelope.js +141 -0
  11. package/js/core/errors.js +75 -0
  12. package/js/core/events.js +96 -0
  13. package/js/core/image.js +58 -0
  14. package/js/core/index.js +10 -0
  15. package/js/core/status.js +48 -0
  16. package/js/factory/bridge.js +372 -0
  17. package/js/session/_cooldown.js +114 -0
  18. package/js/session/_logger.js +138 -0
  19. package/js/session/_rate_limiter.js +77 -0
  20. package/js/session/_tracing.js +58 -0
  21. package/js/session/adapters/_cancelled.js +44 -0
  22. package/js/session/adapters/_catalog.js +58 -0
  23. package/js/session/adapters/_chat_completions.js +439 -0
  24. package/js/session/adapters/_errors.js +85 -0
  25. package/js/session/adapters/_images.js +60 -0
  26. package/js/session/adapters/_lazy_json_cache.js +76 -0
  27. package/js/session/adapters/_pricing.js +67 -0
  28. package/js/session/adapters/_providers.js +60 -0
  29. package/js/session/adapters/_tools.js +185 -0
  30. package/js/session/adapters/_videos.js +283 -0
  31. package/js/session/adapters/anthropic.js +397 -0
  32. package/js/session/adapters/cerebras.js +28 -0
  33. package/js/session/adapters/deepseek.js +32 -0
  34. package/js/session/adapters/echo.js +51 -0
  35. package/js/session/adapters/fake.js +262 -0
  36. package/js/session/adapters/fireworks.js +46 -0
  37. package/js/session/adapters/gemini.js +381 -0
  38. package/js/session/adapters/groq.js +23 -0
  39. package/js/session/adapters/image/fake.js +55 -0
  40. package/js/session/adapters/image/index.js +40 -0
  41. package/js/session/adapters/image/novita.js +135 -0
  42. package/js/session/adapters/image/openai.js +50 -0
  43. package/js/session/adapters/index.js +53 -0
  44. package/js/session/adapters/mistral.js +31 -0
  45. package/js/session/adapters/novita.js +29 -0
  46. package/js/session/adapters/openai.js +381 -0
  47. package/js/session/adapters/openrouter.js +66 -0
  48. package/js/session/adapters/xai.js +27 -0
  49. package/js/session/bin.js +54 -0
  50. package/js/session/driver.js +160 -0
  51. package/js/session/index.js +18 -0
  52. package/js/session/run.js +393 -0
  53. package/js/session/run_image.js +61 -0
  54. package/package.json +107 -0
  55. package/src/cli/ask.js +160 -0
  56. package/src/cli/backup.js +107 -0
  57. package/src/cli/bench.js +262 -0
  58. package/src/cli/check.js +123 -0
  59. package/src/cli/colored-logger.js +67 -0
  60. package/src/cli/colors.js +13 -0
  61. package/src/cli/default.js +39 -0
  62. package/src/cli/index.js +150 -0
  63. package/src/cli/json-output.js +60 -0
  64. package/src/cli/model.js +571 -0
  65. package/src/cli/onboard.js +232 -0
  66. package/src/cli/rank.js +176 -0
  67. package/src/cli/ratelimit.js +160 -0
  68. package/src/cli/tag.js +105 -0
  69. package/src/lib/assets/alibaba.svg +1 -0
  70. package/src/lib/assets/anthropic.svg +5 -0
  71. package/src/lib/assets/deepseek.svg +1 -0
  72. package/src/lib/assets/gemini.svg +1 -0
  73. package/src/lib/assets/google.svg +2 -0
  74. package/src/lib/assets/kwaipilot.svg +1 -0
  75. package/src/lib/assets/meta.svg +1 -0
  76. package/src/lib/assets/minimax.svg +9 -0
  77. package/src/lib/assets/moonshotai.svg +4 -0
  78. package/src/lib/assets/openai.svg +5 -0
  79. package/src/lib/assets/xai.svg +1 -0
  80. package/src/lib/assets/xiaomi.svg +2 -0
  81. package/src/lib/assets/zai.svg +219 -0
  82. package/src/lib/benchmark-score.js +215 -0
  83. package/src/lib/benchmark-truth.js +68 -0
  84. package/src/lib/cache.js +76 -0
  85. package/src/lib/common.js +208 -0
  86. package/src/lib/cooldown.js +63 -0
  87. package/src/lib/creators.js +71 -0
  88. package/src/lib/curated-cache.js +146 -0
  89. package/src/lib/errors.js +126 -0
  90. package/src/lib/index.js +726 -0
  91. package/src/lib/logger.js +29 -0
  92. package/src/lib/providers.js +87 -0
  93. package/src/lib/rank.js +390 -0
  94. package/src/lib/rate-limiter.js +50 -0
  95. package/src/lib/schema.js +150 -0
  96. package/src/lib/select.js +474 -0
  97. package/src/lib/tracing.js +62 -0
  98. package/src/lib/utils.js +85 -0
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Google Gemini (`@google/genai`) adapter.
3
+ *
4
+ * Scope:
5
+ * - Text in, text out, streaming
6
+ * - Status contract (incomplete + warning on MAX_TOKENS)
7
+ * - Tools: unified format → Gemini FunctionDeclaration; functionCall
8
+ * parts collected per chunk; tool_use terminal state;
9
+ * functionResponse parts on the way back in
10
+ * - Images (inlineData for base64 / data URIs, fileData for https://)
11
+ * - Videos (inline ≤20MB, upload + poll for larger or when
12
+ * `cache: true`, content-hashed cache at `~/.cache/mohdel/uploaded-files.json`)
13
+ * - AbortSignal forwarded to SDK
14
+ *
15
+ * @module session/adapters/gemini
16
+ */
17
+
18
+ import { GoogleGenAI } from '@google/genai'
19
+
20
+ import {
21
+ STATUS_COMPLETED,
22
+ STATUS_INCOMPLETE,
23
+ STATUS_TOOL_USE,
24
+ WARNING_INSUFFICIENT_OUTPUT_BUDGET
25
+ } from '#core/status.js'
26
+
27
+ import { cancelledDone } from './_cancelled.js'
28
+ import { getSpec } from './_catalog.js'
29
+ import { classifyProviderError } from './_errors.js'
30
+ import { loadImages } from './_images.js'
31
+ import { loadVideos } from './_videos.js'
32
+ import { costFor } from './_pricing.js'
33
+ import {
34
+ toGeminiTools,
35
+ fromGeminiToolCalls,
36
+ toToolChoice
37
+ } from './_tools.js'
38
+
39
+ /**
40
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
41
+ * @param {{client?: GoogleGenAI, signal?: AbortSignal, log?: any, span?: any}} [deps]
42
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
43
+ */
44
+ export async function * gemini (envelope, deps = {}) {
45
+ const client = deps.client ?? new GoogleGenAI({ apiKey: envelope.auth.key })
46
+ const signal = deps.signal
47
+ const log = deps.log
48
+ const start = String(process.hrtime.bigint())
49
+ let first = null
50
+
51
+ const { systemInstruction, contents } = buildContents(envelope.prompt)
52
+
53
+ if (envelope.images?.length) {
54
+ try {
55
+ const loaded = await loadImages(envelope.images)
56
+ const parts = loaded.map(toGeminiImagePart).filter(Boolean)
57
+ if (parts.length) injectParts(contents, parts)
58
+ } catch (e) {
59
+ log?.warn({ err: e }, '[mohdel:gemini] image load failed')
60
+ yield { type: 'error', error: classifyProviderError(e) }
61
+ return
62
+ }
63
+ }
64
+
65
+ if (envelope.videos?.length) {
66
+ try {
67
+ const parts = await loadVideos(envelope.videos, {
68
+ client,
69
+ useCache: !!envelope.cache,
70
+ signal
71
+ })
72
+ if (parts.length) injectParts(contents, parts)
73
+ } catch (e) {
74
+ if (signal?.aborted) {
75
+ yield cancelledDone(start, first, envelope, '', 0, 0)
76
+ return
77
+ }
78
+ log?.warn({ err: e }, '[mohdel:gemini] video load failed')
79
+ // `typed` lets _videos.js surface PROVIDER_UNAVAILABLE on
80
+ // upload-deadline timeouts; fall back to generic classification.
81
+ const typed = /** @type {any} */(e).typed
82
+ yield { type: 'error', error: typed || classifyProviderError(e) }
83
+ return
84
+ }
85
+ }
86
+
87
+ const request = buildRequest(envelope, contents, systemInstruction)
88
+
89
+ // The Google `@google/genai` SDK reads abortSignal exclusively
90
+ // from `params.config.abortSignal` — a second-arg `{signal}` is
91
+ // dropped (verified against the compiled SDK at
92
+ // node_modules/@google/genai/dist/index.cjs). Merge into config
93
+ // so cancellation actually tears down the HTTPS request, not just
94
+ // the local loop.
95
+ if (signal) {
96
+ request.config = { ...(request.config ?? {}), abortSignal: signal }
97
+ }
98
+
99
+ // F53: accumulate via array + join to avoid per-delta V8 cons-string
100
+ // churn. Materialized at each exit point.
101
+ const outputParts = []
102
+ const currentOutput = () => outputParts.join('')
103
+ let inputTokens = 0
104
+ let outputTokens = 0
105
+ let thinkingTokens = 0
106
+ let status = STATUS_COMPLETED
107
+ /** @type {string | undefined} */
108
+ let warning
109
+ /** @type {Array<{name: string, args: any}>} */
110
+ const collectedFunctionCalls = []
111
+
112
+ try {
113
+ const stream = await client.models.generateContentStream(request)
114
+
115
+ for await (const chunk of stream) {
116
+ if (signal?.aborted) {
117
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
118
+ return
119
+ }
120
+
121
+ for (const part of chunk?.candidates?.[0]?.content?.parts ?? []) {
122
+ if (typeof part.text === 'string' && part.text.length > 0) {
123
+ if (first === null) first = String(process.hrtime.bigint())
124
+ outputParts.push(part.text)
125
+ yield { type: 'delta', delta: { type: 'message', delta: part.text } }
126
+ } else if (part.functionCall) {
127
+ if (first === null) first = String(process.hrtime.bigint())
128
+ // `thoughtSignature` is sibling to `functionCall` on the part.
129
+ // Hoist it onto the collected object so `fromGeminiToolCalls`
130
+ // can propagate it through to the unified toolCalls shape,
131
+ // and any replay of this assistant turn puts the signature
132
+ // back on the outgoing part (required for gemini to accept
133
+ // a prior tool call on the next turn).
134
+ collectedFunctionCalls.push(
135
+ part.thoughtSignature
136
+ ? { ...part.functionCall, thoughtSignature: part.thoughtSignature }
137
+ : part.functionCall
138
+ )
139
+ // Gemini sends complete functionCall parts (not streamed args).
140
+ // Emit a single function_call delta with the serialized args
141
+ // so consumers that watch for delta chunks see something.
142
+ const delta = JSON.stringify(part.functionCall.args ?? {})
143
+ yield {
144
+ type: 'delta',
145
+ delta: { type: 'function_call', delta }
146
+ }
147
+ }
148
+ }
149
+
150
+ const finish = chunk?.candidates?.[0]?.finishReason
151
+ if (finish && isIncompleteFinish(finish)) {
152
+ status = STATUS_INCOMPLETE
153
+ if (finish === 'MAX_TOKENS') warning = WARNING_INSUFFICIENT_OUTPUT_BUDGET
154
+ }
155
+
156
+ if (chunk?.usageMetadata) {
157
+ if (typeof chunk.usageMetadata.promptTokenCount === 'number') {
158
+ inputTokens = chunk.usageMetadata.promptTokenCount
159
+ }
160
+ if (typeof chunk.usageMetadata.candidatesTokenCount === 'number') {
161
+ outputTokens = chunk.usageMetadata.candidatesTokenCount
162
+ }
163
+ if (typeof chunk.usageMetadata.thoughtsTokenCount === 'number') {
164
+ thinkingTokens = chunk.usageMetadata.thoughtsTokenCount
165
+ }
166
+ }
167
+ }
168
+ } catch (e) {
169
+ if (signal?.aborted) {
170
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
171
+ return
172
+ }
173
+ log?.warn({ err: e }, '[mohdel:gemini] stream failed')
174
+ yield { type: 'error', error: classifyProviderError(e) }
175
+ return
176
+ }
177
+
178
+ if (signal?.aborted) {
179
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
180
+ return
181
+ }
182
+
183
+ if (collectedFunctionCalls.length > 0 && status === STATUS_COMPLETED) {
184
+ status = STATUS_TOOL_USE
185
+ }
186
+
187
+ const end = String(process.hrtime.bigint())
188
+ /** @type {import('#core/events.js').DoneEvent} */
189
+ const done = {
190
+ type: 'done',
191
+ result: {
192
+ status,
193
+ output: currentOutput() || null,
194
+ inputTokens,
195
+ outputTokens,
196
+ thinkingTokens,
197
+ cost: costFor(
198
+ `${envelope.provider}/${envelope.model}`,
199
+ { inputTokens, outputTokens, thinkingTokens }
200
+ ),
201
+ timestamps: { start, first: first ?? end, end }
202
+ }
203
+ }
204
+ if (warning) done.result.warning = warning
205
+ if (collectedFunctionCalls.length > 0) {
206
+ done.result.toolCalls = fromGeminiToolCalls(collectedFunctionCalls)
207
+ }
208
+ yield done
209
+ }
210
+
211
+ /**
212
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
213
+ * @param {Array<{role: string, parts: any[]}>} contents
214
+ * @param {string} systemInstruction
215
+ */
216
+ function buildRequest (envelope, contents, systemInstruction) {
217
+ const spec = getSpec(`${envelope.provider}/${envelope.model}`)
218
+
219
+ /** @type {Record<string, any>} */
220
+ const config = {}
221
+ if (systemInstruction) config.systemInstruction = systemInstruction
222
+ if (envelope.outputBudget !== undefined) config.maxOutputTokens = envelope.outputBudget
223
+ if (envelope.tools?.length) {
224
+ config.tools = toGeminiTools(envelope.tools)
225
+ if (envelope.toolChoice) {
226
+ config.toolConfig = toToolChoice('gemini', envelope.toolChoice)
227
+ }
228
+ }
229
+
230
+ // Thinking — model-family-dependent shape:
231
+ // - gemini-3.x: thinkingConfig = { includeThoughts: true, thinkingLevel: <name> }
232
+ // - gemini-2.x: thinkingConfig = { thinkingBudget: <number> }
233
+ // - other (e.g. gemini-1.5): thinkingBudget with maxOutputTokens
234
+ // adjustment for headroom
235
+ const effort = envelope.outputEffort ?? spec?.defaultThinkingEffort
236
+ if (spec?.thinkingEffortLevels && effort && effort !== 'none') {
237
+ const budget = spec.thinkingEffortLevels[effort]
238
+ if (/^gemini-3/.test(envelope.model)) {
239
+ config.thinkingConfig = { includeThoughts: true, thinkingLevel: effort }
240
+ } else if (/gemini-2/.test(envelope.model)) {
241
+ if (typeof budget === 'number') {
242
+ config.thinkingConfig = { thinkingBudget: budget }
243
+ }
244
+ } else {
245
+ if (typeof budget === 'number') {
246
+ config.thinkingConfig = { thinkingBudget: budget }
247
+ if (config.maxOutputTokens && budget > 0) {
248
+ config.maxOutputTokens += budget
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ /** @type {Record<string, any>} */
255
+ const request = {
256
+ model: envelope.model,
257
+ contents
258
+ }
259
+ if (Object.keys(config).length > 0) request.config = config
260
+ return request
261
+ }
262
+
263
+ /** @param {string | import('#core/envelope.js').Message[]} prompt */
264
+ function buildContents (prompt) {
265
+ if (typeof prompt === 'string') {
266
+ return {
267
+ systemInstruction: '',
268
+ contents: [{ role: 'user', parts: [{ text: prompt }] }]
269
+ }
270
+ }
271
+ /** @type {string[]} */
272
+ const systemParts = []
273
+ /** @type {Array<{role: string, parts: any[]}>} */
274
+ const contents = []
275
+ for (const m of prompt) {
276
+ if (m.role === 'system') {
277
+ systemParts.push(flattenText(m.content))
278
+ } else if (m.role === 'tool') {
279
+ contents.push({
280
+ role: 'user',
281
+ parts: [{
282
+ functionResponse: {
283
+ name: m.toolName ?? '',
284
+ response: safeParseToolResult(m.content)
285
+ }
286
+ }]
287
+ })
288
+ } else if (m.role === 'assistant' && m.toolCalls?.length) {
289
+ // Gemini expects a single `model` turn carrying both text and
290
+ // functionCall parts. When replaying a prior assistant turn, the
291
+ // `thoughtSignature` attached to each tool call by gemini on the
292
+ // original response must ride back into the part — without it,
293
+ // the next call is rejected as a hand-constructed history.
294
+ const parts = []
295
+ const text = flattenText(m.content)
296
+ if (text) parts.push({ text })
297
+ for (const tc of m.toolCalls) {
298
+ const part = {
299
+ functionCall: { name: tc.name, args: tc.arguments ?? {} }
300
+ }
301
+ if (tc.thoughtSignature) part.thoughtSignature = tc.thoughtSignature
302
+ parts.push(part)
303
+ }
304
+ contents.push({ role: 'model', parts })
305
+ } else {
306
+ contents.push({
307
+ role: mapRole(m.role),
308
+ parts: toGeminiParts(m.content)
309
+ })
310
+ }
311
+ }
312
+ return {
313
+ systemInstruction: systemParts.filter(Boolean).join('\n\n'),
314
+ contents
315
+ }
316
+ }
317
+
318
+ /** @param {string | import('#core/envelope.js').MessagePart[]} content */
319
+ function safeParseToolResult (content) {
320
+ const text = flattenText(content)
321
+ try { return JSON.parse(text) } catch { return { result: text } }
322
+ }
323
+
324
+ /** @param {string} role */
325
+ function mapRole (role) {
326
+ if (role === 'assistant') return 'model'
327
+ return role
328
+ }
329
+
330
+ /** @param {string | import('#core/envelope.js').MessagePart[]} content */
331
+ function flattenText (content) {
332
+ if (typeof content === 'string') return content
333
+ return content.filter(p => p.type === 'text' && p.text).map(p => p.text).join('\n')
334
+ }
335
+
336
+ /** @param {string | import('#core/envelope.js').MessagePart[]} content */
337
+ function toGeminiParts (content) {
338
+ if (typeof content === 'string') return [{ text: content }]
339
+ return content.map(p => {
340
+ if (p.type === 'text') return { text: p.text ?? '' }
341
+ throw new Error(`unsupported content part type: ${p.type}`)
342
+ })
343
+ }
344
+
345
+ /** @param {import('./_images.js').LoadedImage} img */
346
+ function toGeminiImagePart (img) {
347
+ if (img.base64) {
348
+ return { inlineData: { mimeType: img.mimeType, data: img.base64 } }
349
+ }
350
+ if (img.url) {
351
+ return { fileData: { mimeType: img.mimeType, fileUri: img.url } }
352
+ }
353
+ return null
354
+ }
355
+
356
+ /**
357
+ * Append media parts to the LAST user message in `contents`. Used
358
+ * for both images and videos — Gemini's `parts` array is homogeneous
359
+ * so the injection logic doesn't care which media type.
360
+ *
361
+ * @param {Array<{role: string, parts: any[]}>} contents
362
+ * @param {Array<any>} parts
363
+ */
364
+ function injectParts (contents, parts) {
365
+ for (let i = contents.length - 1; i >= 0; i--) {
366
+ if (contents[i].role !== 'user') continue
367
+ contents[i].parts = [...contents[i].parts, ...parts]
368
+ return
369
+ }
370
+ contents.push({ role: 'user', parts })
371
+ }
372
+
373
+ /** @param {string} reason */
374
+ function isIncompleteFinish (reason) {
375
+ return reason === 'MAX_TOKENS' ||
376
+ reason === 'SAFETY' ||
377
+ reason === 'RECITATION' ||
378
+ reason === 'BLOCKLIST' ||
379
+ reason === 'PROHIBITED_CONTENT' ||
380
+ reason === 'SPII'
381
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Groq adapter — OpenAI-compatible chat completions, non-streaming.
3
+ *
4
+ * @module session/adapters/groq
5
+ */
6
+
7
+ import Groq from 'groq-sdk'
8
+
9
+ import { runChatCompletions } from './_chat_completions.js'
10
+
11
+ /**
12
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
13
+ * @param {{client?: any, signal?: AbortSignal, log?: any, span?: any}} [deps]
14
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
15
+ */
16
+ export async function * groq (envelope, deps = {}) {
17
+ const client = deps.client ?? new Groq({ apiKey: envelope.auth.key })
18
+ yield * runChatCompletions(envelope, client, { provider: 'groq' }, {
19
+ signal: deps.signal,
20
+ log: deps.log,
21
+ span: deps.span
22
+ })
23
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Fake image adapter — scenario-driven for tests, benchmarks, and
3
+ * bug reproductions. Never calls a real API.
4
+ *
5
+ * Mirrors the `fake` answer adapter shape: the envelope's `prompt`
6
+ * carries a JSON scenario spec; the `mode` key picks a behavior.
7
+ * Invalid / non-JSON prompts fall through to `mode: "ok"`.
8
+ *
9
+ * ## Modes
10
+ *
11
+ * | mode | params | behavior |
12
+ * |---------|---------------------------|---------------------------------------------|
13
+ * | `ok` | `count?` (default 1) | returns `count` placeholder image URLs |
14
+ * | `error` | `type`, `message` | throws a tagged error |
15
+ *
16
+ * @module session/adapters/image/fake
17
+ */
18
+
19
+ /**
20
+ * @param {import('#core/image.js').ImageEnvelope} envelope
21
+ * @returns {Promise<import('#core/image.js').ImageResult>}
22
+ */
23
+ export async function fakeImage (envelope) {
24
+ const scenario = parseScenario(envelope.prompt)
25
+ const mode = scenario.mode ?? 'ok'
26
+
27
+ if (mode === 'error') {
28
+ const err = new Error(scenario.message || 'fake image error')
29
+ err.typed = {
30
+ message: scenario.message || 'fake image error',
31
+ severity: 'error',
32
+ retryable: !!scenario.retryable,
33
+ type: scenario.type || 'PROVIDER_ERROR'
34
+ }
35
+ throw err
36
+ }
37
+
38
+ const count = Math.max(1, Number(scenario.count) || 1)
39
+ const now = `${process.hrtime.bigint()}`
40
+ return {
41
+ status: 'completed',
42
+ images: Array.from({ length: count }, (_, i) => ({
43
+ mimeType: 'image/png',
44
+ url: `https://fake.example/img-${envelope.callId}-${i}.png`
45
+ })),
46
+ seed: envelope.seed ?? null,
47
+ timestamps: { start: now, first: now, end: now }
48
+ }
49
+ }
50
+
51
+ /** @param {unknown} prompt */
52
+ function parseScenario (prompt) {
53
+ if (typeof prompt !== 'string') return {}
54
+ try { return JSON.parse(prompt) || {} } catch { return {} }
55
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Image-adapter registry. Mirrors session/adapters/index.js but
3
+ * scoped to image-generation providers.
4
+ *
5
+ * @module session/adapters/image
6
+ */
7
+
8
+ import { openaiImage } from './openai.js'
9
+ import { novitaImage } from './novita.js'
10
+ import { fakeImage } from './fake.js'
11
+
12
+ const IMAGE_ADAPTERS = {
13
+ openai: openaiImage,
14
+ novita: novitaImage,
15
+ fake: fakeImage
16
+ }
17
+
18
+ /**
19
+ * @param {string} provider
20
+ * @returns {(
21
+ * env: import('#core/image.js').ImageEnvelope,
22
+ * deps?: any
23
+ * ) => Promise<import('#core/image.js').ImageResult>}
24
+ */
25
+ export function getImageAdapter (provider) {
26
+ const adapter = IMAGE_ADAPTERS[provider]
27
+ if (!adapter) throw new Error(`no image adapter for provider: ${provider}`)
28
+ return adapter
29
+ }
30
+
31
+ /**
32
+ * Whether the provider has an image adapter registered. Used by
33
+ * `run.js` to distinguish "wrong call path" (image-only provider
34
+ * invoked via answer) from "truly unknown provider".
35
+ *
36
+ * @param {string} provider
37
+ */
38
+ export function isImageProvider (provider) {
39
+ return Object.prototype.hasOwnProperty.call(IMAGE_ADAPTERS, provider)
40
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Novita image adapter.
3
+ *
4
+ * Async two-step: submit generation task, poll for completion.
5
+ *
6
+ * `spec.imageEndpoint` on the curated entry selects the route
7
+ * (e.g. `txt2img`, `flux-dev`). `fetch` and `sleep` are injectable
8
+ * for testability.
9
+ *
10
+ * @module session/adapters/image/novita
11
+ */
12
+
13
+ import { getSpec } from '../_catalog.js'
14
+ import { classifyProviderError } from '../_errors.js'
15
+
16
+ const BASE_URL = 'https://api.novita.ai'
17
+ const NOVITA_TASK_POLL_INTERVAL_MS = 1000
18
+ const MAX_POLL_MS = 120_000
19
+
20
+ /**
21
+ * @param {import('#core/image.js').ImageEnvelope} envelope
22
+ * @param {{
23
+ * fetch?: typeof fetch,
24
+ * sleep?: (ms: number) => Promise<void>,
25
+ * now?: () => number,
26
+ * spec?: any
27
+ * }} [deps]
28
+ * @returns {Promise<import('#core/image.js').ImageResult>}
29
+ */
30
+ export async function novitaImage (envelope, deps = {}) {
31
+ const fetchFn = deps.fetch ?? globalThis.fetch
32
+ const sleep = deps.sleep ?? defaultSleep
33
+ const now = deps.now ?? Date.now
34
+
35
+ const spec = deps.spec ?? getSpec(`${envelope.provider}/${envelope.model}`) ?? {}
36
+ const endpoint = spec.imageEndpoint
37
+ if (!endpoint) {
38
+ throw typedError('image endpoint not configured', 'PROVIDER_ERROR', false)
39
+ }
40
+
41
+ const start = String(process.hrtime.bigint())
42
+ const apiKey = envelope.auth.key
43
+
44
+ const body = { prompt: envelope.prompt }
45
+ const size = envelope.size || spec.imageDefaultSize
46
+ if (size) body.size = size.replace('x', '*')
47
+ if (envelope.seed != null) body.seed = envelope.seed
48
+
49
+ const submitUrl = `${BASE_URL}/v3/async/${endpoint}`
50
+ const submit = await post(fetchFn, submitUrl, body, apiKey)
51
+
52
+ const result = await pollTaskResult(fetchFn, sleep, now, submit.task_id, apiKey)
53
+
54
+ const images = (result.images || []).map(img => ({
55
+ mimeType: img.image_type ? `image/${img.image_type}` : 'image/png',
56
+ url: img.image_url || img.url
57
+ }))
58
+
59
+ const end = String(process.hrtime.bigint())
60
+ return {
61
+ status: 'completed',
62
+ images,
63
+ seed: result.task?.seed ?? null,
64
+ timestamps: { start, first: end, end }
65
+ }
66
+ }
67
+
68
+ /** @param {number} ms */
69
+ function defaultSleep (ms) {
70
+ return new Promise(resolve => setTimeout(resolve, ms))
71
+ }
72
+
73
+ async function post (fetchFn, url, body, apiKey) {
74
+ let res
75
+ try {
76
+ res = await fetchFn(url, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ Authorization: `Bearer ${apiKey}`
81
+ },
82
+ body: JSON.stringify(body)
83
+ })
84
+ } catch (e) {
85
+ throw typedError(classifyProviderError(e).message, 'NET_ERROR', true)
86
+ }
87
+ if (!res.ok) {
88
+ const text = await res.text().catch(() => '')
89
+ throw fromHttpStatus(res.status, 'novita submit failed', text.slice(0, 120))
90
+ }
91
+ return res.json()
92
+ }
93
+
94
+ async function pollTaskResult (fetchFn, sleep, now, taskId, apiKey) {
95
+ const url = `${BASE_URL}/v3/async/task-result?task_id=${taskId}`
96
+ const deadline = now() + MAX_POLL_MS
97
+
98
+ while (now() < deadline) {
99
+ const res = await fetchFn(url, { headers: { Authorization: `Bearer ${apiKey}` } })
100
+ if (!res.ok) {
101
+ const text = await res.text().catch(() => '')
102
+ throw fromHttpStatus(res.status, 'novita poll failed', text.slice(0, 120))
103
+ }
104
+ const data = await res.json()
105
+ const status = data.task?.status
106
+ if (status === 'TASK_STATUS_SUCCEED') return data
107
+ if (status === 'TASK_STATUS_FAILED') {
108
+ throw typedError(
109
+ 'novita image failed',
110
+ 'PROVIDER_ERROR',
111
+ false,
112
+ data.task?.reason || 'unknown'
113
+ )
114
+ }
115
+ await sleep(NOVITA_TASK_POLL_INTERVAL_MS)
116
+ }
117
+
118
+ throw typedError('novita image generation timed out', 'PROVIDER_UNAVAILABLE', true)
119
+ }
120
+
121
+ function fromHttpStatus (status, message, detail) {
122
+ const typed = classifyProviderError({ status })
123
+ // Keep the classifier's message (stable/machine-readable); put the
124
+ // caller's context + any response-body snippet into `detail`. F45:
125
+ // never echo provider response bodies into `TypedError.message`.
126
+ return typedError(typed.message, typed.type, typed.retryable, detail ? `${message}: ${detail}` : message)
127
+ }
128
+
129
+ function typedError (message, type, retryable, detail) {
130
+ const err = new Error(message)
131
+ const typed = { message, severity: retryable ? 'warn' : 'error', retryable, type }
132
+ if (detail) typed.detail = detail
133
+ err.typed = typed
134
+ return err
135
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * OpenAI DALL-E image adapter.
3
+ *
4
+ * Single-shot generation — no streaming. Returns an `ImageResult`.
5
+ *
6
+ * @module session/adapters/image/openai
7
+ */
8
+
9
+ import OpenAI from 'openai'
10
+
11
+ import { getSpec } from '../_catalog.js'
12
+ import { classifyProviderError } from '../_errors.js'
13
+
14
+ /**
15
+ * @param {import('#core/image.js').ImageEnvelope} envelope
16
+ * @param {{client?: OpenAI, spec?: any}} [deps]
17
+ * @returns {Promise<import('#core/image.js').ImageResult>}
18
+ */
19
+ export async function openaiImage (envelope, deps = {}) {
20
+ const client = deps.client ?? new OpenAI({ apiKey: envelope.auth.key })
21
+ const spec = deps.spec ?? getSpec(`${envelope.provider}/${envelope.model}`) ?? {}
22
+ const start = String(process.hrtime.bigint())
23
+
24
+ const args = { model: envelope.model, prompt: envelope.prompt }
25
+ const size = envelope.size || spec.imageDefaultSize
26
+ if (size) args.size = size
27
+
28
+ let response
29
+ try {
30
+ response = await client.images.generate(args)
31
+ } catch (e) {
32
+ throw Object.assign(new Error(classifyProviderError(e).message), {
33
+ typed: classifyProviderError(e)
34
+ })
35
+ }
36
+
37
+ const images = (response.data || []).map(img => ({
38
+ mimeType: 'image/png',
39
+ url: img.url || undefined,
40
+ base64: img.b64_json || undefined
41
+ }))
42
+
43
+ const end = String(process.hrtime.bigint())
44
+ return {
45
+ status: 'completed',
46
+ images,
47
+ seed: null,
48
+ timestamps: { start, first: end, end }
49
+ }
50
+ }