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,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
|
+
})
|