mohdel 0.112.0 → 0.114.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Mohdel
2
2
 
3
- Self-hosted LLM gateway and SDK for Node — think LiteLLM, for the JS world. One `answer()` call for 11 providers; swap models by changing one string; get real per-call USD cost back on every result, with OpenTelemetry built in and process isolation when you need it. Your keys, your infra, no SaaS proxy in the path.
3
+ Self-hosted LLM gateway and SDK for Node — think LiteLLM, for the JS world. One `answer()` call for 13 providers; swap models by changing one string; get real per-call USD cost back on every result, with OpenTelemetry built in and process isolation when you need it. Your keys, your infra, no SaaS proxy in the path.
4
4
 
5
5
  ```bash
6
6
  npm install -g mohdel
@@ -8,7 +8,7 @@ mo # interactive setup — pick a provider,
8
8
  mo ask gemini/gemini-3-flash-preview "why is the sky blue"
9
9
  ```
10
10
 
11
- Providers: Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, Cerebras, Fireworks, DeepSeek, OpenRouter, Novita. Node 22+, ES modules.
11
+ Providers: Anthropic, OpenAI, Gemini, Mistral, Groq, xAI, Cerebras, Fireworks, DeepSeek, Qwen Cloud, Xiaomi, OpenRouter, Novita. Node 22+, ES modules.
12
12
 
13
13
  ## Why mohdel
14
14
 
@@ -306,6 +306,8 @@ What each provider supports through mohdel's unified interface:
306
306
  | DeepSeek | No | Yes | Yes | No | No | DSML tool-call fallback when model emits tags in content |
307
307
  | Fireworks | Yes | Yes | Yes | No | Yes (`reasoning_effort`) | OpenAI SDK + `baseURL`; model id auto-prefixed |
308
308
  | Mistral | No | Yes | Yes | No | No | `tool_choice: "any"` = required |
309
+ | Qwen Cloud | No | Yes | No | No | Yes (`enable_thinking` + `thinking_budget`) | Alibaba DashScope intl; hybrid models think by default — effort `none` sends explicit off |
310
+ | Xiaomi | No | Yes | Yes | No | Auto | MiMo; shared chat-completions path, `reasoning_content` captured |
309
311
  | OpenRouter | Yes | Yes | Yes | No | Varies | Meta-provider; `providerOptions.openrouter` for routing prefs |
310
312
  | Novita | No | No | No | No | No | Image generation only |
311
313
 
@@ -55,10 +55,14 @@ const DSML_PARAM_RE = /<\uFF5CDSML\uFF5Cparameter\s+name="([^"]+)"(?:\s+string="
55
55
  * @property {'openai'|'mistral'|'cerebras'} [toolChoiceFlavor]
56
56
  * @property {'user'|'safety_identifier'} [identifierField]
57
57
  * Defaults to 'user'.
58
- * @property {'reasoning_effort'|'cerebras_zai'} [reasoningField]
58
+ * @property {'reasoning_effort'|'cerebras_zai'|'qwen'} [reasoningField]
59
59
  * How to wire outputEffort into the request. `reasoning_effort`
60
60
  * sets `args.reasoning_effort = effort`. `cerebras_zai` flips
61
61
  * `args.disable_reasoning = false` instead (zai-family only).
62
+ * `qwen` sets `args.enable_thinking` plus a numeric
63
+ * `args.thinking_budget` from the spec's effort level — Qwen hybrid
64
+ * models think by default, so `enable_thinking: false` must be sent
65
+ * explicitly to switch thinking off.
62
66
  * @property {boolean} [parseDsml]
63
67
  * Extract DeepSeek DSML function-call blocks from message content
64
68
  * when native `tool_calls` is absent.
@@ -351,6 +355,11 @@ function buildRequest (envelope, spec, config) {
351
355
  if (effort !== 'none') delete args.temperature
352
356
  if (config.reasoningField === 'cerebras_zai' && /zai/i.test(bareOf(envelope.model))) {
353
357
  args.disable_reasoning = (effort === 'none')
358
+ } else if (config.reasoningField === 'qwen') {
359
+ args.enable_thinking = (effort !== 'none')
360
+ if (effort !== 'none' && typeof headroom === 'number') {
361
+ args.thinking_budget = headroom
362
+ }
354
363
  } else {
355
364
  args.reasoning_effort = effort
356
365
  }
@@ -243,6 +243,10 @@ const providerOverrides = {
243
243
  if (code === 'rate_limit_exceeded') return tierResult(detail)
244
244
  return undefined
245
245
  },
246
+ qwen (_err, code, detail) {
247
+ if (code === 'rate_limit_exceeded') return tierResult(detail)
248
+ return undefined
249
+ },
246
250
  novita (_err, code, detail) {
247
251
  if (code === 'rate_limit_exceeded') return tierResult(detail)
248
252
  return undefined
@@ -27,8 +27,15 @@ import { getSpec, setCatalog } from './_catalog.js'
27
27
  * Optional fields fall back to other prices when absent:
28
28
  * - `thinkingPrice` → `outputPrice`
29
29
  * - `cacheWritePrice` → `inputPrice` (graceful for non-caching providers)
30
+ * - `cacheWrite1hPrice` → `cacheWritePrice` (graceful when only one write tier is priced)
30
31
  * - `cacheReadPrice` → `inputPrice`
31
32
  *
33
+ * `cacheWrite1hInputTokens` is the 1h-TTL subset of `cacheWriteInputTokens`
34
+ * (Anthropic bills 1h writes at a higher rate than 5m). The 5m portion is
35
+ * `cacheWriteInputTokens - cacheWrite1hInputTokens`. When the 1h field is
36
+ * absent or the catalog has no `cacheWrite1hPrice`, the whole write bills at
37
+ * `cacheWritePrice` — identical to the single-tier behaviour.
38
+ *
32
39
  * Token-counting convention: this function is purely additive across
33
40
  * `inputTokens`, `cacheWriteInputTokens`, `cacheReadInputTokens`,
34
41
  * `outputTokens`, and `thinkingTokens`. Adapters normalize provider-specific
@@ -36,7 +43,8 @@ import { getSpec, setCatalog } from './_catalog.js'
36
43
  *
37
44
  * @param {any} spec Catalog entry, or `undefined`.
38
45
  * @param {{inputTokens?: number, outputTokens?: number, thinkingTokens?: number,
39
- * cacheWriteInputTokens?: number, cacheReadInputTokens?: number}} usage
46
+ * cacheWriteInputTokens?: number, cacheWrite1hInputTokens?: number,
47
+ * cacheReadInputTokens?: number}} usage
40
48
  * @returns {number}
41
49
  */
42
50
  export function computeCost (spec, usage) {
@@ -45,6 +53,8 @@ export function computeCost (spec, usage) {
45
53
  const o = usage.outputTokens ?? 0
46
54
  const t = usage.thinkingTokens ?? 0
47
55
  const cw = usage.cacheWriteInputTokens ?? 0
56
+ const cw1h = Math.min(cw, usage.cacheWrite1hInputTokens ?? 0)
57
+ const cw5m = Math.max(0, cw - cw1h)
48
58
  const cr = usage.cacheReadInputTokens ?? 0
49
59
  const ip = resolveTier(spec.inputPrice, i)
50
60
  const op = resolveTier(spec.outputPrice, i)
@@ -53,9 +63,11 @@ export function computeCost (spec, usage) {
53
63
  const tpFinal = typeof tp === 'number' ? tp : op
54
64
  const cwp = resolveTier(spec.cacheWritePrice, i)
55
65
  const cwpFinal = typeof cwp === 'number' ? cwp : ip
66
+ const cw1hp = resolveTier(spec.cacheWrite1hPrice, i)
67
+ const cw1hpFinal = typeof cw1hp === 'number' ? cw1hp : cwpFinal
56
68
  const crp = resolveTier(spec.cacheReadPrice, i)
57
69
  const crpFinal = typeof crp === 'number' ? crp : ip
58
- const total = (i * ip + cw * cwpFinal + cr * crpFinal + o * op + t * tpFinal) / 1_000_000
70
+ const total = (i * ip + cw5m * cwpFinal + cw1h * cw1hpFinal + cr * crpFinal + o * op + t * tpFinal) / 1_000_000
59
71
  return round(total)
60
72
  }
61
73
 
@@ -114,6 +114,7 @@ export async function * anthropic (envelope, deps = {}) {
114
114
  let inputTokens = 0
115
115
  let outputTokens = 0
116
116
  let cacheWriteTokens = 0
117
+ let cacheWrite1hTokens = 0
117
118
  let cacheReadTokens = 0
118
119
  let thinkingChars = 0
119
120
  let status = STATUS_COMPLETED
@@ -140,6 +141,12 @@ export async function * anthropic (envelope, deps = {}) {
140
141
  if (event.message?.usage?.cache_creation_input_tokens) {
141
142
  cacheWriteTokens = event.message.usage.cache_creation_input_tokens
142
143
  }
144
+ // cache_creation breaks the write total down by TTL (5m + 1h).
145
+ // Surface the 1h subset so the cost layer can price it separately;
146
+ // a request may mix TTLs, so both tiers can be non-zero.
147
+ if (event.message?.usage?.cache_creation?.ephemeral_1h_input_tokens) {
148
+ cacheWrite1hTokens = event.message.usage.cache_creation.ephemeral_1h_input_tokens
149
+ }
143
150
  if (event.message?.usage?.cache_read_input_tokens) {
144
151
  cacheReadTokens = event.message.usage.cache_read_input_tokens
145
152
  }
@@ -249,6 +256,7 @@ export async function * anthropic (envelope, deps = {}) {
249
256
  outputTokens: messageOutputTokens,
250
257
  thinkingTokens: estimatedThinkingTokens,
251
258
  ...(cacheWriteTokens > 0 && { cacheWriteInputTokens: cacheWriteTokens }),
259
+ ...(cacheWrite1hTokens > 0 && { cacheWrite1hInputTokens: cacheWrite1hTokens }),
252
260
  ...(cacheReadTokens > 0 && { cacheReadInputTokens: cacheReadTokens }),
253
261
  cost: costFor(
254
262
  catalogKey(envelope.model),
@@ -257,6 +265,7 @@ export async function * anthropic (envelope, deps = {}) {
257
265
  outputTokens: messageOutputTokens,
258
266
  thinkingTokens: estimatedThinkingTokens,
259
267
  cacheWriteInputTokens: cacheWriteTokens,
268
+ cacheWrite1hInputTokens: cacheWrite1hTokens,
260
269
  cacheReadInputTokens: cacheReadTokens
261
270
  }
262
271
  ),
@@ -104,6 +104,7 @@ export async function * gemini (envelope, deps = {}) {
104
104
  let inputTokens = 0
105
105
  let outputTokens = 0
106
106
  let thinkingTokens = 0
107
+ let cacheReadTokens = 0
107
108
  let status = STATUS_COMPLETED
108
109
  /** @type {string | undefined} */
109
110
  let warning
@@ -164,6 +165,9 @@ export async function * gemini (envelope, deps = {}) {
164
165
  if (typeof chunk.usageMetadata.thoughtsTokenCount === 'number') {
165
166
  thinkingTokens = chunk.usageMetadata.thoughtsTokenCount
166
167
  }
168
+ if (typeof chunk.usageMetadata.cachedContentTokenCount === 'number') {
169
+ cacheReadTokens = chunk.usageMetadata.cachedContentTokenCount
170
+ }
167
171
  }
168
172
  }
169
173
  } catch (e) {
@@ -186,18 +190,23 @@ export async function * gemini (envelope, deps = {}) {
186
190
  }
187
191
 
188
192
  const end = String(process.hrtime.bigint())
193
+ // Implicit caching reports cachedContentTokenCount as a SUBSET of
194
+ // promptTokenCount. Convert to mohdel's additive convention by subtracting
195
+ // the cached portion before pricing, matching the OpenAI adapter.
196
+ const regularInputTokens = Math.max(0, inputTokens - cacheReadTokens)
189
197
  /** @type {import('#core/events.js').DoneEvent} */
190
198
  const done = {
191
199
  type: 'done',
192
200
  result: {
193
201
  status,
194
202
  output: currentOutput() || null,
195
- inputTokens,
203
+ inputTokens: regularInputTokens,
196
204
  outputTokens,
197
205
  thinkingTokens,
206
+ ...(cacheReadTokens > 0 && { cacheReadInputTokens: cacheReadTokens }),
198
207
  cost: costFor(
199
208
  catalogKey(envelope.model),
200
- { inputTokens, outputTokens, thinkingTokens }
209
+ { inputTokens: regularInputTokens, outputTokens, thinkingTokens, cacheReadInputTokens: cacheReadTokens }
201
210
  ),
202
211
  timestamps: { start, first: first ?? end, end }
203
212
  }
@@ -23,6 +23,7 @@ import { mistral } from './mistral.js'
23
23
  import { novita } from './novita.js'
24
24
  import { openai } from './openai.js'
25
25
  import { openrouter } from './openrouter.js'
26
+ import { qwen } from './qwen.js'
26
27
  import { xai } from './xai.js'
27
28
  import { xiaomi } from './xiaomi.js'
28
29
 
@@ -39,6 +40,7 @@ export const adapters = Object.freeze({
39
40
  novita,
40
41
  openai,
41
42
  openrouter,
43
+ qwen,
42
44
  xai,
43
45
  xiaomi
44
46
  })
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Qwen Cloud adapter — OpenAI-compatible chat completions against
3
+ * Alibaba's international DashScope endpoint. Reasoning arrives as
4
+ * the standard `reasoning_content` field handled by the shared core;
5
+ * thinking is wired via `reasoningField: 'qwen'` (`enable_thinking`
6
+ * + `thinking_budget`) because Qwen hybrid models think by default.
7
+ *
8
+ * @module session/adapters/qwen
9
+ */
10
+
11
+ import OpenAI from 'openai'
12
+
13
+ import { runChatCompletions } from './_chat_completions.js'
14
+ import { streamingDispatcher } from './_dispatcher.js'
15
+
16
+ const BASE_URL = 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
17
+
18
+ /**
19
+ * @param {import('#core/envelope.js').CallEnvelope} envelope
20
+ * @param {{client?: any, signal?: AbortSignal, log?: any, span?: any}} [deps]
21
+ * @returns {AsyncGenerator<import('#core/events.js').Event>}
22
+ */
23
+ export async function * qwen (envelope, deps = {}) {
24
+ const client = deps.client ?? new OpenAI({
25
+ apiKey: envelope.auth.key,
26
+ baseURL: envelope.auth.baseURL || BASE_URL,
27
+ fetchOptions: { dispatcher: streamingDispatcher() }
28
+ })
29
+ yield * runChatCompletions(envelope, client, {
30
+ provider: 'qwen',
31
+ reasoningField: 'qwen'
32
+ }, {
33
+ signal: deps.signal,
34
+ log: deps.log,
35
+ span: deps.span
36
+ })
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mohdel",
3
- "version": "0.112.0",
3
+ "version": "0.114.0",
4
4
  "license": "MIT",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -84,10 +84,10 @@
84
84
  },
85
85
  "optionalDependencies": {
86
86
  "@clack/prompts": "^1.5.1",
87
- "@opentelemetry/exporter-trace-otlp-grpc": "^0.218.0",
88
- "@opentelemetry/sdk-node": "^0.218.0",
87
+ "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0",
88
+ "@opentelemetry/sdk-node": "^0.219.0",
89
89
  "chalk": "^5.4.0",
90
- "mohdel-thin-gate-linux-x64-gnu": "0.112.0"
90
+ "mohdel-thin-gate-linux-x64-gnu": "0.114.0"
91
91
  },
92
92
  "dependencies": {
93
93
  "@anthropic-ai/sdk": "^0.104.1",
@@ -83,6 +83,13 @@ const PROVIDER_INFO = {
83
83
  url: 'https://novita.ai/dashboard/key',
84
84
  hint: 'Create an API key at novita.ai → Dashboard → API Key',
85
85
  free: false
86
+ },
87
+ qwen: {
88
+ label: 'Qwen Cloud',
89
+ description: 'Qwen 3.7 Max/Plus, 3.6 Flash — reasoning, coding, long context. Free quota for new users.',
90
+ url: 'https://home.qwencloud.com/api-keys',
91
+ hint: 'Create an API key at home.qwencloud.com → API Keys',
92
+ free: false
86
93
  }
87
94
  }
88
95
 
@@ -88,6 +88,15 @@ const providers = {
88
88
  },
89
89
  creators: []
90
90
  },
91
+ qwen: {
92
+ sdk: 'openai',
93
+ api: 'chatCompletions',
94
+ apiKeyEnv: 'QWEN_API_SK',
95
+ createConfiguration: apiKey => ({ baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', apiKey }),
96
+ creators: ['alibaba'],
97
+ contextSemantics: 'shared',
98
+ outputCapStrategy: 'accept'
99
+ },
91
100
  xai: {
92
101
  sdk: 'openai',
93
102
  apiKeyEnv: 'XAI_API_SK',