@ryuu-reinzz/ryuu-cli 0.3.1 → 0.4.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/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  Semua perubahan penting pada Ryuu Agent CLI didokumentasikan di sini.
4
4
  Project ini mengikuti [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.3.2] - 2026-10-30
7
+
8
+ ### Ditambahkan
9
+
10
+ - **River AI jadi provider bawaan, dan reasoning DeepSeek-nya akhirnya tampil.**
11
+ River mematikan thinking secara default untuk `DeepSeek-V4.1-Flash-1017` dan
12
+ `DeepSeek-V4.1-Flash`, sehingga respons hanya berisi `content` dan kotak
13
+ `◈ reasoning` tidak pernah muncul. Modul baru `src/providers/river.js`
14
+ mengirim `reasoning_effort` (default `high`) lewat tabel field request
15
+ `src/providers/vendor-extras.js` yang dibaca klien bersama sebelum setiap
16
+ request. Prioritas nilai: `RYUU_RIVER_REASONING_EFFORT` → `reasoningEffort` di
17
+ `config.json` → `RYUU_REASONING_EFFORT` → `capabilities.reasoningEffort`.
18
+ Level yang tidak didukung model diturunkan ke level aman model itu dan dicatat
19
+ di log, jadi request tidak pernah gagal karena field ini.
20
+ - **Modul klien per vendor.** `tokenharbor.js` menyusul `river.js` sebagai
21
+ pemilik default provider-nya; provider kustom tetap memakai template
22
+ `nvidia.js` tanpa perubahan kode, dan kini bisa membawa field request sendiri
23
+ lewat `extraBody`/`requestParams` di entri `extraProviders`.
24
+
25
+ ### Diubah
26
+
27
+ - Provider bawaan menjadi `nvidia`, `tokenharbor`, dan `river`, masing-masing
28
+ dengan daftar model default-nya sendiri (River: DeepSeek, GLM-5.3,
29
+ Qwen3.8-27B, Nemotron-3.5-Super-VL). `/model` dan `/provider` menampilkannya
30
+ tanpa entri `extraProviders`.
31
+ - Entri `extraProviders` lama yang mendeklarasikan ulang provider bawaan dengan
32
+ base URL yang sama (misalnya `river`) kini diabaikan dengan peringatan, bukan
33
+ menggagalkan startup. Id bawaan dengan URL berbeda tetap ditolak.
34
+ - `extraProviders` menerima `reasoningEffort`, `thinking`, `extraBody`, dan
35
+ `requestParams`; nilai-nilainya dibawa bersama pilihan provider sehingga
36
+ `/provider` tidak menghapusnya.
37
+
6
38
  ## [0.3.1] - 2026-10-29
7
39
 
8
40
  ### Ditambahkan
package/README.md CHANGED
@@ -565,7 +565,8 @@ Kuncinya disimpan seperti provider lain, yaitu `providers.myproxy.apiKey`, dan
565
565
  bisa diisi lewat `ryuu --settings` setelah provider terdaftar.
566
566
 
567
567
  - `id`, `name`, dan `baseURL` wajib. `models` boleh kosong saat baru dibuat dan
568
- diisi lewat `/addmodel`; `id` tidak boleh menabrak provider bawaan.
568
+ diisi lewat `/addmodel`; `id` tidak boleh menabrak provider bawaan (`nvidia`,
569
+ `tokenharbor`, `river`).
569
570
  - `capabilities` menentukan apakah agent mengirim tools (`tools`), meminta
570
571
  stream (`streaming`), dan menampilkan reasoning bila provider mengirimnya
571
572
  (`reasoning`). Jangan menyalakan `tools` untuk endpoint yang tidak mendukung
@@ -578,6 +579,58 @@ bisa diisi lewat `ryuu --settings` setelah provider terdaftar.
578
579
  dilaporkan apa adanya (dengan retry terbatas untuk kegagalan sesaat) agar
579
580
  kunci dan data tidak berpindah ke provider lain tanpa keputusan Anda.
580
581
 
582
+ #### Provider bawaan dan modul klien per vendor (`src/providers/`)
583
+
584
+ Tiga provider dikenali tanpa entri `extraProviders`: `nvidia`, `tokenharbor`,
585
+ dan `river`. Masing-masing punya modul sendiri di `src/providers/`:
586
+
587
+ | Modul | Provider | Isi |
588
+ | --- | --- | --- |
589
+ | `nvidia.js` | `nvidia` | Klien Chat Completions bersama (dipakai semua provider OpenAI-compatible), retry, streaming, ekstraksi reasoning. |
590
+ | `tokenharbor.js` | `tokenharbor` | Default provider + pass-through `extraBody`/`requestParams`. |
591
+ | `river.js` | `river` | Menambahkan `reasoning_effort` (lihat di bawah) dan profil reasoning per model. |
592
+ | `vendor-extras.js` | — | Tabel field request per provider; klien bersama bertanya ke sini sebelum setiap request. |
593
+
594
+ Karena ketiganya bawaan, `extraProviders` **tidak boleh** memakai id yang sama.
595
+ Entri lama untuk `river` yang menunjuk base URL yang sama tetap diterima: entri
596
+ itu diabaikan dengan peringatan, bukan menggagalkan startup, dan descriptor
597
+ bawaan yang dipakai (daftar model, kapabilitas, field vendor). Provider kustom
598
+ lainnya tetap memakai template `nvidia.js` tanpa perubahan kode; field request
599
+ tambahan bisa diberikan lewat `extraBody`/`requestParams` di entrinya:
600
+
601
+ ```json
602
+ { "id": "myproxy", "baseURL": "https://proxy.example/v1",
603
+ "extraBody": { "top_k": 40 }, "requestParams": { "reasoning_effort": "high" } }
604
+ ```
605
+
606
+ #### Reasoning DeepSeek di River AI
607
+
608
+ River mematikan thinking secara default untuk `DeepSeek-V4.1-Flash-1017` dan
609
+ `DeepSeek-V4.1-Flash`: tanpa `reasoning_effort` di body request, respons hanya
610
+ berisi `content`, dan tidak ada teks reasoning untuk ditampilkan. `river.js`
611
+ mengirim field itu otomatis (dokumentasi: <https://docs.river.ai/guides/inference-controls/>):
612
+
613
+ - Default: `high` untuk kedua model DeepSeek, sehingga reasoning langsung muncul
614
+ di UI dan di `.ryuu/logs/reasoning/`.
615
+ - Model River lain (`GLM-5.3`, `GLM-5.3-Flash`, `Qwen3.8-27B-FP8`,
616
+ `Nemotron-3.5-Super-VL-120B`) sudah reasoning secara default dan tidak dipaksa
617
+ — effort hanya dikirim bila Anda memintanya.
618
+ - Urutan prioritas: env `RYUU_RIVER_REASONING_EFFORT` → `reasoningEffort` di
619
+ `config.json` → env `RYUU_REASONING_EFFORT` → `capabilities.reasoningEffort`
620
+ di entri provider.
621
+ - Nilai yang diterima: `none|low|medium|high|xhigh|max` (plus `off`/`on`).
622
+ Level yang tidak didukung model akan diturunkan ke level aman model itu dan
623
+ dicatat di log, jadi request tidak gagal.
624
+
625
+ Contoh mematikan reasoning River lewat `config.json`:
626
+
627
+ ```json
628
+ { "provider": "river", "reasoningEffort": "none" }
629
+ ```
630
+
631
+ `capabilities.reasoning` hanya label tampilan di `/provider`; reasoning tetap
632
+ ditampilkan selama provider benar-benar mengirim `reasoning_content`.
633
+
581
634
  ## Tool yang tersedia untuk model
582
635
 
583
636
  Model hanya bisa memanggil tool yang terdaftar di allowlist lokal. Setiap
@@ -839,10 +892,13 @@ src/
839
892
  planner.js prompt sesi, task brief, ekstraksi rencana, ringkasan
840
893
  worklog.js fakta sesi terverifikasi yang dipakai ulang antar langkah dan tugas
841
894
  providers/
842
- catalog.js katalog provider + model, kapabilitas, validasi id, extraProviders
895
+ catalog.js katalog provider + model (nvidia, tokenharbor, river), kapabilitas, extraProviders
843
896
  registry.js registry provider dinamis (factory per id)
844
897
  index.js pembuatan provider aktif dan resolusi pilihan provider/model
845
898
  nvidia.js klien SDK OpenAI (dipakai semua provider), normalisasi, retry, pemetaan galat
899
+ tokenharbor.js provider Token Harbor: default dan pass-through field request
900
+ river.js provider River AI: reasoning_effort untuk DeepSeek + profil reasoning per model
901
+ vendor-extras.js tabel field request per provider yang dibaca klien bersama
846
902
  tools/
847
903
  registry.js allowlist, skema, risiko, antarmuka plugin
848
904
  filesystem.js baca/tulis/edit/pindah/hapus/diff file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuu-reinzz/ryuu-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Ryuu Agent CLI - an autonomous, approval-gated AI coding agent for your terminal",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/app.js CHANGED
@@ -8,7 +8,7 @@ import { createLogger } from '../services/logger.js';
8
8
  import { buildProjectSummary } from '../tools/project.js';
9
9
  import { buildToolRegistry } from '../tools/index.js';
10
10
  import { createActiveProvider, selectionFor } from '../providers/index.js';
11
- import { buildProviders, parseExtraProviders } from '../providers/catalog.js';
11
+ import { buildProviders, parseExtraProviders, PROVIDER_REQUEST_FIELDS } from '../providers/catalog.js';
12
12
  import { buildRawConfig } from '../config/load.js';
13
13
  import { readSettings } from '../config/settings.js';
14
14
  import { Agent } from '../agent/agent.js';
@@ -306,6 +306,14 @@ export async function createApp({
306
306
  baseURL: selection.baseURL,
307
307
  model: selection.model,
308
308
  capabilities: selection.capabilities,
309
+ // Vendor request overrides (e.g. River's reasoningEffort) come from the
310
+ // provider that is now active, not from the one left behind.
311
+ ...Object.fromEntries(
312
+ PROVIDER_REQUEST_FIELDS.filter((field) => selection[field] !== undefined).map((field) => [
313
+ field,
314
+ selection[field],
315
+ ]),
316
+ ),
309
317
  });
310
318
  const persisted = await writeProviderPreference(stateDir, selection.providerId);
311
319
  return { ...selection, persisted };
@@ -24,6 +24,7 @@ import {
24
24
  writeModelPreference,
25
25
  } from '../services/preferences.js';
26
26
  import { describeConfig } from '../config/validate.js';
27
+ import { describeReasoning } from '../providers/river.js';
27
28
  import { formatDoctor, runDoctor } from '../services/doctor.js';
28
29
  import { createUnifiedDiff, diffStats } from '../services/diff.js';
29
30
  import { PROVIDER_INFO } from '../services/websearch/providers.js';
@@ -261,12 +262,16 @@ export function createCommands(state) {
261
262
  define('status', 'Show provider, model, workspace, limits and the last task result', '/status', async () => {
262
263
  const last = state.getLastSummary();
263
264
  const running = processes.list({ includeCompleted: false }).filter((proc) => proc.status === 'running');
265
+ // The reasoning effort that will actually be sent, so a provider that
266
+ // thinks only when asked (River's DeepSeek models) is visible at a glance.
267
+ const reasoningLine = describeReasoning(config);
264
268
  const lines = [
265
269
  `model ${config.model}`,
266
270
  `provider ${config.providerName || 'NVIDIA Integrate API'} (${currentProviderId()})`,
267
271
  `endpoint ${config.baseURL}`,
268
272
  `key ${config.apiKeyPath || 'providers.nvidia.apiKey'} ${config.apiKey ? 'set (redacted)' : '(not set)'}`,
269
273
  `caps ${capabilityText(config.capabilities)}`,
274
+ ...(reasoningLine ? [`reasoning ${reasoningLine}`] : []),
270
275
  `workspace ${workspace.root}`,
271
276
  `node ${process.version} (${process.platform})`,
272
277
  `approval ${config.approvalPolicy}`,
@@ -86,6 +86,11 @@ export function buildRawConfig(settings = {}, { cwd = process.cwd() } = {}) {
86
86
  baseURL: typeof stored.baseURL === 'string' ? stored.baseURL : '',
87
87
  model: '',
88
88
  };
89
+ // Per-provider request overrides (River's `reasoningEffort`, a
90
+ // pass-through `extraBody`, ...) are stored next to the key.
91
+ for (const field of ['reasoningEffort', 'thinking', 'extraBody', 'requestParams']) {
92
+ if (stored[field] !== undefined) credentials[id][field] = stored[field];
93
+ }
89
94
  }
90
95
  const storedModels = data.providerModel && typeof data.providerModel === 'object' ? data.providerModel : {};
91
96
  for (const [id, model] of Object.entries(storedModels)) {
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_PROVIDER_ID,
6
6
  MAX_MODEL_ID_LENGTH,
7
7
  MODEL_ID_PATTERN,
8
+ PROVIDER_REQUEST_FIELDS,
8
9
  buildProviders,
9
10
  isCatalogModel,
10
11
  parseExtraProviders,
@@ -142,7 +143,7 @@ export function validateConfig(raw = {}, { requireApiKey = true, cwd = raw.cwd |
142
143
  // come from config.json only.
143
144
  const providerCredentials =
144
145
  raw.providerCredentials && typeof raw.providerCredentials === 'object' ? raw.providerCredentials : {};
145
- const extraProviders = parseExtraProviders(raw.extraProviders, issues);
146
+ const extraProviders = parseExtraProviders(raw.extraProviders, issues, warnings);
146
147
  const providers = buildProviders({ credentials: providerCredentials, extras: extraProviders });
147
148
 
148
149
  const explicitProvider = raw.provider ? String(raw.provider).trim().toLowerCase() : null;
@@ -372,6 +373,15 @@ export function validateConfig(raw = {}, { requireApiKey = true, cwd = raw.cwd |
372
373
  apiKey,
373
374
  model,
374
375
  baseURL,
376
+ // Per-provider request overrides reach the client through the effective
377
+ // config: a user provider may declare a vendor field such as River's
378
+ // reasoningEffort, and `switchProvider` has no entry for it.
379
+ ...Object.fromEntries(
380
+ PROVIDER_REQUEST_FIELDS.filter((field) => activeProvider[field] !== undefined).map((field) => [
381
+ field,
382
+ activeProvider[field],
383
+ ]),
384
+ ),
375
385
  workspace,
376
386
  logLevel,
377
387
  approvalPolicy,
@@ -38,6 +38,40 @@ export const DEFAULT_BASE_URL = 'https://integrate.api.nvidia.com/v1';
38
38
  export const TOKENHARBOR_BASE_URL = 'https://tokenharbor.ai/v1';
39
39
  export const TOKENHARBOR_MODEL = 'deepseek-v4.1-flash:free';
40
40
 
41
+ export const RIVER_BASE_URL = 'https://api.river.ai/v1';
42
+ export const RIVER_PROVIDER_ID = 'river';
43
+ export const RIVER_DOCS_URL = 'https://docs.river.ai/guides/inference-controls/';
44
+
45
+ /** River's documented `reasoning_effort` menu, lowest to highest. */
46
+ export const RIVER_REASONING_EFFORTS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
47
+ /** Used when thinking is enabled without an explicit level (River's own rule). */
48
+ export const RIVER_DEFAULT_REASONING_EFFORT = 'high';
49
+
50
+ /** Model ids `/model` offers for River, default first. */
51
+ export const RIVER_MODEL_CATALOG = [
52
+ 'deepseek-ai/DeepSeek-V4.1-Flash-1017',
53
+ 'deepseek-ai/DeepSeek-V4.1-Flash',
54
+ 'zai-org/GLM-5.3-Flash',
55
+ 'GLM-5.3',
56
+ 'Qwen/Qwen3.8-27B-FP8',
57
+ 'nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-NVFP4',
58
+ 'nvidia/GLM-5.2-NVFP4',
59
+ 'nvidia/GLM-5.2-NVFP4-262K',
60
+ 'nvidia/Kimi-K2.6-NVFP4',
61
+ 'nvidia/Kimi-K2.6-NVFP4-262K',
62
+ 'nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-NVFP4',
63
+ 'Qwen/Qwen3.5-9B',
64
+ 'Qwen/Qwen3.5-122B-A10B-FP8',
65
+ 'Qwen/Qwen3.5-397B-A17B-FP8',
66
+ 'Qwen/Qwen3.6-35B-A3B-FP8',
67
+ 'XiaomiMiMo/MiMo-V2.6-Flash-RL',
68
+ 'XiaomiMiMo/MiMo-V2.6-Pro-RL',
69
+ ];
70
+ export const RIVER_DEFAULT_MODEL = RIVER_MODEL_CATALOG[0];
71
+
72
+ /** Per-provider request fields a config.json override may carry verbatim. */
73
+ export const PROVIDER_REQUEST_FIELDS = ['reasoningEffort', 'extraBody', 'requestParams', 'thinking'];
74
+
41
75
  /**
42
76
  * Built-in providers, in fallback order for key auto-detection.
43
77
  *
@@ -69,6 +103,23 @@ export const BUILTIN_PROVIDERS = [
69
103
  capabilities: { streaming: true, tools: true, reasoning: true, multimodal: false },
70
104
  docsUrl: 'https://tokenharbor.ai/',
71
105
  },
106
+ {
107
+ // River keeps thinking off by default for its DeepSeek models, so the
108
+ // dedicated client (providers/river.js) adds `reasoning_effort` to the
109
+ // request; without it a DeepSeek answer arrives with no reasoning text.
110
+ id: 'river',
111
+ name: 'River AI',
112
+ apiFormat: 'openai-chat-completions',
113
+ baseURL: RIVER_BASE_URL,
114
+ defaultModel: RIVER_DEFAULT_MODEL,
115
+ models: RIVER_MODEL_CATALOG.map((id) => ({
116
+ id,
117
+ name: id,
118
+ ...(id === RIVER_DEFAULT_MODEL ? { default: true } : {}),
119
+ })),
120
+ capabilities: { streaming: true, tools: true, reasoning: true, multimodal: false },
121
+ docsUrl: RIVER_DOCS_URL,
122
+ },
72
123
  ];
73
124
 
74
125
  /** config.json path that holds a provider's API key. */
@@ -140,9 +191,42 @@ function normalizeCapabilities(raw, issues, label) {
140
191
  }
141
192
  out[key] = raw[key];
142
193
  }
194
+ // Vendor extras kept inside capabilities (e.g. River's reasoning effort) are
195
+ // copied through untouched: the client that knows the field validates it.
196
+ if (typeof raw.reasoningEffort === 'string' && raw.reasoningEffort.trim()) {
197
+ out.reasoningEffort = raw.reasoningEffort.trim();
198
+ }
143
199
  return out;
144
200
  }
145
201
 
202
+ /** Copy the per-provider request overrides the descriptor declares. */
203
+ function mergeRequestFields(entry, raw, label, issues) {
204
+ for (const field of PROVIDER_REQUEST_FIELDS) {
205
+ if (raw[field] === undefined) continue;
206
+ if (field === 'reasoningEffort') {
207
+ if (typeof raw[field] !== 'string' || !raw[field].trim()) {
208
+ issues.push(`${label}.reasoningEffort must be a non-empty string`);
209
+ continue;
210
+ }
211
+ entry[field] = raw[field].trim();
212
+ continue;
213
+ }
214
+ if (field === 'thinking') {
215
+ if (typeof raw[field] !== 'boolean') {
216
+ issues.push(`${label}.thinking must be true or false`);
217
+ continue;
218
+ }
219
+ entry[field] = raw[field];
220
+ continue;
221
+ }
222
+ if (!raw[field] || typeof raw[field] !== 'object' || Array.isArray(raw[field])) {
223
+ issues.push(`${label}.${field} must be a JSON object`);
224
+ continue;
225
+ }
226
+ entry[field] = { ...raw[field] };
227
+ }
228
+ }
229
+
146
230
  /**
147
231
  * Parse the `extraProviders` list from config.json.
148
232
  *
@@ -154,7 +238,7 @@ function normalizeCapabilities(raw, issues, label) {
154
238
  * `providers.myproxy.apiKey`. Invalid entries are reported as config issues
155
239
  * instead of being guessed.
156
240
  */
157
- export function parseExtraProviders(value, issues = []) {
241
+ export function parseExtraProviders(value, issues = [], warnings = []) {
158
242
  if (value === undefined || value === null || String(value).trim() === '') return [];
159
243
  let parsed;
160
244
  try {
@@ -180,7 +264,25 @@ export function parseExtraProviders(value, issues = []) {
180
264
  issues.push(`${label}.id must match ${PROVIDER_ID_PATTERN}`);
181
265
  continue;
182
266
  }
183
- if (builtinIds.has(id) || extras.some((provider) => provider.id === id)) {
267
+ if (builtinIds.has(id)) {
268
+ // A stale entry for a provider that has since become built-in (River,
269
+ // for example) is dropped instead of failing startup, as long as it
270
+ // points at the same endpoint; the built-in descriptor then supplies the
271
+ // models, capabilities and any vendor request fields.
272
+ const builtin = BUILTIN_PROVIDERS.find((provider) => provider.id === id);
273
+ const sameUrl =
274
+ builtin &&
275
+ String(raw.baseURL || '').trim().replace(/\/+$/, '') === String(builtin.baseURL).trim().replace(/\/+$/, '');
276
+ if (sameUrl) {
277
+ warnings.push(
278
+ `${label} redeclares the built-in provider "${id}" with the same base URL; the built-in provider is used and this entry is ignored.`,
279
+ );
280
+ continue;
281
+ }
282
+ issues.push(`${label}.id ${JSON.stringify(id)} is already registered`);
283
+ continue;
284
+ }
285
+ if (extras.some((provider) => provider.id === id)) {
184
286
  issues.push(`${label}.id ${JSON.stringify(id)} is already registered`);
185
287
  continue;
186
288
  }
@@ -206,6 +308,11 @@ export function parseExtraProviders(value, issues = []) {
206
308
  capabilities: normalizeCapabilities(raw.capabilities, issues, label),
207
309
  docsUrl: null,
208
310
  source: 'settings',
311
+ ...(() => {
312
+ const entry = {};
313
+ mergeRequestFields(entry, raw, label, issues);
314
+ return entry;
315
+ })(),
209
316
  });
210
317
  }
211
318
  if (parsed.length > MAX_EXTRA_PROVIDERS) {
@@ -231,7 +338,7 @@ export function buildProviders({ credentials = {}, extras = [] } = {}) {
231
338
  const model = normalizeModelId(stored.model) || base.defaultModel;
232
339
  const models = [...base.models];
233
340
  if (model && !models.some((entry) => entry.id === model)) models.push({ id: model, name: model });
234
- providers.push({
341
+ const descriptor = {
235
342
  ...base,
236
343
  baseURL,
237
344
  apiKey,
@@ -240,7 +347,25 @@ export function buildProviders({ credentials = {}, extras = [] } = {}) {
240
347
  modelPath: modelPathFor(base.id),
241
348
  defaultModel: model,
242
349
  models,
243
- });
350
+ };
351
+ // Per-provider request overrides live next to the key
352
+ // (`providers.<id>.reasoningEffort`, `providers.<id>.extraBody`, ...). They
353
+ // travel with the descriptor, so `/provider` keeps them and the client
354
+ // sends them only to that provider.
355
+ for (const field of PROVIDER_REQUEST_FIELDS) {
356
+ const value = stored[field];
357
+ if (value === undefined) continue;
358
+ if (field === 'reasoningEffort') {
359
+ if (typeof value === 'string' && value.trim()) descriptor[field] = value.trim();
360
+ continue;
361
+ }
362
+ if (field === 'thinking') {
363
+ if (typeof value === 'boolean') descriptor[field] = value;
364
+ continue;
365
+ }
366
+ if (value && typeof value === 'object' && !Array.isArray(value)) descriptor[field] = { ...value };
367
+ }
368
+ providers.push(descriptor);
244
369
  }
245
370
  return providers;
246
371
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Provider identifiers and model-id rules.
3
+ *
4
+ * These primitives are shared by the provider modules (`nvidia.js`,
5
+ * `tokenharbor.js`, `river.js`) and by the catalog that assembles them. They
6
+ * live in their own file so a provider module can be imported by the catalog
7
+ * without an import cycle.
8
+ */
9
+
10
+ /** ID used when nothing else selects a provider. */
11
+ export const DEFAULT_PROVIDER_ID = 'nvidia';
12
+
13
+ export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
14
+ export const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
15
+ export const MAX_MODEL_ID_LENGTH = 200;
16
+
17
+ /**
18
+ * Per-provider request fields a config.json override may carry verbatim.
19
+ *
20
+ * `reasoningEffort` becomes the wire field `reasoning_effort`; `extraBody`
21
+ * and `requestParams` are merged into the request body as-is; `thinking` is a
22
+ * boolean shorthand for turning reasoning on or off.
23
+ */
24
+ export const PROVIDER_REQUEST_FIELDS = ['reasoningEffort', 'extraBody', 'requestParams', 'thinking'];
@@ -1,8 +1,13 @@
1
1
  import { ProviderError } from '../utils/errors.js';
2
2
  import { ProviderRegistry } from './registry.js';
3
3
  import { createProvider as createChatCompletionsProvider } from './nvidia.js';
4
+ // Importing a provider module registers its vendor request fields (River's
5
+ // `reasoning_effort`, for example) with vendor-extras.js.
6
+ import './river.js';
7
+ import './tokenharbor.js';
4
8
  import {
5
9
  DEFAULT_PROVIDER_ID,
10
+ PROVIDER_REQUEST_FIELDS,
6
11
  normalizeModelId,
7
12
  normalizeProviderId,
8
13
  providerById,
@@ -12,9 +17,14 @@ import {
12
17
  * Provider wiring.
13
18
  *
14
19
  * Every catalog entry speaks the OpenAI Chat Completions protocol, so they all
15
- * share one client implementation. The registry exists so a new protocol can be
16
- * added later without touching the agent: register a factory under its id and
17
- * the selection code will use it.
20
+ * share one client implementation. The registry exists so a different protocol
21
+ * can be added later without touching the agent: register a factory under its
22
+ * id and the selection code will use it.
23
+ *
24
+ * A provider that needs request fields the protocol does not define lives in its
25
+ * own module (`river.js`, `tokenharbor.js`) and registers a builder with
26
+ * `vendor-extras.js`; the shared client applies it by provider id, so a
27
+ * `/provider` switch needs no new instance.
18
28
  */
19
29
  function factory(config, deps) {
20
30
  return createChatCompletionsProvider(config, deps);
@@ -78,6 +88,15 @@ export function selectionFor(config, providerId, { model = null } = {}) {
78
88
  apiKey: descriptor.apiKey,
79
89
  model: chosen,
80
90
  capabilities: { ...(descriptor.capabilities || {}) },
91
+ // Vendor request overrides (River's reasoningEffort, a transparent
92
+ // extraBody, ...) travel with the selection so switching provider also
93
+ // switches which fields the client adds to the request body.
94
+ ...Object.fromEntries(
95
+ PROVIDER_REQUEST_FIELDS.filter((field) => descriptor[field] !== undefined).map((field) => [
96
+ field,
97
+ descriptor[field],
98
+ ]),
99
+ ),
81
100
  };
82
101
  }
83
102
 
@@ -2,7 +2,8 @@ import OpenAI from 'openai';
2
2
  import { CancelledError, ProviderError, isRecoverableError } from '../utils/errors.js';
3
3
  import { redact } from '../utils/redaction.js';
4
4
  import { toToolArgumentsString } from '../utils/validation.js';
5
- import { MODEL_ID_PATTERN, MAX_MODEL_ID_LENGTH } from './catalog.js';
5
+ import { MODEL_ID_PATTERN, MAX_MODEL_ID_LENGTH, PROVIDER_REQUEST_FIELDS } from './catalog.js';
6
+ import { applyRequestExtras } from './vendor-extras.js';
6
7
 
7
8
  const RETRY_BASE_MS = 500;
8
9
  const RETRY_MAX_MS = 8000;
@@ -348,6 +349,18 @@ export class NvidiaProvider {
348
349
  return this.config.capabilities || null;
349
350
  }
350
351
 
352
+ /**
353
+ * Extra top-level fields to merge into the request body before it is sent.
354
+ *
355
+ * The OpenAI Chat Completions protocol defines no vendor fields, so the
356
+ * builder comes from `vendor-extras.js`, where a provider module registers
357
+ * itself by id (River's `reasoning_effort`, for example). Registration is by
358
+ * id because one instance is reused across `/provider` switches.
359
+ */
360
+ requestExtras(context = {}) {
361
+ return applyRequestExtras(this.config, { ...context, logger: this.logger });
362
+ }
363
+
351
364
  get providerId() {
352
365
  return this.config.providerId || null;
353
366
  }
@@ -364,7 +377,7 @@ export class NvidiaProvider {
364
377
  * Repoint this provider at another provider or model without rebuilding the
365
378
  * agent (conversation memory and the worklog stay intact).
366
379
  */
367
- updateSelection({ providerId, providerName, apiKeyPath, docsUrl = null, baseURL, apiKey, model, capabilities = null } = {}) {
380
+ updateSelection({ providerId, providerName, apiKeyPath, docsUrl = null, baseURL, apiKey, model, capabilities = null, ...vendor } = {}) {
368
381
  if (!apiKey) {
369
382
  const path = apiKeyPath || `providers.${providerId || 'unknown'}.apiKey`;
370
383
  throw new ProviderError(
@@ -386,6 +399,14 @@ export class NvidiaProvider {
386
399
  model: model || this.config.model,
387
400
  capabilities: capabilities || this.config.capabilities || null,
388
401
  });
402
+ // Vendor request fields (River's `reasoningEffort`, a pass-through
403
+ // `extraBody`, ...) belong to the provider that is now active. The values
404
+ // of the provider left behind are dropped, so a request never carries a
405
+ // field meant for another endpoint.
406
+ for (const field of PROVIDER_REQUEST_FIELDS) {
407
+ delete this.config[field];
408
+ if (vendor[field] !== undefined) this.config[field] = vendor[field];
409
+ }
389
410
  if (!this.externalClient) this.client = this.#createClient();
390
411
  return this;
391
412
  }
@@ -441,6 +462,11 @@ export class NvidiaProvider {
441
462
  body.tools = tools;
442
463
  body.tool_choice = 'auto';
443
464
  }
465
+ // Vendor extensions (e.g. River's `reasoning_effort`, which turns thinking
466
+ // on for models whose default is off). The base client adds nothing; a
467
+ // provider-specific subclass overrides requestExtras().
468
+ const extras = this.requestExtras({ capabilities, streaming });
469
+ if (extras && typeof extras === 'object') Object.assign(body, extras);
444
470
 
445
471
  const startedAt = Date.now();
446
472
  const effectiveTimeout = Math.max(1000, Number(timeoutMs ?? this.config.requestTimeoutMs) || 1000);
@@ -0,0 +1,198 @@
1
+ import { MODEL_ID_PATTERN, MAX_MODEL_ID_LENGTH } from './catalog.js';
2
+ import { RIVER_DEFAULT_REASONING_EFFORT, RIVER_PROVIDER_ID, RIVER_REASONING_EFFORTS } from './catalog.js';
3
+ import { registerRequestExtras, passthroughExtras } from './vendor-extras.js';
4
+
5
+ export {
6
+ RIVER_BASE_URL,
7
+ RIVER_DEFAULT_MODEL,
8
+ RIVER_DEFAULT_REASONING_EFFORT,
9
+ RIVER_DOCS_URL,
10
+ RIVER_MODEL_CATALOG,
11
+ RIVER_PROVIDER_ID,
12
+ RIVER_REASONING_EFFORTS,
13
+ } from './catalog.js';
14
+
15
+ /**
16
+ * River AI provider.
17
+ *
18
+ * River speaks the OpenAI Chat Completions protocol, so it reuses the shared
19
+ * client (`nvidia.js`) and only adds the vendor fields that protocol does not
20
+ * define. The client asks `vendor-extras.js` for those fields by provider id,
21
+ * and this module registers the River builder at import time — no subclass, so
22
+ * one client instance keeps working across `/provider` switches.
23
+ *
24
+ * Why this file exists: River keeps thinking *off by default* for its DeepSeek
25
+ * models, so a plain Chat Completions request never produces `reasoning_content`
26
+ * and the CLI has nothing to render. River turns thinking on with a top-level
27
+ * `reasoning_effort` field (see https://docs.river.ai/guides/inference-controls/),
28
+ * which the shared client never sent. `riverRequestExtras()` adds it.
29
+ *
30
+ * The effort is only sent when the model is documented to accept it, and it is
31
+ * only forced for models whose documented default is *off*; every other River
32
+ * model already returns `reasoning_content` by itself.
33
+ */
34
+
35
+ /**
36
+ * Per-model reasoning profile, transcribed from
37
+ * https://docs.river.ai/guides/inference-controls/ (read 2026-10-30).
38
+ *
39
+ * `levels` is the model's accepted `reasoning_effort` menu, `onByDefault` tells
40
+ * whether it already thinks without the field, and `enable` is the level used to
41
+ * turn thinking on. A model missing from this table is left untouched: an effort
42
+ * value an endpoint does not know can fail the whole request.
43
+ */
44
+ export const RIVER_MODEL_REASONING = {
45
+ 'deepseek-ai/DeepSeek-V4.1-Flash-1017': {
46
+ levels: ['none', 'low', 'high', 'max'],
47
+ onByDefault: false,
48
+ enable: 'high',
49
+ },
50
+ 'deepseek-ai/DeepSeek-V4.1-Flash': {
51
+ levels: ['none', 'low', 'high', 'max'],
52
+ onByDefault: false,
53
+ enable: 'high',
54
+ },
55
+ 'zai-org/GLM-5.3-Flash': { levels: ['low', 'high', 'max'], onByDefault: true, enable: 'max' },
56
+ 'GLM-5.3': { levels: ['low', 'high', 'max'], onByDefault: true, enable: 'max' },
57
+ 'Qwen/Qwen3.8-27B-FP8': { levels: ['none', 'low', 'medium', 'xhigh'], onByDefault: true, enable: 'xhigh' },
58
+ 'nvidia/NVIDIA-Nemotron-3.5-Super-VL-120B-A12B-NVFP4': {
59
+ levels: ['none', 'medium', 'high'],
60
+ onByDefault: true,
61
+ enable: 'high',
62
+ },
63
+ };
64
+
65
+ /** Environment override for the River reasoning effort. */
66
+ export const RIVER_EFFORT_ENV = 'RYUU_RIVER_REASONING_EFFORT';
67
+ /** Generic environment override, read after the River-specific one. */
68
+ export const REASONING_EFFORT_ENV = 'RYUU_REASONING_EFFORT';
69
+
70
+ /**
71
+ * Normalize an effort value.
72
+ *
73
+ * Returns one of the documented levels, or null when the value is not a level at
74
+ * all. "off"/"disabled"/false map to "none" so the same word works in the
75
+ * environment variable and in config.json.
76
+ */
77
+ export function normalizeReasoningEffort(value) {
78
+ if (value === undefined || value === null) return null;
79
+ if (typeof value === 'boolean') return value ? RIVER_DEFAULT_REASONING_EFFORT : 'none';
80
+ const text = String(value).trim().toLowerCase().replace(/[\s_]+/g, '-');
81
+ if (!text) return null;
82
+ if (['off', 'false', 'no', 'disabled', 'disable'].includes(text)) return 'none';
83
+ if (['on', 'true', 'yes', 'enabled'].includes(text)) return RIVER_DEFAULT_REASONING_EFFORT;
84
+ return RIVER_REASONING_EFFORTS.includes(text) ? text : null;
85
+ }
86
+
87
+ /**
88
+ * The effort the user asked for, or null when they asked for nothing.
89
+ *
90
+ * Precedence: `RYUU_RIVER_REASONING_EFFORT`, `reasoningEffort` from config.json,
91
+ * `RYUU_REASONING_EFFORT`, then `capabilities.reasoningEffort` — so an official
92
+ * `river` provider and a hand-written provider entry can both set it.
93
+ */
94
+ export function resolveRequestedEffort(config = {}, env = process.env) {
95
+ const candidates = [
96
+ env?.[RIVER_EFFORT_ENV],
97
+ config?.reasoningEffort,
98
+ env?.[REASONING_EFFORT_ENV],
99
+ config?.capabilities?.reasoningEffort,
100
+ ];
101
+ for (const candidate of candidates) {
102
+ const effort = normalizeReasoningEffort(candidate);
103
+ if (effort) return effort;
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /** The model's documented profile, or null when it is not a known model. */
109
+ export function reasoningProfileFor(model) {
110
+ return RIVER_MODEL_REASONING[String(model || '')] || null;
111
+ }
112
+
113
+ /**
114
+ * Decide which `reasoning_effort` (if any) to send for a model.
115
+ *
116
+ * Returns null when the field must be omitted — the user asked for nothing and
117
+ * the model already reasons by default, or the requested level is not in the
118
+ * model's documented menu.
119
+ */
120
+ export function resolveReasoningEffort(config = {}, { env = process.env, model = null, logger = null } = {}) {
121
+ const target = String(model || config?.model || '');
122
+ const profile = reasoningProfileFor(target);
123
+ const requested = resolveRequestedEffort(config, env);
124
+ let effort = null;
125
+ if (requested) {
126
+ effort = requested;
127
+ } else if (config?.thinking === false) {
128
+ effort = 'none';
129
+ } else if (config?.thinking === true) {
130
+ effort = profile?.enable || RIVER_DEFAULT_REASONING_EFFORT;
131
+ } else if (profile && !profile.onByDefault) {
132
+ effort = profile.enable; // DeepSeek: thinking is off unless asked for
133
+ }
134
+ if (!effort || effort === 'none') {
135
+ // "none" only means something to a model that accepts it; elsewhere an
136
+ // absent field falls back to the same model default.
137
+ return effort === 'none' && profile?.levels?.includes('none') ? 'none' : null;
138
+ }
139
+ if (profile && !profile.levels.includes(effort)) {
140
+ logger?.warn('reasoning_effort is not supported by this model; using the provider default', {
141
+ model: target,
142
+ requested: effort,
143
+ supported: profile.levels,
144
+ });
145
+ return profile.enable && profile.levels.includes(profile.enable) ? profile.enable : null;
146
+ }
147
+ return effort;
148
+ }
149
+
150
+ /**
151
+ * River's request-body extension for the shared client.
152
+ *
153
+ * Opaque pass-through fields first (a hand-written `extraProviders` entry may
154
+ * carry `extraBody`/`requestParams`), then the `reasoning_effort` that turns
155
+ * DeepSeek thinking on.
156
+ */
157
+ export function riverRequestExtras(config = {}, { logger = null, env = process.env } = {}) {
158
+ const extras = passthroughExtras(config);
159
+ const effort = resolveReasoningEffort(config, { model: config.model, logger, env });
160
+ if (effort) extras.reasoning_effort = effort;
161
+ return extras;
162
+ }
163
+
164
+ /** True when a requestExtras() payload carries an explicit reasoning effort. */
165
+ export function hasReasoningEffort(extras) {
166
+ const value = extras?.reasoning_effort;
167
+ if (value === undefined || value === null) return false;
168
+ return !(typeof value === 'string' && !value.trim());
169
+ }
170
+
171
+ /** Human-readable effort for /status. */
172
+ export function describeEffort(effort) {
173
+ return !effort || effort === 'none' ? 'off (model default)' : effort;
174
+ }
175
+
176
+ /**
177
+ * One line describing the reasoning effort that will be sent, or null when the
178
+ * provider does not use one (so `/status` stays quiet for them).
179
+ */
180
+ export function describeReasoning(config = {}, { env = process.env, logger = null } = {}) {
181
+ const extras = riverRequestExtras(config, { env, logger });
182
+ if (!hasReasoningEffort(extras)) return null;
183
+ const effort = extras.reasoning_effort;
184
+ return effort === 'none'
185
+ ? 'off (reasoning_effort: none)'
186
+ : `reasoning_effort: ${effort}`;
187
+ }
188
+
189
+ /** Sanity check used by tests and `/doctor`: the model id must remain valid. */
190
+ export function isValidRiverModel(model) {
191
+ return typeof model === 'string' && model.length <= MAX_MODEL_ID_LENGTH && MODEL_ID_PATTERN.test(model);
192
+ }
193
+
194
+ // The shared client consults this table on every request. Registering at import
195
+ // time keeps the wiring in one place: `providers/index.js` only has to import
196
+ // this module, and any provider with the id "river" gets the extension. The host
197
+ // fallback also covers a hand-written `extraProviders` entry for the same API.
198
+ registerRequestExtras(RIVER_PROVIDER_ID, riverRequestExtras, { hosts: ['api.river.ai'] });
@@ -0,0 +1,38 @@
1
+ import { TOKENHARBOR_BASE_URL, TOKENHARBOR_MODEL } from './catalog.js';
2
+ import { registerRequestExtras, passthroughExtras } from './vendor-extras.js';
3
+
4
+ /**
5
+ * Token Harbor provider.
6
+ *
7
+ * It speaks the OpenAI Chat Completions protocol and already returns
8
+ * `reasoning_content`, so it needs no vendor request field: the module exists
9
+ * to own the provider's defaults and to be the place a future Token Harbor-only
10
+ * field would go.
11
+ *
12
+ * A user `extraProviders` entry may still carry `extraBody`/`requestParams`; they
13
+ * are passed through untouched, which makes an unusual gateway reachable without
14
+ * a code change.
15
+ */
16
+
17
+ export const TOKENHARBOR_DOCS_URL = 'https://tokenharbor.ai/';
18
+
19
+ /** Request-body extension for Token Harbor: only explicit pass-through fields. */
20
+ export function tokenHarborRequestExtras(config = {}) {
21
+ return passthroughExtras(config);
22
+ }
23
+
24
+ /** Declared descriptor, kept next to the client like the other provider modules. */
25
+ export function tokenHarborDescriptor() {
26
+ return {
27
+ id: 'tokenharbor',
28
+ name: 'Token Harbor',
29
+ apiFormat: 'openai-chat-completions',
30
+ baseURL: TOKENHARBOR_BASE_URL,
31
+ defaultModel: TOKENHARBOR_MODEL,
32
+ models: [{ id: TOKENHARBOR_MODEL, name: 'DeepSeek V4.1 Flash Free', default: true }],
33
+ capabilities: { streaming: true, tools: true, reasoning: true, multimodal: false },
34
+ docsUrl: TOKENHARBOR_DOCS_URL,
35
+ };
36
+ }
37
+
38
+ registerRequestExtras('tokenharbor', tokenHarborRequestExtras);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Provider ids whose request body needs extra fields. Keyed by provider id (and
3
+ * by API host for a hand-written entry pointing at the same endpoint).
4
+ *
5
+ * `passthrough` is the fallback for any provider without a registered builder:
6
+ * a `extraBody`/`requestParams` object in its config.json entry is copied into
7
+ * the request verbatim, so an unusual gateway stays reachable without a code
8
+ * change.
9
+ */
10
+
11
+ /** providerId -> { builder, hosts: Set<string> } */
12
+ const builders = new Map();
13
+
14
+ function hostOf(baseURL) {
15
+ try {
16
+ return new URL(String(baseURL || '')).hostname.toLowerCase();
17
+ } catch {
18
+ return '';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Register (or replace) the builder for one provider id.
24
+ *
25
+ * `hosts` makes the builder apply to any provider whose base URL uses that host,
26
+ * which keeps a hand-written `extraProviders` entry for the same vendor working.
27
+ */
28
+ export function registerRequestExtras(providerId, builder, { hosts = [] } = {}) {
29
+ if (!providerId || typeof builder !== 'function') return;
30
+ const list = [].concat(hosts).map((host) => String(host || '').toLowerCase()).filter(Boolean);
31
+ builders.set(String(providerId), { builder, hosts: new Set(list) });
32
+ }
33
+
34
+ /** Remove a builder. Mostly useful for tests. */
35
+ export function unregisterRequestExtras(providerId) {
36
+ builders.delete(String(providerId));
37
+ }
38
+
39
+ /** The registered builder for a provider id, then fall back to its API host. */
40
+ export function requestExtrasFor(providerId, baseURL = '') {
41
+ const direct = builders.get(String(providerId || ''));
42
+ if (direct) return direct.builder;
43
+ const host = hostOf(baseURL);
44
+ if (!host) return null;
45
+ for (const entry of builders.values()) {
46
+ if (entry.hosts.has(host)) return entry.builder;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Default builder for providers without one: copy the explicit pass-through
53
+ * objects a config.json entry may carry. Nothing is guessed, so a plain provider
54
+ * still sends only the standard Chat Completions fields.
55
+ */
56
+ export function passthroughExtras(config = {}) {
57
+ const extras = {};
58
+ for (const field of ['extraBody', 'requestParams']) {
59
+ const value = config?.[field];
60
+ if (value && typeof value === 'object' && !Array.isArray(value)) Object.assign(extras, value);
61
+ }
62
+ return extras;
63
+ }
64
+
65
+ /**
66
+ * Extra top-level request fields for this provider, or null when it has none.
67
+ * A builder that throws must never break a request: the failure is swallowed
68
+ * and the request goes out unchanged.
69
+ */
70
+ export function applyRequestExtras(config, context = {}) {
71
+ const builder = requestExtrasFor(config?.providerId, config?.baseURL) || passthroughExtras;
72
+ try {
73
+ const extras = builder(config, context);
74
+ return extras && typeof extras === 'object' && Object.keys(extras).length > 0 ? extras : null;
75
+ } catch (error) {
76
+ context.logger?.warn('provider request extras failed; sending the request unchanged', {
77
+ provider: config?.providerId,
78
+ message: error?.message || String(error),
79
+ });
80
+ return null;
81
+ }
82
+ }