dsh-newapi-provider 0.8.4 → 0.9.4

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
@@ -82,6 +82,31 @@ Each gateway keeps an independent list, even when two gateways point at the same
82
82
 
83
83
  Capability data is a hint, not proof: a relay may list a model and still fail to serve it.
84
84
 
85
+ ## Thinking levels
86
+
87
+ Vendors do not agree on reasoning levels, so each model resolves its own set instead of every reasoning model showing the same five:
88
+
89
+ | Vendor / model | Levels offered | Notes |
90
+ | --- | --- | --- |
91
+ | Claude adaptive thinking (Opus 5 / Sonnet 5 / Fable) | low / medium / high / xhigh / max | no `minimal`, no `off` |
92
+ | Older Claude (budget based) | off / minimal / low / medium / high | mapped to token budgets |
93
+ | GPT-5.1+ / GPT-6 | off / low / medium / high / xhigh / max | `off` is sent as `none`, no `minimal` |
94
+ | GPT-5.0 / o-series | minimal / low / medium / high | GPT-5.0 cannot be turned off; o-series stops at high |
95
+ | gpt-5-pro | high only | the vendor accepts nothing else |
96
+ | Gemini 3 / 4 | minimal / low / medium / high | `thinking_level` has no off; no xhigh/max |
97
+ | Gemini 2.5 | off / minimal / low / medium / high | budget based; Pro cannot really be switched off |
98
+ | DeepSeek V4 | off / low / high / max | the vendor folds the in-between levels |
99
+ | GLM-5.3 | low / high / max | sending `disabled` errors, so no off |
100
+ | GLM-5.2 | all seven | most levels are folded upstream |
101
+ | Kimi K3 | low / high / max | no medium, cannot be switched off |
102
+ | Qwen3 / unknown | off / minimal / low / medium / high | pi-ai's own default when a model has no levels |
103
+
104
+ Resolution order: **your override → pi-ai's shipped model data (same model, same behaviour as the built-in provider) → vendor-family default**.
105
+
106
+ **Model capabilities → Edit model → Thinking levels** opens on a table pre-filled from the model data (turn the switch off to see the detected set read-only). The left column is the level shown in the chat selector — the built-ins (off/minimal/low/medium/high/xhigh/max) or a name you type yourself (1–24 lowercase letters, digits, `-`, `_`); a custom level must carry a value and then appears in the chat selector like any other. The right column is what that level sends upstream; an empty value sends nothing. The mapping is stored per model per gateway and affects only this plugin.
107
+
108
+ > Limitation: relays rename models (`claude-opus-5-5`), so a family guess fills the gap when the exact id is unknown. Use a custom override if the upstream disagrees. A level only changes the request; whether the upstream honours that depth is up to it.
109
+
85
110
  ## Models and modalities
86
111
 
87
112
  models.dev data is cached. Exact matches win; when nothing matches, defaults apply. Base-model matches, defaults and manual overrides are all marked in the UI. Never treat a guessed capability as something the gateway has proven.
package/README.zh.md CHANGED
@@ -87,6 +87,31 @@ DeepSeek Harness 的 New API 渠道与模型工作台:多网关同一时间激
87
87
 
88
88
  跨厂商或跨模型系列映射需要检查协议、容量和能力是否合适。模型编辑器修改参数时绑定打开时的渠道;出现配置冲突提示应刷新再编辑,避免覆盖另一个窗口的修改。
89
89
 
90
+ ## 思考等级(v0.9.0)
91
+
92
+ 不同厂商的推理档位并不相同,插件按模型分别解析,不再对所有推理模型显示同一组档位:
93
+
94
+ | 厂商 / 模型 | 提供的档位 | 说明 |
95
+ | --- | --- | --- |
96
+ | Claude 自适应思考(Opus 5 / Sonnet 5 / Fable) | 低 / 中 / 高 / 很高 / 最高 | 无「最少」,`off` 不提供 |
97
+ | 旧版 Claude(预算式) | 关闭 / 最少 / 低 / 中 / 高 | 按 token 预算换算 |
98
+ | GPT-5.1+ / GPT-6 | 关闭 / 低 / 中 / 高 / 很高 / 最高 | `off` 发 `none`,无「最少」 |
99
+ | GPT-5.0 / o 系列 | 最少 / 低 / 中 / 高 | GPT-5.0 无法关闭;o 系列只到高 |
100
+ | gpt-5-pro | 仅「高」 | 官方只接受 high |
101
+ | Gemini 3 / 4 | 最少 / 低 / 中 / 高 | `thinking_level` 无关闭项;xhigh/max 不存在 |
102
+ | Gemini 2.5 | 关闭 / 最少 / 低 / 中 / 高 | 预算式;Pro 无法真正关闭 |
103
+ | DeepSeek V4 | 关闭 / 低 / 高 / 最高 | 官方会折叠无意义档位 |
104
+ | GLM-5.3 | 低 / 高 / 最高 | 传 `disabled` 会报错,故不提供关闭 |
105
+ | GLM-5.2 | 全部 7 档 | 多数档位会被上游折叠 |
106
+ | Kimi K3 | 低 / 高 / 最高 | 无 medium、无法关闭 |
107
+ | Qwen3 / 未知模型 | 关闭 / 最少 / 低 / 中 / 高 | 无离散档位时沿用 pi-ai 默认 |
108
+
109
+ 解析顺序:**你的自定义覆盖 → pi-ai 自带模型数据(同一模型与官方 provider 行为一致)→ 厂商家族默认**。
110
+
111
+ 在「模型能力 → 编辑模型参数 → 推理等级」里,开关默认打开并预填模型当前档位。左列是对话选择器里的档位(英文),右列是该档位发给网关的参数值,留空表示不发送参数。左列除了 off/minimal/low/medium/high/xhigh/max 内置档位,也可以直接输入自定义名字(1–24 位小写字母、数字、`-`、`_`),自定义档位必须填写参数值,它会像内置档位一样出现在对话选择器里。配置按渠道按模型保存,只影响本插件。
112
+
113
+ > 限制:中转站的模型 id 常与厂商原名不同(如 `claude-opus-5-5`),无法逐字命中时按家族推断;若上游行为与推断不符,用自定义覆盖修正。思考等级只影响发送给网关的参数,是否能达到该档位仍取决于上游。
114
+
90
115
  ## 模型与多模态
91
116
 
92
117
  models.dev 数据有缓存。精确匹配优先,无法匹配时使用默认值;基础模型匹配、默认值与手动覆盖均在界面标出。切勿把猜测的能力当作网关实测结果。
package/client.js CHANGED
@@ -48,6 +48,17 @@ window.__ModuleLoader__.load({
48
48
  reasoning: '推理', noReasoning: '未声明推理', auto: '自动识别', overridden: '已自定义', defaults: '默认值',
49
49
  modelEmpty: '没有匹配的模型', noModels: '当前渠道暂无模型目录', hidden: '已隐藏', modelEdit: '编辑模型参数', modelEditHint: '只影响此渠道的该模型;不会修改 New API 站点。',
50
50
  kind: '模型类型', displayName: '显示名称', inputModalities: '输入能力', inputHint: 'DSH 对话层支持文本与图片;PDF、音视频标签仅用于展示,不能直接作为对话输入。',
51
+ reasoningLevels: '推理等级', reasoningLevelsMode: '推理等级模式',
52
+ levelsAutoHint: '已关闭自定义,按模型自动识别的档位发送;打开开关可以逐个修改。',
53
+ levelsCustomHint: '下面是从模型数据预填的档位。左侧决定对话里可选什么,右侧是发给网关的参数值;留空表示不发送参数(常用于 off)。不改动就保持跟随模型。',
54
+ levelsOffered: '将提供',
55
+ levelsRequired: '至少保留一个档位', level_off: 'off', level_minimal: 'minimal', level_low: 'low', level_medium: 'medium', level_high: 'high', level_xhigh: 'xhigh', level_max: 'max',
56
+ levelColumn: '档位', apiValueColumn: 'API 参数值', apiValuePlaceholder: '留空则不发送',
57
+ levelPlaceholder: '档位名,可自定义', levelInvalid: '档位只能用 1-24 位小写字母、数字、- 或 _',
58
+ levelDuplicate: '档位名重复了', levelNeedsValue: '自定义档位必须填写 API 参数值',
59
+ levelCustomOption: '自定义…', levelBackToPreset: '改回内置档位',
60
+ addLevel: '添加档位', removeLevel: '移除该档位',
61
+ levelsHelp: '不同厂商的推理档位并不相同:这里决定对话里可选哪些档位,以及每个档位发给网关的参数值。关闭开关则跟随模型自动识别。',
51
62
  reasoningEnabled: '声明支持推理', hideModel: '隐藏该模型', modelSave: '保存参数', reset: '恢复自动识别',
52
63
  resetHint: '清除此渠道中该模型的全部自定义参数。', modelSaved: '模型参数已更新', modelReset: '已恢复自动识别',
53
64
  inherited: '留空使用自动识别或默认值', positiveInteger: '请输入大于 0 的整数',
@@ -127,6 +138,17 @@ window.__ModuleLoader__.load({
127
138
  reasoning: 'Reasoning', noReasoning: 'No reasoning declared', auto: 'Automatic', overridden: 'Customized', defaults: 'Default',
128
139
  modelEmpty: 'No matching models', noModels: 'No model catalog for this gateway', hidden: 'Hidden', modelEdit: 'Edit model', modelEditHint: 'Changes this model on this gateway only, not the New API site.',
129
140
  kind: 'Model kind', displayName: 'Display name', inputModalities: 'Input capabilities', inputHint: 'DSH chat accepts text and image. PDF, video and audio tags are informational only.',
141
+ reasoningLevels: 'Thinking levels', reasoningLevelsMode: 'Thinking level mode',
142
+ levelsAutoHint: 'Customisation is off: the levels detected for this model are sent as-is. Turn the switch on to edit them.',
143
+ levelsCustomHint: 'Pre-filled from the model data. The left column decides what the chat selector offers, the right column is the value sent upstream; empty sends nothing (common for off). Leave it untouched to keep following the model.',
144
+ levelsOffered: 'Offers',
145
+ levelsRequired: 'Pick at least one thinking level', level_off: 'off', level_minimal: 'minimal', level_low: 'low', level_medium: 'medium', level_high: 'high', level_xhigh: 'xhigh', level_max: 'max',
146
+ levelColumn: 'Level', apiValueColumn: 'API value', apiValuePlaceholder: 'Empty sends nothing',
147
+ levelPlaceholder: 'Level name, custom allowed', levelInvalid: 'Levels use 1-24 lowercase letters, digits, - or _',
148
+ levelDuplicate: 'Duplicate level name', levelNeedsValue: 'A custom level needs an API value',
149
+ levelCustomOption: 'Custom…', levelBackToPreset: 'Back to built-in levels',
150
+ addLevel: 'Add level', removeLevel: 'Remove level',
151
+ levelsHelp: 'Vendors differ: this decides which levels the chat selector offers and what each one sends upstream. Leave the switch off to follow the detected set.',
130
152
  reasoningEnabled: 'Declare reasoning support', hideModel: 'Hide model', modelSave: 'Save parameters', reset: 'Use automatic values',
131
153
  resetHint: 'Removes all overrides for this model on this gateway.', modelSaved: 'Model parameters updated', modelReset: 'Automatic values restored',
132
154
  inherited: 'Leave empty for automatic or default values', positiveInteger: 'Enter a positive integer',
@@ -178,6 +200,7 @@ window.__ModuleLoader__.load({
178
200
  useEffect(() => locale?.subscribe(() => rerender((x) => x + 1)), []);
179
201
  return (key) => translate?.(key) ?? ZH[key] ?? key;
180
202
  }
203
+ const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
181
204
  const ICONS = {
182
205
  plus: 'M12 5v14M5 12h14', refresh: 'M20 7v5h-5M4 17v-5h5M6.1 7a7 7 0 0 1 11.6-1L20 9M4 15l2.3 3a7 7 0 0 0 11.6-1',
183
206
  check: 'm5 12 4 4L19 6', close: 'm6 6 12 12M6 18 18 6', edit: 'm15 5 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16Z',
@@ -371,6 +394,38 @@ window.__ModuleLoader__.load({
371
394
  .nas-side-balance-icon{display:inline-flex;color:var(--nas-sub);flex-shrink:0}
372
395
  .nas-side-balance[data-wide=no] .nas-side-balance-btn{justify-content:center;padding:6px 0;gap:0}
373
396
  .nas-side-balance[data-wide=no] .nas-side-balance-icon{display:none}
397
+ /* Level chips: same look as the input-modality toggles. */
398
+ .nas-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
399
+ .nas-chips .nas-chip,.nas-chips button{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--nas-line);background:var(--nas-bg);color:var(--nas-text);border-radius:7px;padding:4px 9px;font-size:12px;line-height:1.5}
400
+ .nas-chips button:hover{border-color:var(--nas-brand)}
401
+ .nas-chips button[aria-pressed=true]{border-color:var(--nas-brand);background:color-mix(in srgb,var(--nas-accent) 7%,transparent);font-weight:550}
402
+ .nas-chips.readonly .nas-chip{cursor:default;color:var(--nas-sub)}
403
+ .nas-chips.readonly .nas-chip.on{color:var(--nas-text);border-color:var(--nas-line-strong,var(--nas-line))}
404
+ /* Reasoning levels: switch + level/value table, mirroring the editor rows. */
405
+ .nas-levels{display:flex;flex-direction:column;gap:8px;margin-top:14px;padding:12px 13px;border:1px solid var(--nas-line);border-radius:10px;background:var(--nas-raised)}
406
+ .nas-levels-head{display:flex;align-items:center;justify-content:space-between;gap:12px}
407
+ .nas-levels-title{display:flex;align-items:center;gap:6px;font-size:12.5px;font-weight:550}
408
+ .nas-help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border:1px solid var(--nas-line-strong,var(--nas-line));border-radius:50%;font-size:10px;color:var(--nas-sub);cursor:help}
409
+ .nas-switch{position:relative;flex-shrink:0;width:38px;height:22px;border:1px solid var(--nas-line);border-radius:999px;background:var(--nas-line);cursor:pointer;transition:background .15s,border-color .15s}
410
+ .nas-switch>i{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.2);transition:transform .15s}
411
+ .nas-switch.on{background:var(--nas-brand);border-color:var(--nas-brand)}
412
+ .nas-switch.on>i{transform:translateX(16px)}
413
+ .nas-switch:disabled{opacity:.5;cursor:default}
414
+ .nas-levels-table{display:flex;flex-direction:column;gap:8px}
415
+ .nas-levels-row{display:grid;grid-template-columns:minmax(96px,150px) minmax(0,1fr) 26px;align-items:center;gap:10px}
416
+ .nas-levels-row.head{font-size:11px;color:var(--nas-sub);padding:0 1px}
417
+ .nas-levels-row select,.nas-levels-row input{width:100%;min-width:0;border:1px solid var(--nas-line);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--nas-text);padding:6px 9px;font-size:12.5px;font-family:inherit}
418
+ .nas-levels-row select:focus-visible,.nas-levels-row input:focus-visible{outline:2px solid var(--nas-brand);outline-offset:1px}
419
+ .nas-levels-remove{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border:1px solid transparent;border-radius:7px;background:transparent;color:var(--nas-sub);cursor:pointer}
420
+ .nas-levels-remove:hover:not(:disabled){color:var(--dsw-alias-state-error-primary);border-color:var(--nas-line)}
421
+ .nas-levels-remove:disabled{opacity:.35;cursor:default}
422
+ .nas-levels-add{display:inline-flex;align-items:center;gap:6px;align-self:flex-start;border:1px solid var(--nas-line);border-radius:8px;background:var(--dsw-alias-bg-base);color:var(--nas-text);padding:6px 10px;font-size:12px;cursor:pointer}
423
+ .nas-levels-add:hover:not(:disabled){border-color:var(--nas-brand)}
424
+ .nas-levels-add:disabled{opacity:.5;cursor:default}
425
+ .nas-levels-custom{display:flex;align-items:center;gap:6px;min-width:0}
426
+ .nas-levels-custom input{flex:1;min-width:0}
427
+ .nas-levels-back{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;flex-shrink:0;border:1px solid var(--nas-line);border-radius:6px;background:var(--dsw-alias-bg-base);color:var(--nas-sub);cursor:pointer}
428
+ .nas-levels-back:hover:not(:disabled){color:var(--nas-text);border-color:var(--nas-brand)}
374
429
  /* Tooltips: instant, themed, keyboard accessible. */
375
430
  .nas [data-tip]{position:relative}
376
431
  .nas [data-tip]::after{content:attr(data-tip);position:absolute;z-index:60;bottom:calc(100% + 9px);left:50%;transform:translateX(-50%);width:max-content;max-width:270px;padding:6px 9px;border-radius:7px;border:1px solid var(--nas-line);background:var(--dsw-alias-bg-overlay);color:var(--nas-text);font-size:11px;font-weight:400;line-height:1.55;text-align:left;white-space:pre-line;box-shadow:0 8px 24px color-mix(in srgb,var(--dsw-alias-label-primary) 16%,transparent);opacity:0;pointer-events:none;transition:opacity .12s}
@@ -569,6 +624,117 @@ window.__ModuleLoader__.load({
569
624
  : h('div', { className: 'nas-test-error' }, tested.error))
570
625
  : h('div', { className: 'nas-note', style: { marginTop: 14 } }, h(Icon, { name: 'info', size: 13 }), t('testHint'))));
571
626
  }
627
+ /**
628
+ * Reasoning levels: a switch plus a level→wire-value table.
629
+ * Off = follow what the model declares; on = the table is exactly what ships.
630
+ */
631
+ function ReasoningLevelsEditor({ enabled, rows, detected, busy, onToggle, onChange }) {
632
+ const CUSTOM_CHOICE = '__custom';
633
+ const t = useT();
634
+ const setRow = (index, patch) => onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
635
+ // A new row starts on the first preset nobody uses yet; when all seven are taken it
636
+ // starts as a custom row instead, which is the only case where a name is typed.
637
+ const add = () => {
638
+ const free = LEVELS.find((level) => !rows.some((row) => row.level === level));
639
+ onChange([...rows, free ? { level: free, value: defaultWireFor(free) } : { level: '', value: '' }]);
640
+ };
641
+ const used = new Set(rows.map((row) => row.level).filter(Boolean));
642
+ const problems = rows.map((row) => {
643
+ if (!row.level) return { row: true };
644
+ if (!LEVELS.includes(row.level) && !CUSTOM_LEVEL.test(row.level)) return { invalid: true };
645
+ if (rows.filter((other) => other.level === row.level).length > 1) return { duplicate: true };
646
+ if (row.value.trim() === '' && !LEVELS.includes(row.level)) return { needsValue: true };
647
+ return {};
648
+ });
649
+ return h('div', { className: 'nas-levels' },
650
+ h('div', { className: 'nas-levels-head' },
651
+ h('div', { className: 'nas-levels-title' },
652
+ h('span', null, t('reasoningLevels')),
653
+ h('span', { className: 'nas-help', role: 'img', 'aria-label': t('levelsHelp'), 'data-tip': t('levelsHelp'), 'data-tip-align': 'start' }, '?')),
654
+ h('button', {
655
+ type: 'button', role: 'switch', 'aria-checked': enabled, 'aria-label': t('reasoningLevelsMode'),
656
+ className: `nas-switch ${enabled ? 'on' : ''}`, disabled: busy, onClick: onToggle,
657
+ }, h('i'))),
658
+ h('div', { className: 'nas-sub' }, enabled ? t('levelsCustomHint') : t('levelsAutoHint')),
659
+ !enabled && h('div', { className: 'nas-levels-summary', lang: 'en' }, `${t('levelsOffered')}: ${detected.join(' / ') || '—'}`),
660
+ enabled && h(React.Fragment, null,
661
+ h('div', { className: 'nas-levels-table' },
662
+ h('div', { className: 'nas-levels-row head' },
663
+ h('span', null, t('levelColumn')), h('span', null, t('apiValueColumn')), h('span', { 'aria-hidden': true })),
664
+ rows.map((row, index) => h('div', { className: 'nas-levels-row', key: `${row.level}-${index}` },
665
+ LEVELS.includes(row.level)
666
+ ? h('select', {
667
+ value: row.level, disabled: busy, lang: 'en', 'aria-label': t('levelColumn'),
668
+ onChange: (e) => setRow(index, e.target.value === CUSTOM_CHOICE
669
+ ? { level: '' } // switch this row to a typed name
670
+ : { level: e.target.value, value: row.value || defaultWireFor(e.target.value) }),
671
+ },
672
+ LEVELS.map((level) => h('option', {
673
+ key: level, value: level, lang: 'en',
674
+ disabled: level !== row.level && used.has(level),
675
+ }, level)),
676
+ h('option', { value: CUSTOM_CHOICE }, t('levelCustomOption')))
677
+ : h('div', { className: 'nas-levels-custom' },
678
+ h('input', {
679
+ value: row.level, disabled: busy, lang: 'en', spellCheck: false, autoFocus: true,
680
+ placeholder: t('levelPlaceholder'), 'aria-label': t('levelColumn'),
681
+ 'aria-invalid': Boolean(problems[index].invalid || problems[index].duplicate),
682
+ onChange: (e) => setRow(index, { level: e.target.value.trim().toLowerCase() }),
683
+ }),
684
+ h('button', { type: 'button', className: 'nas-levels-back', disabled: busy,
685
+ 'aria-label': t('levelBackToPreset'), 'data-tip': t('levelBackToPreset'), 'data-tip-align': 'end',
686
+ onClick: () => {
687
+ const free = LEVELS.find((level) => !used.has(level));
688
+ setRow(index, free ? { level: free, value: defaultWireFor(free) } : { level: LEVELS[0], value: defaultWireFor(LEVELS[0]) });
689
+ } }, h(Icon, { name: 'refresh', size: 12 }))),
690
+ h('input', {
691
+ value: row.value, disabled: busy, lang: 'en', spellCheck: false, placeholder: t('apiValuePlaceholder'),
692
+ 'aria-label': t('apiValueColumn'), onChange: (e) => setRow(index, { value: e.target.value }),
693
+ }),
694
+ h('button', {
695
+ type: 'button', className: 'nas-levels-remove', disabled: busy || rows.length <= 1,
696
+ 'aria-label': t('removeLevel'), 'data-tip': t('removeLevel'), 'data-tip-align': 'end',
697
+ onClick: () => onChange(rows.filter((_, i) => i !== index)),
698
+ }, h(Icon, { name: 'trash', size: 13 })))),
699
+ h('button', { type: 'button', className: 'nas-levels-add', disabled: busy || rows.length >= LEVELS.length, onClick: add },
700
+ h(Icon, { name: 'plus', size: 13 }), t('addLevel')),
701
+ !rows.length && h('span', { className: 'nas-field-error' }, t('levelsRequired')))));
702
+ }
703
+ /** Level→wire map to editable rows; falls back to the detected set when nothing is pinned. */
704
+ function rowsFromMap(map, detectedLevels, detectedMap) {
705
+ if (map && Object.keys(map).length) {
706
+ const names = Object.keys(map).filter((l) => typeof map[l] === 'string');
707
+ return orderLevelNames(names).map((l) => ({ level: l, value: map[l] }));
708
+ }
709
+ const levels = detectedLevels ?? [];
710
+ return levels.map((l) => ({ level: l, value: (detectedMap && typeof detectedMap[l] === 'string') ? detectedMap[l] : defaultWireFor(l) }));
711
+ }
712
+ /** Preset levels first, then custom names — the same order the server resolves. */
713
+ function orderLevelNames(names) {
714
+ const preset = LEVELS.filter((l) => names.includes(l));
715
+ const custom = names.filter((l) => !LEVELS.includes(l)).sort();
716
+ return [...preset, ...custom];
717
+ }
718
+ /** A custom level name must be a safe id; presets are always allowed. */
719
+ const CUSTOM_LEVEL = /^[a-z0-9][a-z0-9_-]{0,23}$/;
720
+ /** Rows to the override map. A blank value means "this level sends nothing" (e.g. off). */
721
+ function rowsToMap(rows) {
722
+ const map = {};
723
+ for (const row of rows) map[row.level] = row.value.trim() ? row.value.trim() : null;
724
+ return map;
725
+ }
726
+ /** True when two level maps say the same thing (null and absent both mean "hidden"). */
727
+ function sameLevelMap(a, b) {
728
+ const read = (map, level) => {
729
+ const value = map ? map[level] : undefined;
730
+ return typeof value === 'string' ? value : null;
731
+ };
732
+ return LEVELS.every((level) => read(a, level) === read(b, level));
733
+ }
734
+ /** Wire value a level usually needs when the user adds it by hand. */
735
+ function defaultWireFor(level) {
736
+ return level === 'off' ? '' : level;
737
+ }
572
738
  function ModelEditor({ model, channel, revision, siblings = [], onClose, onSaved }) {
573
739
  const t = useT(); const id = useId(); const a = useAction();
574
740
  const initial = model.override || {};
@@ -581,6 +747,9 @@ window.__ModuleLoader__.load({
581
747
  inputMode: Array.isArray(initial.input) ? 'custom' : 'auto',
582
748
  input: [...(initial.input ?? automaticInput)],
583
749
  reasoningMode: initial.reasoning == null ? 'auto' : initial.reasoning ? 'on' : 'off',
750
+ levelsMode: 'custom',
751
+ // Custom rows: level id plus the wire value it sends ('' means "no value", i.e. off/none).
752
+ rows: rowsFromMap(initial.thinkingLevelMap, model.automaticReasoningLevels, model.automaticThinkingLevelMap),
584
753
  hidden: Boolean(initial.hidden),
585
754
  });
586
755
  const original = useRef(JSON.stringify(form)); const dirty = original.current !== JSON.stringify(form);
@@ -593,6 +762,13 @@ window.__ModuleLoader__.load({
593
762
  if (a.pending) return;
594
763
  for (const k of ['contextWindow', 'maxTokens']) if (form[k] && (!/^\d+$/.test(form[k]) || Number(form[k]) < 1 || !Number.isSafeInteger(Number(form[k])))) return setInvalid(t('positiveInteger'));
595
764
  if (form.upstreamModel.trim() && (/\s|[\u0000-\u001f\u007f-\u009f]/u.test(form.upstreamModel.trim()) || form.upstreamModel.trim().length > 500)) return setInvalid(t('invalidMapping'));
765
+ if (form.levelsMode === 'custom') {
766
+ const levels = form.rows.map((row) => row.level).filter(Boolean);
767
+ if (!levels.length) return setInvalid(t('levelsRequired'));
768
+ if (levels.some((level) => !LEVELS.includes(level) && !CUSTOM_LEVEL.test(level))) return setInvalid(t('levelInvalid'));
769
+ if (new Set(levels).size !== levels.length) return setInvalid(t('levelDuplicate'));
770
+ if (form.rows.some((row) => row.level && !LEVELS.includes(row.level) && !row.value.trim())) return setInvalid(t('levelNeedsValue'));
771
+ }
596
772
  const override = { ...initial };
597
773
  for (const k of ['name', 'upstreamModel', 'kind', 'protocol', 'contextWindow', 'maxTokens']) {
598
774
  const value = form[k].trim();
@@ -600,6 +776,12 @@ window.__ModuleLoader__.load({
600
776
  }
601
777
  override.input = form.inputMode === 'auto' ? null : form.input;
602
778
  override.reasoning = form.reasoningMode === 'auto' ? null : form.reasoningMode === 'on';
779
+ // A table that still matches what the model declares is not an override: keeping it
780
+ // null means the levels keep following pi-ai's data instead of freezing today's copy.
781
+ override.reasoningLevels = null;
782
+ const edited = rowsToMap(form.rows);
783
+ const detected = model.automaticThinkingLevelMap;
784
+ override.thinkingLevelMap = sameLevelMap(edited, detected) ? null : edited;
603
785
  override.hidden = form.hidden ? true : null;
604
786
  return a.run(async () => { await store.mutate('model/override', { revision, channelId: channel.id, modelId: model.id, override }); onSaved(false); });
605
787
  };
@@ -637,7 +819,15 @@ window.__ModuleLoader__.load({
637
819
  })),
638
820
  h('div', { className: 'nas-note', style: { marginTop: 8, marginBottom: 16 } }, t('inputHint')),
639
821
  h(Field, { label: t('reasoning'), htmlFor: `${id}-reasoning` }, h('select', { id: `${id}-reasoning`, value: form.reasoningMode, onChange: changed('reasoningMode'), disabled: a.pending },
640
- h('option', { value: 'auto' }, `${t('followAuto')} · ${t((model.automaticReasoning ?? model.reasoning) ? 'enableReasoning' : 'disableReasoning')}`), h('option', { value: 'on' }, t('enableReasoning')), h('option', { value: 'off' }, t('disableReasoning'))))),
822
+ h('option', { value: 'auto' }, `${t('followAuto')} · ${t((model.automaticReasoning ?? model.reasoning) ? 'enableReasoning' : 'disableReasoning')}`), h('option', { value: 'on' }, t('enableReasoning')), h('option', { value: 'off' }, t('disableReasoning')))),
823
+ (form.reasoningMode !== 'off') && h(ReasoningLevelsEditor, {
824
+ enabled: form.levelsMode === 'custom',
825
+ rows: form.rows,
826
+ detected: model.automaticReasoningLevels ?? [],
827
+ busy: a.pending,
828
+ onToggle: () => setForm({ ...form, levelsMode: form.levelsMode === 'custom' ? 'auto' : 'custom' }),
829
+ onChange: (rows) => setForm({ ...form, rows }),
830
+ })),
641
831
  h('details', { className: 'nas-editor-advanced' }, h('summary', null, t('advancedOptions')),
642
832
  h('div', { className: 'nas-two' },
643
833
  h(Field, { label: t('kind'), htmlFor: `${id}-kind` }, h('select', { id: `${id}-kind`, value: form.kind, onChange: changed('kind'), disabled: a.pending }, h('option', { value: '' }, t('auto')), ['chat', 'image', 'video', 'audio', 'embedding', 'other'].map((k) => h('option', { key: k, value: k }, t(k))))),
@@ -1032,6 +1222,7 @@ window.__ModuleLoader__.load({
1032
1222
  m.kind === 'chat' ? `${t('context')}: ${m.effectiveContextWindow} / ${t('maxOutput')}: ${m.effectiveMaxTokens}` : t(m.kind),
1033
1223
  `${t('source')}: ${m.source?.modelsDev?.provider || t('defaults')} · ${t(m.source?.modelsDev ? m.source.modelsDev.match === 'prefix' ? 'originPrefix' : 'originExact' : 'originNone')}`,
1034
1224
  m.source?.overridden?.length ? `${t('overridden')}: ${m.source.overridden.join(', ')}` : null,
1225
+ m.reasoning && m.reasoningLevels?.length ? `${t('reasoningLevels')}: ${m.reasoningLevels.map((l) => t('level_' + l)).join(' / ')}` : null,
1035
1226
  ].filter(Boolean).join('\n') },
1036
1227
  m.kind === 'chat' ? h('span', { className: 'nas-cap-numbers' }, tokens(m.effectiveContextWindow), h('span', { className: 'nas-cap-divider' }, ' / '), tokens(m.effectiveMaxTokens)) : h('span', null, t(m.kind)),
1037
1228
  h('span', { className: 'nas-cap-icons' },
@@ -1086,7 +1277,7 @@ window.__ModuleLoader__.load({
1086
1277
  const modelChannel = channels.find((c) => c.id === modelChannelId) || active;
1087
1278
  const count = (kind) => entries.filter((e) => e.kind === kind).length;
1088
1279
  const operate = (path, ch, key) => a.run(async () => { await store.mutate(path, { id: ch.id, revision: state.revision }); notify(key); });
1089
- return h('div', { className: 'nas nas-page', 'data-newapi-provider-version': '0.8.4' }, h(Styles), header,
1280
+ return h('div', { className: 'nas nas-page', 'data-newapi-provider-version': '0.9.4' }, h(Styles), header,
1090
1281
  notice && h('div', { className: 'nas-feedback', role: 'status' }, h(Icon, { name: 'check', size: 14 }), notice),
1091
1282
  h(ErrorText, { text: a.error || error }),
1092
1283
  h('div', { className: 'nas-summary' },
package/index.js CHANGED
@@ -45,6 +45,8 @@ const ModelOverride = z.object({
45
45
  input: z.array(z.union(MODALITIES)).default(undefined).description('Input modalities the model accepts.'),
46
46
  reasoning: z.boolean().description('Whether the model supports reasoning effort.'),
47
47
  reasoningLevels: z.array(z.union(LEVELS)).default(undefined).description('Reasoning effort levels to offer.'),
48
+ thinkingLevelMap: z.dict(z.union([z.string(), z.const(null)])).default(undefined)
49
+ .description('Per-level wire value: a string is sent upstream, null hides the level. Omitted levels fall back to the detected value.'),
48
50
  protocol: z.union(ENDPOINT_TYPES).description('Force the wire protocol for this model.'),
49
51
  custom: z.boolean().description('Add this model even if the gateway does not list it.'),
50
52
  });
@@ -461,9 +463,10 @@ export function apply(ctx, config) {
461
463
  fetchedAt: value.fetchedAt,
462
464
  pricingAvailable: value.pricingAvailable,
463
465
  pricingError: value.pricingError,
464
- entries: value.entries.map((e) => ({
466
+ entries: value.entries.map(({ thinkingLevelMap, ...e }) => ({ // the map is wire detail; the UI gets the same data under its own name
465
467
  ...e,
466
468
  // Own-property read: built-in names such as `toString` are legal model ids.
469
+ automaticThinkingLevelMap: thinkingLevelMap ?? null,
467
470
  override: structuredClone(Object.hasOwn(captured.models ?? {}, e.id) ? captured.models[e.id] : {}),
468
471
  automaticContextWindow: e.automaticContextWindow ?? conn.defaultContextWindow,
469
472
  automaticMaxTokens: e.automaticMaxTokens ?? conn.defaultMaxTokens,
@@ -612,6 +615,8 @@ export function apply(ctx, config) {
612
615
  input: e.input,
613
616
  output: e.outputModalities,
614
617
  reasoning: e.reasoning,
618
+ reasoningLevels: e.reasoningLevels ?? [],
619
+ reasoningLevelsSource: e.reasoningLevelsSource ?? 'default',
615
620
  endpointTypes: e.endpointTypes,
616
621
  vendor: e.vendor,
617
622
  pricing: e.pricing,
package/lib/adapter.js CHANGED
@@ -41,9 +41,14 @@ function baseUrlFor(baseURL, api) {
41
41
  return api === 'openai-completions' || api === 'openai-responses' ? openaiBase(baseURL) : gatewayRoot(baseURL);
42
42
  }
43
43
 
44
- /** Reasoning level map: explicit override levels, else "reasoning on" defaults. */
44
+ /**
45
+ * Reasoning level map. The catalog resolves it per model — the user's override,
46
+ * pi-ai's own data for that id, or a vendor-family default — so pi-ai hides the
47
+ * levels this model cannot express instead of offering the same five everywhere.
48
+ */
45
49
  function thinkingLevelMap(entry) {
46
50
  if (!entry.reasoning) return undefined;
51
+ if (entry.thinkingLevelMap) return entry.thinkingLevelMap;
47
52
  if (!entry.reasoningLevels?.length) return undefined;
48
53
  const allowed = new Set(entry.reasoningLevels);
49
54
  return Object.fromEntries(LEVELS.map((l) => [l, allowed.has(l) ? l : null]));
@@ -163,9 +168,15 @@ export class NewApiAdapter extends LlmAdapter {
163
168
  async modelInfo(bound, provider, model, signal) {
164
169
  const info = await bound.inner.resolveModel(provider, model, signal);
165
170
  const entry = bound.catalog.entries.find((e) => e.id === model);
166
- if (entry?.reasoningLevels?.length) info.reasoning = {
167
- efforts: entry.reasoningLevels.map((l) => ({ id: ReasoningEffortId(l), name: l.charAt(0).toUpperCase() + l.slice(1) })),
168
- };
171
+ // Report exactly the levels the request path can express: DSH rejects an effort
172
+ // it did not advertise, and clamps nothing, so these two must agree.
173
+ if (entry?.reasoning && entry.reasoningLevels?.length) {
174
+ const names = { off: 'Off', minimal: 'Minimal', low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Extra high', max: 'Max' };
175
+ info.reasoning = {
176
+ efforts: entry.reasoningLevels.map((l) => ({ id: ReasoningEffortId(l), name: names[l] ?? l })),
177
+ ...(entry.reasoningLevels.includes('medium') ? { defaultEffort: ReasoningEffortId('medium') } : {}),
178
+ };
179
+ }
169
180
  if (entry?.maxTokens) info.defaultMaxTokens = entry.maxTokens;
170
181
  return info;
171
182
  }
package/lib/catalog.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * other) so chat models go to the picker and media models go to the tools.
12
12
  */
13
13
  import { fetchJson, gatewayRoot } from './util.js';
14
+ import { LevelCatalog, resolveReasoningLevels } from './levels.js';
14
15
 
15
16
  export const DEFAULT_CONTEXT_WINDOW = 128_000;
16
17
  export const DEFAULT_MAX_TOKENS = 32_768;
@@ -44,9 +45,10 @@ function prettyName(id) {
44
45
  }
45
46
 
46
47
  export class Catalog {
47
- constructor({ modelsDev, logger }) {
48
+ constructor({ modelsDev, logger, levels }) {
48
49
  this.modelsDev = modelsDev;
49
50
  this.logger = logger;
51
+ this.levels = levels ?? new LevelCatalog();
50
52
  this.cache = new Map();
51
53
  this.epoch = 0;
52
54
  }
@@ -81,11 +83,32 @@ export class Catalog {
81
83
  if (!force && hit && Date.now() - hit.at < CATALOG_TTL_MS && hit.overridesRev === conn.overridesRev) return hit.value;
82
84
  void this.modelsDev.refresh();
83
85
  const raw = await this.fetchGateway(conn, apiKey, signal);
84
- const value = this.build(conn, raw);
86
+ const value = this.build(conn, raw, await this.resolveLevels(conn, raw));
85
87
  if (epoch === this.epoch) this.cache.set(key, { at: Date.now(), value, overridesRev: conn.overridesRev });
86
88
  return value;
87
89
  }
88
90
 
91
+ /**
92
+ * Reasoning levels for every id the catalog will contain, resolved before the
93
+ * synchronous build so each entry can carry its exact selector.
94
+ */
95
+ async resolveLevels(conn, { models }) {
96
+ const overrides = conn.overrides ?? {};
97
+ const ids = new Set((models?.data ?? []).map((m) => m?.id).filter(Boolean));
98
+ for (const [id, ov] of Object.entries(overrides)) if (ov?.custom === true) ids.add(id);
99
+ const out = new Map();
100
+ await Promise.all([...ids].map(async (id) => {
101
+ const own = Object.hasOwn(overrides, id) ? overrides[id] : undefined;
102
+ const override = Array.isArray(own?.reasoningLevels) && own.reasoningLevels.length ? own.reasoningLevels : undefined;
103
+ const overrideMap = own?.thinkingLevelMap && Object.keys(own.thinkingLevelMap).length ? own.thinkingLevelMap : undefined;
104
+ const hasOverride = Boolean(override || overrideMap);
105
+ const effective = await resolveReasoningLevels({ id, override, overrideMap, catalog: this.levels });
106
+ const automatic = hasOverride ? await resolveReasoningLevels({ id, catalog: this.levels }) : effective;
107
+ out.set(id, { effective, automatic });
108
+ }));
109
+ return out;
110
+ }
111
+
89
112
  /** Last good catalog for a connection without network, if any. */
90
113
  key(conn) {
91
114
  return JSON.stringify([conn.channelId, gatewayRoot(conn.baseURL), conn.keyFingerprint, conn.overridesRev, conn.headers, conn.enabledModels ?? null]);
@@ -95,7 +118,7 @@ export class Catalog {
95
118
  return this.cache.get(this.key(conn))?.value;
96
119
  }
97
120
 
98
- build(conn, { models, pricing, pricingError }) {
121
+ build(conn, { models, pricing, pricingError }, levelInfo = new Map()) {
99
122
  const priced = new Map();
100
123
  for (const p of pricing?.data ?? []) priced.set(p.model_name, p);
101
124
  const vendors = new Map((pricing?.vendors ?? []).map((v) => [v.id, v.name]));
@@ -146,7 +169,11 @@ export class Catalog {
146
169
  automaticReasoning: dev?.reasoning ?? false,
147
170
  outputModalities: dev?.outputModalities ?? [],
148
171
  reasoning: ov.reasoning ?? dev?.reasoning ?? false,
149
- reasoningLevels: ov.reasoningLevels,
172
+ // Only a reasoning model has a selector; image/embedding models show none.
173
+ reasoningLevels: (ov.reasoning ?? dev?.reasoning ?? false) ? levelInfo.get(id)?.effective.levels : [],
174
+ automaticReasoningLevels: (ov.reasoning ?? dev?.reasoning ?? false) ? levelInfo.get(id)?.automatic.levels : [],
175
+ reasoningLevelsSource: levelInfo.get(id)?.effective.source ?? 'default',
176
+ thinkingLevelMap: levelInfo.get(id)?.effective.map,
150
177
  source: {
151
178
  gateway: Boolean(localGatewayEntry),
152
179
  upstreamGateway: gateway.has(upstreamModel),
package/lib/channels.js CHANGED
@@ -45,12 +45,26 @@ export function resolveChannels(raw = {}) {
45
45
  }
46
46
 
47
47
  /** Merge only supplied fields; null removes one override, reset removes the whole override. */
48
+ /** A level map may only key known levels, and each value is a wire string or null. */
49
+ function isValidLevelMap(value) {
50
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
51
+ const entries = Object.entries(value);
52
+ if (!entries.length) return false;
53
+ return entries.every(([level, mapped]) => {
54
+ const known = LEVELS.includes(level);
55
+ // Custom names must be a safe id and must carry a value: that value is the whole point.
56
+ if (!known && !/^[a-z0-9][a-z0-9_-]{0,23}$/.test(level)) return false;
57
+ if (mapped === null) return known;
58
+ return typeof mapped === 'string' && mapped.length > 0 && mapped.length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/.test(mapped);
59
+ });
60
+ }
61
+
48
62
  export function mergeModelOverride(previous, patch, reset = false) {
49
63
  if (reset) return undefined;
50
64
  if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw channelError(400, 'override must be an object');
51
65
  const next = { ...previous };
52
66
  for (const [key, value] of Object.entries(patch)) {
53
- if (!['name', 'upstreamModel', 'kind', 'hidden', 'contextWindow', 'maxTokens', 'input', 'reasoning', 'reasoningLevels', 'protocol', 'custom'].includes(key)) {
67
+ if (!['name', 'upstreamModel', 'kind', 'hidden', 'contextWindow', 'maxTokens', 'input', 'reasoning', 'reasoningLevels', 'thinkingLevelMap', 'protocol', 'custom'].includes(key)) {
54
68
  if (Object.hasOwn(previous ?? {}, key) && JSON.stringify(value) === JSON.stringify(previous[key])) continue;
55
69
  throw channelError(400, `unsupported override field: ${key}`);
56
70
  }
@@ -62,6 +76,7 @@ export function mergeModelOverride(previous, patch, reset = false) {
62
76
  else if (key === 'upstreamModel') valid = typeof value === 'string' && value.trim().length > 0 && value.length <= 500 && !/[\u0000-\u001f\u007f-\u009f]/.test(value);
63
77
  else if (key === 'kind') valid = KINDS.includes(value);
64
78
  else if (key === 'protocol') valid = PROTOCOLS.includes(value);
79
+ else if (key === 'thinkingLevelMap') valid = isValidLevelMap(value);
65
80
  else valid = Array.isArray(value) && value.every((v) => (key === 'input' ? MODALITIES : LEVELS).includes(v));
66
81
  if (!valid) throw channelError(400, `invalid override field: ${key}`);
67
82
  next[key] = ['name', 'upstreamModel'].includes(key) ? value.trim() : value;
package/lib/levels.js ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Reasoning ("thinking") levels per model.
3
+ *
4
+ * pi-ai renders a level selector from `thinkingLevelMap`: a `null` value hides that
5
+ * level, a string maps it to the vendor's own vocabulary, and an absent key falls
6
+ * back to the provider default. Without a map every reasoning model advertises the
7
+ * same five levels (`off`, `minimal`, `low`, `medium`, `high`) — which is wrong for
8
+ * vendors that cannot turn thinking off, have no effort parameter at all, or support
9
+ * `xhigh`/`max`.
10
+ *
11
+ * Resolution order, highest precedence first:
12
+ * 1. the user's per-model override (`reasoningLevels` in the channel config),
13
+ * 2. pi-ai's own shipped catalog for that model id — the exact map the built-in
14
+ * providers use, so a model here behaves like the same model there,
15
+ * 3. a vendor-family default (below) when the id is a relay alias the catalog
16
+ * does not know,
17
+ * 4. the pi-ai default (no map at all) for anything unrecognised.
18
+ */
19
+
20
+ /** pi-ai's built-in level vocabulary, in display order. Custom names may be added on top. */
21
+ export const LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
22
+
23
+ /** A level id: the built-ins, or a user-chosen name the upstream understands. */
24
+ export const CUSTOM_LEVEL = /^[a-z0-9][a-z0-9_-]{0,23}$/;
25
+
26
+ /** Preset levels first (pi's order), then custom names alphabetically. */
27
+ export function orderLevels(names) {
28
+ const preset = LEVELS.filter((level) => names.includes(level));
29
+ const custom = names.filter((name) => !LEVELS.includes(name)).sort();
30
+ return [...preset, ...custom];
31
+ }
32
+
33
+ /** Provider catalogs to consult. Kept small: only vendors a New API relay resells. */
34
+ const CATALOG_PROVIDERS = ['openai', 'anthropic', 'google', 'deepseek', 'zai', 'moonshotai', 'xai', 'minimax', 'alibaba'];
35
+
36
+ /** First-party provider per model family, used to prefer the right catalog and default. */
37
+ const FAMILY_RULES = [
38
+ [/^claude|^fable/, 'anthropic'],
39
+ [/^(gpt|o\d|codex|chatgpt|dall-e)/, 'openai'],
40
+ [/^gemini|^gemma/, 'google'],
41
+ [/^deepseek/, 'deepseek'],
42
+ [/^glm|^cogview|^cogvideo/, 'zai'],
43
+ [/^(kimi|moonshot)/, 'moonshotai'],
44
+ [/^grok/, 'xai'],
45
+ [/^(minimax|abab|hailuo)/, 'minimax'],
46
+ [/^(qwen|qwq|wan)/, 'alibaba'],
47
+ ];
48
+
49
+ /**
50
+ * Family defaults for ids the catalog does not know (relay aliases such as
51
+ * `claude-opus-5-5` or `deepseek-v4.1-flash`). Each entry mirrors the shape pi-ai
52
+ * ships for that family; `null` hides a level, a string is sent upstream.
53
+ */
54
+ const FAMILY_DEFAULTS = [
55
+ // Adaptive thinking: effort levels, no `minimal`, `max` available on every adaptive model.
56
+ [/^claude-(opus-([456]-?\d*|5)|sonnet-([456]|4-\d+|[56]-\d+)|fable|mythos)/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
57
+ // Budget-based thinking: every level maps to a token budget, so all five apply.
58
+ // Older/unknown Claude: budget-based thinking covers every level (pi-ai ships no map).
59
+ [/^claude/, null],
60
+ // Pro variants expose a narrower set (gpt-5-pro: high only; later pros: medium+).
61
+ [/^gpt-5-pro/, { off: null, minimal: null, low: null, medium: null, high: 'high', xhigh: null, max: null }],
62
+ [/^gpt-5[.-][245]-pro/, { off: null, minimal: null, low: null, medium: 'medium', high: 'high', xhigh: 'xhigh', max: null }],
63
+ [/^gpt-6-astra/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
64
+ [/^gpt-6[.-]1/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
65
+ // GPT-5.1+ and GPT-6: "none" turns reasoning off; minimal is gone; xhigh/max exist.
66
+ [/^gpt-(5[.-][1-9]|5[.-]\d\d|6)/, { off: 'none', minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
67
+ // GPT-5.0: minimal exists, no xhigh/max.
68
+ [/^gpt-5/, { off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
69
+ // o-series: low/medium/high only.
70
+ [/^o\d/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
71
+ // Gemini 3: two-step thinking level, cannot be switched off.
72
+ [/^gemini-[34]/, { off: null, minimal: 'MINIMAL', low: 'LOW', medium: 'MEDIUM', high: 'HIGH', xhigh: null, max: null }],
73
+ // Gemini 2.5: thinkingBudget; 0 disables it on flash models.
74
+ // Gemini 2.5 and older: budget-based, no level vocabulary (pi-ai ships no map).
75
+ [/^gemini/, null],
76
+ // DeepSeek V4 exposes effort with a gap at `medium`.
77
+ [/^deepseek/, { minimal: null, medium: null, low: 'low', high: 'high', max: 'max', xhigh: null }],
78
+ [/^glm-5[.-][3-9]/, { off: null, minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
79
+ [/^glm-5[.-]2/, { off: 'none', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }],
80
+ [/^glm-5/, { off: 'none', minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
81
+ [/^glm/, null],
82
+ [/^(kimi-k3|moonshot-k3)/, { off: null, minimal: null, low: 'low', medium: null, high: 'high', xhigh: null, max: 'max' }],
83
+ [/^(kimi|moonshot)/, null],
84
+ [/^grok-4\.[5-9]/, { off: null, minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
85
+ [/^grok/, { off: 'none', minimal: null, low: 'low', medium: 'medium', high: 'high', xhigh: null, max: null }],
86
+ ];
87
+
88
+ /** Normalise an id for catalog comparison: case, dots/underscores, vendor prefixes. */
89
+ function normalize(id) {
90
+ return String(id).toLowerCase().split('/').pop().replace(/[._]/g, '-');
91
+ }
92
+
93
+ /** Index one provider catalog by its normalised ids. */
94
+ function indexCatalog(catalog) {
95
+ const index = new Map();
96
+ for (const model of Object.values(catalog ?? {})) {
97
+ if (!model?.id) continue;
98
+ const key = normalize(model.id);
99
+ if (!index.has(key)) index.set(key, model);
100
+ }
101
+ return index;
102
+ }
103
+
104
+ /**
105
+ * Lazily loads and indexes pi-ai's shipped catalogs. Import failures are not fatal:
106
+ * the family defaults still give a model the right level set.
107
+ */
108
+ export class LevelCatalog {
109
+ constructor({ providers = CATALOG_PROVIDERS } = {}) {
110
+ this.providers = providers;
111
+ this.index = undefined;
112
+ this.loading = undefined;
113
+ this.byFamily = new Map();
114
+ }
115
+
116
+ async load() {
117
+ if (this.index) return this.index;
118
+ this.loading ??= (async () => {
119
+ const index = new Map();
120
+ for (const name of this.providers) {
121
+ try {
122
+ const mod = await import(`@earendil-works/pi-ai/providers/${name}.models`);
123
+ for (const value of Object.values(mod)) {
124
+ const models = indexCatalog(value);
125
+ for (const [key, model] of models) if (!index.has(key)) index.set(key, model);
126
+ }
127
+ } catch { /* that provider catalog is absent in this pi-ai build */ }
128
+ }
129
+ this.index = index;
130
+ return index;
131
+ })();
132
+ return this.loading;
133
+ }
134
+
135
+ familyOf(id) {
136
+ const key = normalize(id);
137
+ for (const [re, provider] of FAMILY_RULES) if (re.test(key)) return provider;
138
+ return undefined;
139
+ }
140
+
141
+ /**
142
+ * pi-ai's own entry for this id: exact, then progressively shorter prefixes so a
143
+ * relay variant (`claude-opus-5-5`, `claude-sonnet-5-5-high`) lands on its base model.
144
+ */
145
+ async find(id) {
146
+ const index = await this.load();
147
+ const key = normalize(id);
148
+ const exact = index.get(key);
149
+ if (exact) return { model: exact, match: 'exact' };
150
+ const parts = key.split('-');
151
+ for (let n = parts.length - 1; n >= 2; n--) {
152
+ const hit = index.get(parts.slice(0, n).join('-'));
153
+ if (hit) return { model: hit, match: 'prefix' };
154
+ }
155
+ return undefined;
156
+ }
157
+
158
+ /** Family default map, or `undefined` when the family keeps the pi-ai default. */
159
+ familyDefault(id) {
160
+ const key = normalize(id);
161
+ if (this.byFamily.has(key)) return this.byFamily.get(key);
162
+ let map;
163
+ for (const [re, value] of FAMILY_DEFAULTS) if (re.test(key)) { map = value; break; }
164
+ this.byFamily.set(key, map);
165
+ return map;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Levels a map exposes, in pi-ai display order. `off`/`minimal`/`low`/`medium`/`high`
171
+ * are shown unless explicitly `null`; `xhigh`/`max` only when mapped.
172
+ */
173
+ export function levelsFromMap(map) {
174
+ if (!map) return ['off', 'minimal', 'low', 'medium', 'high'];
175
+ const names = LEVELS.filter((level) => {
176
+ const mapped = map[level];
177
+ if (mapped === null) return false;
178
+ if (level === 'xhigh' || level === 'max') return mapped !== undefined;
179
+ return true;
180
+ });
181
+ // A custom level exists only when it is mapped to something the upstream accepts.
182
+ for (const [level, mapped] of Object.entries(map)) {
183
+ if (LEVELS.includes(level)) continue;
184
+ if (typeof mapped === 'string' && CUSTOM_LEVEL.test(level)) names.push(level);
185
+ }
186
+ return orderLevels(names);
187
+ }
188
+
189
+ /** Build a pi-ai map that exposes exactly `levels`, keeping `base`'s upstream strings. */
190
+ export function mapFromLevels(levels, base) {
191
+ if (!Array.isArray(levels) || levels.length === 0) return undefined;
192
+ const allowed = new Set(levels);
193
+ const map = {};
194
+ for (const level of LEVELS) {
195
+ if (!allowed.has(level)) { map[level] = null; continue; }
196
+ const mapped = base?.[level];
197
+ map[level] = typeof mapped === 'string' ? mapped : level;
198
+ }
199
+ return map;
200
+ }
201
+
202
+ /**
203
+ * Resolve one model's reasoning levels.
204
+ *
205
+ * @returns `{ levels, map, source }` where `source` is `override` (user),
206
+ * `catalog` (pi-ai shipped data), `family` (vendor default) or `default`.
207
+ */
208
+ export async function resolveReasoningLevels({ id, override, overrideMap, catalog }) {
209
+ const found = await catalog.find(id);
210
+ const base = found?.model?.thinkingLevelMap ?? catalog.familyDefault(id);
211
+ const source = found?.model?.thinkingLevelMap
212
+ ? 'catalog'
213
+ : base ? 'family' : 'default';
214
+ // An explicit level→wire map is authoritative: it is literally what the user configured.
215
+ if (overrideMap && typeof overrideMap === 'object' && Object.keys(overrideMap).length) {
216
+ // The table is authoritative: preset levels not listed are hidden, and custom names
217
+ // are honoured as long as they carry a wire value.
218
+ const map = {};
219
+ for (const level of LEVELS) {
220
+ const mapped = overrideMap[level];
221
+ map[level] = typeof mapped === 'string' ? mapped : null;
222
+ }
223
+ for (const [level, mapped] of Object.entries(overrideMap)) {
224
+ if (LEVELS.includes(level) || !CUSTOM_LEVEL.test(level)) continue;
225
+ if (typeof mapped === 'string') map[level] = mapped;
226
+ }
227
+ const levels = levelsFromMap(map);
228
+ // A table that exposes nothing usable is a mistake, not an empty selector: fall back.
229
+ if (levels.length) return { levels, map, source: 'override' };
230
+ }
231
+ if (Array.isArray(override) && override.length) {
232
+ return { levels: override.filter((l) => LEVELS.includes(l)), map: mapFromLevels(override, base), source: 'override' };
233
+ }
234
+ return { levels: levelsFromMap(base), map: base ?? undefined, source };
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-newapi-provider",
3
- "version": "0.8.4",
3
+ "version": "0.9.4",
4
4
  "description": "New API gateway for DeepSeek Harness: models with context and modality details, balance, image and video generation.",
5
5
  "license": "MIT",
6
6
  "author": "zenVibeCoding",