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.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/config/benchmarks.json +39 -0
- package/js/client/call.js +75 -0
- package/js/client/call_image.js +82 -0
- package/js/client/gate-binary.js +72 -0
- package/js/client/index.js +16 -0
- package/js/client/ndjson.js +29 -0
- package/js/client/transport.js +48 -0
- package/js/core/envelope.js +141 -0
- package/js/core/errors.js +75 -0
- package/js/core/events.js +96 -0
- package/js/core/image.js +58 -0
- package/js/core/index.js +10 -0
- package/js/core/status.js +48 -0
- package/js/factory/bridge.js +372 -0
- package/js/session/_cooldown.js +114 -0
- package/js/session/_logger.js +138 -0
- package/js/session/_rate_limiter.js +77 -0
- package/js/session/_tracing.js +58 -0
- package/js/session/adapters/_cancelled.js +44 -0
- package/js/session/adapters/_catalog.js +58 -0
- package/js/session/adapters/_chat_completions.js +439 -0
- package/js/session/adapters/_errors.js +85 -0
- package/js/session/adapters/_images.js +60 -0
- package/js/session/adapters/_lazy_json_cache.js +76 -0
- package/js/session/adapters/_pricing.js +67 -0
- package/js/session/adapters/_providers.js +60 -0
- package/js/session/adapters/_tools.js +185 -0
- package/js/session/adapters/_videos.js +283 -0
- package/js/session/adapters/anthropic.js +397 -0
- package/js/session/adapters/cerebras.js +28 -0
- package/js/session/adapters/deepseek.js +32 -0
- package/js/session/adapters/echo.js +51 -0
- package/js/session/adapters/fake.js +262 -0
- package/js/session/adapters/fireworks.js +46 -0
- package/js/session/adapters/gemini.js +381 -0
- package/js/session/adapters/groq.js +23 -0
- package/js/session/adapters/image/fake.js +55 -0
- package/js/session/adapters/image/index.js +40 -0
- package/js/session/adapters/image/novita.js +135 -0
- package/js/session/adapters/image/openai.js +50 -0
- package/js/session/adapters/index.js +53 -0
- package/js/session/adapters/mistral.js +31 -0
- package/js/session/adapters/novita.js +29 -0
- package/js/session/adapters/openai.js +381 -0
- package/js/session/adapters/openrouter.js +66 -0
- package/js/session/adapters/xai.js +27 -0
- package/js/session/bin.js +54 -0
- package/js/session/driver.js +160 -0
- package/js/session/index.js +18 -0
- package/js/session/run.js +393 -0
- package/js/session/run_image.js +61 -0
- package/package.json +107 -0
- package/src/cli/ask.js +160 -0
- package/src/cli/backup.js +107 -0
- package/src/cli/bench.js +262 -0
- package/src/cli/check.js +123 -0
- package/src/cli/colored-logger.js +67 -0
- package/src/cli/colors.js +13 -0
- package/src/cli/default.js +39 -0
- package/src/cli/index.js +150 -0
- package/src/cli/json-output.js +60 -0
- package/src/cli/model.js +571 -0
- package/src/cli/onboard.js +232 -0
- package/src/cli/rank.js +176 -0
- package/src/cli/ratelimit.js +160 -0
- package/src/cli/tag.js +105 -0
- package/src/lib/assets/alibaba.svg +1 -0
- package/src/lib/assets/anthropic.svg +5 -0
- package/src/lib/assets/deepseek.svg +1 -0
- package/src/lib/assets/gemini.svg +1 -0
- package/src/lib/assets/google.svg +2 -0
- package/src/lib/assets/kwaipilot.svg +1 -0
- package/src/lib/assets/meta.svg +1 -0
- package/src/lib/assets/minimax.svg +9 -0
- package/src/lib/assets/moonshotai.svg +4 -0
- package/src/lib/assets/openai.svg +5 -0
- package/src/lib/assets/xai.svg +1 -0
- package/src/lib/assets/xiaomi.svg +2 -0
- package/src/lib/assets/zai.svg +219 -0
- package/src/lib/benchmark-score.js +215 -0
- package/src/lib/benchmark-truth.js +68 -0
- package/src/lib/cache.js +76 -0
- package/src/lib/common.js +208 -0
- package/src/lib/cooldown.js +63 -0
- package/src/lib/creators.js +71 -0
- package/src/lib/curated-cache.js +146 -0
- package/src/lib/errors.js +126 -0
- package/src/lib/index.js +726 -0
- package/src/lib/logger.js +29 -0
- package/src/lib/providers.js +87 -0
- package/src/lib/rank.js +390 -0
- package/src/lib/rate-limiter.js +50 -0
- package/src/lib/schema.js +150 -0
- package/src/lib/select.js +474 -0
- package/src/lib/tracing.js +62 -0
- 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
|
+
}
|