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,53 @@
1
+ /**
2
+ * Adapter registry. Maps `envelope.provider` to an adapter function.
3
+ *
4
+ * Each adapter has the shape:
5
+ * async function* adapter(envelope) => AsyncGenerator<Event>
6
+ *
7
+ * Adapters should yield events in order and return when the stream
8
+ * is complete. Exceptions thrown from an adapter are caught by
9
+ * `run()` and converted into `call.error` events.
10
+ *
11
+ * @module session/adapters
12
+ */
13
+
14
+ import { anthropic } from './anthropic.js'
15
+ import { cerebras } from './cerebras.js'
16
+ import { deepseek } from './deepseek.js'
17
+ import { echo } from './echo.js'
18
+ import { fake } from './fake.js'
19
+ import { fireworks } from './fireworks.js'
20
+ import { gemini } from './gemini.js'
21
+ import { groq } from './groq.js'
22
+ import { mistral } from './mistral.js'
23
+ import { novita } from './novita.js'
24
+ import { openai } from './openai.js'
25
+ import { openrouter } from './openrouter.js'
26
+ import { xai } from './xai.js'
27
+
28
+ export const adapters = Object.freeze({
29
+ anthropic,
30
+ cerebras,
31
+ deepseek,
32
+ echo,
33
+ fake,
34
+ fireworks,
35
+ gemini,
36
+ groq,
37
+ mistral,
38
+ novita,
39
+ openai,
40
+ openrouter,
41
+ xai
42
+ })
43
+
44
+ /**
45
+ * @param {string} provider
46
+ * @returns {(env: import('#core/envelope.js').CallEnvelope)
47
+ * => AsyncGenerator<import('#core/events.js').Event>}
48
+ */
49
+ export function getAdapter (provider) {
50
+ const a = adapters[provider]
51
+ if (!a) throw new Error(`unknown provider: ${provider}`)
52
+ return a
53
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Mistral adapter — OpenAI-compatible chat completions against
3
+ * api.mistral.ai/v1. Mistral uses `tool_choice: "any"` for what
4
+ * other providers spell as `required`; the shared core re-routes via
5
+ * `toolChoiceFlavor: 'mistral'`.
6
+ *
7
+ * @module session/adapters/mistral
8
+ */
9
+
10
+ import OpenAI from 'openai'
11
+
12
+ import { runChatCompletions } from './_chat_completions.js'
13
+
14
+ const BASE_URL = 'https://api.mistral.ai/v1'
15
+
16
+ /**
17
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
18
+ * @param {{client?: any, signal?: AbortSignal, log?: any, span?: any}} [deps]
19
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
20
+ */
21
+ export async function * mistral (envelope, deps = {}) {
22
+ const client = deps.client ?? new OpenAI({ apiKey: envelope.auth.key, baseURL: envelope.auth.baseURL || BASE_URL })
23
+ yield * runChatCompletions(envelope, client, {
24
+ provider: 'mistral',
25
+ toolChoiceFlavor: 'mistral'
26
+ }, {
27
+ signal: deps.signal,
28
+ log: deps.log,
29
+ span: deps.span
30
+ })
31
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Novita adapter — OpenAI-compatible chat completions against
3
+ * api.novita.ai. Image generation lives in `adapters/image/novita.js`;
4
+ * this file covers the text path only.
5
+ *
6
+ * @module session/adapters/novita
7
+ */
8
+
9
+ import OpenAI from 'openai'
10
+
11
+ import { runChatCompletions } from './_chat_completions.js'
12
+
13
+ const BASE_URL = 'https://api.novita.ai/openai'
14
+
15
+ /**
16
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
17
+ * @param {{client?: any, signal?: AbortSignal, log?: any, span?: any}} [deps]
18
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
19
+ */
20
+ export async function * novita (envelope, deps = {}) {
21
+ const client = deps.client ?? new OpenAI({ apiKey: envelope.auth.key, baseURL: envelope.auth.baseURL || BASE_URL })
22
+ yield * runChatCompletions(envelope, client, {
23
+ provider: 'novita'
24
+ }, {
25
+ signal: deps.signal,
26
+ log: deps.log,
27
+ span: deps.span
28
+ })
29
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * OpenAI Responses API adapter.
3
+ *
4
+ * Scope:
5
+ * - Text in, text out, streaming
6
+ * - Status contract (incomplete + warning on max_output_tokens)
7
+ * - Tools: unified format → OpenAI function tool; streaming
8
+ * function_call argument deltas; tool_use terminal state;
9
+ * function_call_output input items on the way back in
10
+ * - AbortSignal forwarded to SDK
11
+ *
12
+ * Deferred: vision, reasoning (outputEffort → reasoning.effort),
13
+ * outputStyle (GPT-5 verbosity).
14
+ *
15
+ * @module session/adapters/openai
16
+ */
17
+
18
+ import OpenAI from 'openai'
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 { costFor } from './_pricing.js'
32
+ import {
33
+ toOpenAITools,
34
+ fromOpenAIToolCalls,
35
+ toToolChoice
36
+ } from './_tools.js'
37
+
38
+ /**
39
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
40
+ * @param {{client?: OpenAI, signal?: AbortSignal, log?: any, span?: any}} [deps]
41
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
42
+ */
43
+ export async function * openai (envelope, deps = {}) {
44
+ const client = deps.client ?? new OpenAI({
45
+ apiKey: envelope.auth.key,
46
+ ...(envelope.auth.baseURL ? { baseURL: envelope.auth.baseURL } : {})
47
+ })
48
+ const signal = deps.signal
49
+ const log = deps.log
50
+ const start = String(process.hrtime.bigint())
51
+ let first = null
52
+
53
+ const { instructions, input } = splitPrompt(envelope.prompt)
54
+
55
+ if (envelope.images?.length) {
56
+ try {
57
+ const loaded = await loadImages(envelope.images)
58
+ const parts = loaded.map(toOpenAIImagePart).filter(Boolean)
59
+ if (parts.length) injectImageParts(input, parts)
60
+ } catch (e) {
61
+ log?.warn({ err: e }, '[mohdel:openai] image load failed')
62
+ yield { type: 'error', error: classifyProviderError(e) }
63
+ return
64
+ }
65
+ }
66
+
67
+ const request = buildRequest(envelope, input, instructions)
68
+
69
+ // F53: accumulate via array + join to avoid per-delta V8 cons-string
70
+ // churn. Materialized at each exit point.
71
+ const outputParts = []
72
+ const currentOutput = () => outputParts.join('')
73
+ let inputTokens = 0
74
+ let outputTokens = 0
75
+ let thinkingTokens = 0
76
+ let status = STATUS_COMPLETED
77
+ /** @type {string | undefined} */
78
+ let warning
79
+
80
+ // Tool-call accumulation: itemId → {call_id, name, arguments}
81
+ /** @type {Map<string, {call_id: string, name: string, arguments: string}>} */
82
+ const toolItems = new Map()
83
+
84
+ try {
85
+ const stream = await client.responses.stream(request, { signal })
86
+
87
+ for await (const event of stream) {
88
+ if (signal?.aborted) {
89
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
90
+ return
91
+ }
92
+ switch (event.type) {
93
+ case 'response.output_text.delta':
94
+ if (event.delta) {
95
+ if (first === null) first = String(process.hrtime.bigint())
96
+ outputParts.push(event.delta)
97
+ yield { type: 'delta', delta: { type: 'message', delta: event.delta } }
98
+ }
99
+ break
100
+
101
+ case 'response.output_item.added':
102
+ if (event.item?.type === 'function_call') {
103
+ toolItems.set(event.item.id, {
104
+ call_id: event.item.call_id ?? event.item.id,
105
+ name: event.item.name ?? '',
106
+ arguments: ''
107
+ })
108
+ }
109
+ break
110
+
111
+ case 'response.function_call_arguments.delta':
112
+ if (event.item_id && event.delta) {
113
+ const t = toolItems.get(event.item_id)
114
+ if (t) {
115
+ t.arguments += event.delta
116
+ if (first === null) first = String(process.hrtime.bigint())
117
+ yield {
118
+ type: 'delta',
119
+ delta: { type: 'function_call', delta: event.delta }
120
+ }
121
+ }
122
+ }
123
+ break
124
+
125
+ case 'response.completed':
126
+ if (event.response?.usage) {
127
+ inputTokens = event.response.usage.input_tokens ?? 0
128
+ outputTokens = event.response.usage.output_tokens ?? 0
129
+ thinkingTokens = event.response.usage.output_tokens_details?.reasoning_tokens ?? 0
130
+ }
131
+ if (toolItems.size > 0) {
132
+ status = STATUS_TOOL_USE
133
+ }
134
+ break
135
+
136
+ case 'response.incomplete':
137
+ status = STATUS_INCOMPLETE
138
+ if (event.response?.incomplete_details?.reason === 'max_output_tokens') {
139
+ warning = WARNING_INSUFFICIENT_OUTPUT_BUDGET
140
+ }
141
+ if (event.response?.usage) {
142
+ inputTokens = event.response.usage.input_tokens ?? 0
143
+ outputTokens = event.response.usage.output_tokens ?? 0
144
+ thinkingTokens = event.response.usage.output_tokens_details?.reasoning_tokens ?? 0
145
+ }
146
+ break
147
+
148
+ default:
149
+ break
150
+ }
151
+ }
152
+ } catch (e) {
153
+ if (signal?.aborted) {
154
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
155
+ return
156
+ }
157
+ log?.warn({ err: e }, '[mohdel:openai] stream failed')
158
+ yield { type: 'error', error: classifyProviderError(e) }
159
+ return
160
+ }
161
+
162
+ if (signal?.aborted) {
163
+ yield cancelledDone(start, first, envelope, currentOutput(), inputTokens, outputTokens)
164
+ return
165
+ }
166
+
167
+ const end = String(process.hrtime.bigint())
168
+ // OpenAI Responses reports `output_tokens` INCLUDING reasoning
169
+ // tokens. The `AnswerResult` contract separates them into
170
+ // `outputTokens` (message-only) and `thinkingTokens`, so subtract
171
+ // one from the other for the message-only count.
172
+ const messageOutputTokens = Math.max(0, outputTokens - thinkingTokens)
173
+
174
+ /** @type {import('#core/events.js').DoneEvent} */
175
+ const done = {
176
+ type: 'done',
177
+ result: {
178
+ status,
179
+ output: currentOutput() || null,
180
+ inputTokens,
181
+ outputTokens: messageOutputTokens,
182
+ thinkingTokens,
183
+ cost: costFor(
184
+ `${envelope.provider}/${envelope.model}`,
185
+ { inputTokens, outputTokens: messageOutputTokens, thinkingTokens }
186
+ ),
187
+ timestamps: { start, first: first ?? end, end }
188
+ }
189
+ }
190
+ if (warning) done.result.warning = warning
191
+ if (toolItems.size > 0) {
192
+ done.result.toolCalls = fromOpenAIToolCalls(Array.from(toolItems.values()))
193
+ }
194
+ yield done
195
+ }
196
+
197
+ /**
198
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
199
+ * @param {Array<any>} input
200
+ * @param {string} instructions
201
+ */
202
+ function buildRequest (envelope, input, instructions) {
203
+ const spec = getSpec(`${envelope.provider}/${envelope.model}`)
204
+
205
+ /** @type {Record<string, any>} */
206
+ const request = {
207
+ model: envelope.model,
208
+ input
209
+ }
210
+ if (instructions) request.instructions = instructions
211
+ if (envelope.outputBudget !== undefined) request.max_output_tokens = envelope.outputBudget
212
+ if (envelope.tools?.length) {
213
+ request.tools = toOpenAITools(envelope.tools)
214
+ }
215
+ if (envelope.toolChoice) {
216
+ request.tool_choice = toToolChoice('openai', envelope.toolChoice)
217
+ }
218
+ if (envelope.parallelToolCalls === false) {
219
+ request.parallel_tool_calls = false
220
+ }
221
+
222
+ // Thinking: when the spec has `thinkingEffortLevels`, set
223
+ // `reasoning.effort` and add the thinking-budget headroom on top
224
+ // of the user's `outputBudget`. `reasoning` is an OpenAI-only
225
+ // parameter — xAI reasoning is automatic, so add the headroom
226
+ // but skip the request field on xAI.
227
+ if (spec?.thinkingEffortLevels) {
228
+ const effort = envelope.outputEffort ?? spec.defaultThinkingEffort ?? 'low'
229
+ if (effort && effort !== 'none') {
230
+ const headroom = spec.thinkingEffortLevels[effort]
231
+ if (request.max_output_tokens && typeof headroom === 'number') {
232
+ request.max_output_tokens += headroom
233
+ }
234
+ if (envelope.provider === 'openai') {
235
+ request.reasoning = { effort }
236
+ }
237
+ }
238
+ }
239
+
240
+ // outputType: 'json' → text.format
241
+ if (envelope.outputType === 'json') {
242
+ request.text = { ...(request.text || {}), format: { type: 'json_object' } }
243
+ }
244
+
245
+ // outputStyle: 'chat' → GPT-5 verbosity hint (only on gpt-5 family)
246
+ if (envelope.outputStyle && /gpt-5/.test(envelope.model)) {
247
+ request.text = {
248
+ ...(request.text || {}),
249
+ verbosity: envelope.outputStyle === 'chat' ? 'high' : 'low'
250
+ }
251
+ }
252
+
253
+ // Per-user identifier — openai uses `safety_identifier`; other
254
+ // Responses-API providers (xai) use the legacy `user` field.
255
+ if (envelope.identifier) {
256
+ if (envelope.provider === 'openai') {
257
+ request.safety_identifier = envelope.identifier
258
+ } else {
259
+ request.user = envelope.identifier
260
+ }
261
+ }
262
+
263
+ return request
264
+ }
265
+
266
+ /** @param {string | import('#core/envelope.js').Message[]} prompt */
267
+ function splitPrompt (prompt) {
268
+ if (typeof prompt === 'string') {
269
+ return { instructions: '', input: [{ role: 'user', content: prompt }] }
270
+ }
271
+ /** @type {string[]} */
272
+ const systemParts = []
273
+ /** @type {Array<any>} */
274
+ const input = []
275
+ for (const m of prompt) {
276
+ if (m.role === 'system') {
277
+ systemParts.push(flattenText(m.content))
278
+ } else if (m.role === 'tool') {
279
+ input.push({
280
+ type: 'function_call_output',
281
+ call_id: m.toolCallId ?? '',
282
+ output: flattenText(m.content)
283
+ })
284
+ } else if (m.role === 'assistant' && m.toolCalls?.length) {
285
+ // Responses API wants a message item (if any text) followed
286
+ // by one function_call item per tool invocation.
287
+ const text = flattenText(m.content)
288
+ if (text) {
289
+ input.push({
290
+ type: 'message',
291
+ role: 'assistant',
292
+ content: [{ type: 'output_text', text }]
293
+ })
294
+ }
295
+ for (const tc of m.toolCalls) {
296
+ input.push({
297
+ type: 'function_call',
298
+ name: tc.name,
299
+ call_id: tc.id,
300
+ arguments: stringifyToolArgs(tc.arguments)
301
+ })
302
+ }
303
+ } else {
304
+ input.push({
305
+ role: m.role,
306
+ content: toInputContent(m.role, m.content)
307
+ })
308
+ }
309
+ }
310
+ return { instructions: systemParts.filter(Boolean).join('\n\n'), input }
311
+ }
312
+
313
+ /** @param {string | import('#core/envelope.js').MessagePart[]} content */
314
+ function flattenText (content) {
315
+ if (typeof content === 'string') return content
316
+ return content.filter(p => p.type === 'text' && p.text).map(p => p.text).join('\n')
317
+ }
318
+
319
+ /**
320
+ * OpenAI's `function_call` item demands a JSON string for arguments.
321
+ * The unified `ToolCall.arguments` is an object, so stringify here
322
+ * and fall back to `"{}"` on any JSON oddness rather than crashing
323
+ * mid-call.
324
+ * @param {unknown} args
325
+ */
326
+ function stringifyToolArgs (args) {
327
+ if (typeof args === 'string' && args) return args
328
+ try {
329
+ return JSON.stringify(args ?? {})
330
+ } catch {
331
+ return '{}'
332
+ }
333
+ }
334
+
335
+ /**
336
+ * @param {string} role
337
+ * @param {string | import('#core/envelope.js').MessagePart[]} content
338
+ */
339
+ function toInputContent (role, content) {
340
+ if (typeof content === 'string') return content
341
+ const partType = role === 'assistant' ? 'output_text' : 'input_text'
342
+ return content.map(p => {
343
+ if (p.type === 'text') return { type: partType, text: p.text ?? '' }
344
+ throw new Error(`unsupported content part type: ${p.type}`)
345
+ })
346
+ }
347
+
348
+ /** @param {import('./_images.js').LoadedImage} img */
349
+ function toOpenAIImagePart (img) {
350
+ if (img.url) {
351
+ return { type: 'input_image', image_url: img.url }
352
+ }
353
+ if (img.base64) {
354
+ // Responses API takes a data URI for inline images.
355
+ return {
356
+ type: 'input_image',
357
+ image_url: `data:${img.mimeType};base64,${img.base64}`
358
+ }
359
+ }
360
+ return null
361
+ }
362
+
363
+ /**
364
+ * Inject image parts into the LAST user input item.
365
+ *
366
+ * @param {Array<any>} input
367
+ * @param {Array<any>} parts
368
+ */
369
+ function injectImageParts (input, parts) {
370
+ for (let i = input.length - 1; i >= 0; i--) {
371
+ const item = input[i]
372
+ if (item.role !== 'user') continue
373
+ if (typeof item.content === 'string') {
374
+ item.content = [{ type: 'input_text', text: item.content }, ...parts]
375
+ } else if (Array.isArray(item.content)) {
376
+ item.content = [...item.content, ...parts]
377
+ }
378
+ return
379
+ }
380
+ input.push({ role: 'user', content: parts })
381
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * OpenRouter adapter — meta-provider with streaming chat completions
3
+ * and optional `provider` routing preferences (order/allow/deny).
4
+ *
5
+ * Routing prefs ride on `envelope.providerOptions.openrouter` to keep
6
+ * the base envelope schema clean; the shape matches OpenRouter's
7
+ * `provider` request field: `{order?, allow?, deny?}`.
8
+ *
9
+ * @module session/adapters/openrouter
10
+ */
11
+
12
+ import OpenAI from 'openai'
13
+
14
+ import { runChatCompletions } from './_chat_completions.js'
15
+
16
+ const BASE_URL = 'https://openrouter.ai/api/v1'
17
+
18
+ /**
19
+ * Strip `\r` and `\n` from a header value. Node already rejects CRLF
20
+ * in header values at send time (throws), so this is defense-in-depth
21
+ * against a misconfigured `OPENROUTER_REFERER` / `OPENROUTER_TITLE`
22
+ * env var crashing the adapter instead of producing a valid header.
23
+ *
24
+ * @param {string} v
25
+ */
26
+ export function sanitizeHeader (v) {
27
+ return String(v).replace(/[\r\n]/g, '')
28
+ }
29
+
30
+ /**
31
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
32
+ * @param {{client?: any, signal?: AbortSignal}} [deps]
33
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
34
+ */
35
+ export async function * openrouter (envelope, deps = {}) {
36
+ // OpenRouter accepts optional `HTTP-Referer` + `X-Title` headers for
37
+ // attribution in their dashboard. Only send when the embedder sets
38
+ // the env vars — no defaults, so mohdel never identifies any
39
+ // upstream consumer unprompted.
40
+ const defaultHeaders = {}
41
+ if (process.env.OPENROUTER_REFERER) {
42
+ defaultHeaders['HTTP-Referer'] = sanitizeHeader(process.env.OPENROUTER_REFERER)
43
+ }
44
+ if (process.env.OPENROUTER_TITLE) {
45
+ defaultHeaders['X-Title'] = sanitizeHeader(process.env.OPENROUTER_TITLE)
46
+ }
47
+ const client = deps.client ?? new OpenAI({
48
+ apiKey: envelope.auth.key,
49
+ baseURL: envelope.auth.baseURL || BASE_URL,
50
+ defaultHeaders
51
+ })
52
+
53
+ yield * runChatCompletions(envelope, client, {
54
+ provider: 'openrouter',
55
+ stream: true,
56
+ mutateArgs: (env, args) => {
57
+ const routing = env.providerOptions?.openrouter
58
+ if (routing && (routing.order || routing.allow || routing.deny)) {
59
+ args.provider = {}
60
+ if (routing.order) args.provider.order = routing.order
61
+ if (routing.allow) args.provider.allow = routing.allow
62
+ if (routing.deny) args.provider.deny = routing.deny
63
+ }
64
+ }
65
+ }, { signal: deps.signal, log: deps.log, span: deps.span })
66
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * xAI adapter — OpenAI Responses API over x.ai/v1. Delegates to the
3
+ * `openai` adapter with a baseURL-configured client; the openai
4
+ * adapter branches on `envelope.provider === 'openai'` for fields
5
+ * that differ between vendors (reasoning param, safety_identifier).
6
+ *
7
+ * @module session/adapters/xai
8
+ */
9
+
10
+ import OpenAI from 'openai'
11
+
12
+ import { openai } from './openai.js'
13
+
14
+ const BASE_URL = 'https://api.x.ai/v1'
15
+
16
+ /**
17
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
18
+ * @param {{client?: any, signal?: AbortSignal}} [deps]
19
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
20
+ */
21
+ export async function * xai (envelope, deps = {}) {
22
+ const client = deps.client ?? new OpenAI({
23
+ apiKey: envelope.auth.key,
24
+ baseURL: envelope.auth.baseURL || BASE_URL
25
+ })
26
+ yield * openai(envelope, { ...deps, client })
27
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Session process entrypoint. Invoked by thin-gate (or any supervisor)
4
+ * as `node <path-to-this-file>`. Reads one CallEnvelope from stdin
5
+ * and writes events to stdout; stderr is for structured logs.
6
+ *
7
+ * At startup:
8
+ * - Lazy-initializes OTel SDK when `OTEL_EXPORTER_OTLP_ENDPOINT`
9
+ * is set (exports spans to the configured collector).
10
+ * - Constructs the default logger from `MOHDEL_LOG_LEVEL` /
11
+ * `MOHDEL_VERBOSITY` (see LOGGING.md).
12
+ *
13
+ * `run.js` reads `logger` as a module-level import — `bin.js` does
14
+ * not need to thread it through.
15
+ *
16
+ * @module session/bin
17
+ */
18
+
19
+ import { drive } from './driver.js'
20
+ import { ensureOtelInitialized } from './_tracing.js'
21
+ import { logger } from './_logger.js'
22
+ import { initCatalogFromDefault } from './adapters/_catalog.js'
23
+ import { initProvidersFromDefault } from './adapters/_providers.js'
24
+
25
+ async function main () {
26
+ await ensureOtelInitialized()
27
+
28
+ // `MOHDEL_NO_CONFIG_DISK=1` tells the session that a supervisor is
29
+ // responsible for pushing config over stdin (`op: set_catalog`,
30
+ // future `op: set_providers`). Skip the eager disk init in that
31
+ // case — the catalog cache stays empty until the first injection
32
+ // lands. Standalone use (CLI, tests) leaves the variable unset and
33
+ // the cache warms from `~/.config/mohdel/` as before.
34
+ const noDisk = process.env.MOHDEL_NO_CONFIG_DISK === '1'
35
+ if (!noDisk) {
36
+ await Promise.all([initCatalogFromDefault(), initProvidersFromDefault()])
37
+ }
38
+
39
+ logger.info(
40
+ {
41
+ level: logger.level,
42
+ verbosity: logger.verbosity,
43
+ pid: process.pid,
44
+ configFrom: noDisk ? 'supervisor' : 'disk'
45
+ },
46
+ '[mohdel:session] starting'
47
+ )
48
+ await drive(process.stdin, process.stdout)
49
+ }
50
+
51
+ main().catch((e) => {
52
+ logger.fatal({ err: e }, '[mohdel:session] fatal')
53
+ process.exit(1)
54
+ })