@hncode/hncode 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
package/src/config.js CHANGED
@@ -27,7 +27,10 @@ export const DEFAULTS = {
27
27
  protocol: 'openai',
28
28
  // Fallback context window when neither config nor /v1/models provides one.
29
29
  maxContextTokens: 512000,
30
- maxOutputTokens: 4096,
30
+ // Fallback output-token limit when neither config nor the model itself provides
31
+ // one. This is the per-response cap, not the context window — most models allow
32
+ // well above the old 4096, but it must stay within what the API accepts.
33
+ maxOutputTokens: 10000,
31
34
  reasoning: false,
32
35
  workspace: process.cwd(),
33
36
  allowExternal: false,
@@ -336,7 +339,9 @@ const b = baseUrl.replace(/\/+$/, '');
336
339
  // An empty base URL produces a relative endpoint like "/chat/completions",
337
340
  // which fetch() rejects with a URL-scheme error the model can't decode. Leave
338
341
  // the endpoint empty instead so the caller can show "not set".
339
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
342
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
343
+ : protocol === 'responses' ? `${b}/responses`
344
+ : `${b}/chat/completions`);
340
345
 
341
346
  // Context window: an explicit env/root override wins, then the model's own
342
347
  // `contextLength` (discovered from the provider's /v1/models), then the
@@ -344,7 +349,20 @@ const b = baseUrl.replace(/\/+$/, '');
344
349
  const overrideContext = process.env.HNCODE_MAX_CONTEXT || root.max_context_size || root.max_context_tokens;
345
350
  const modelContext = Number(modelEntry.contextLength || modelEntry.context_length || modelEntry.context_window || modelEntry.max_context_size || 0);
346
351
  const maxContext = Number(overrideContext || modelContext || DEFAULTS.maxContextTokens);
347
- const maxOutput = Number(process.env.HNCODE_MAX_OUTPUT || root.max_output_size || DEFAULTS.maxOutputTokens);
352
+ // Output limit, resolved like the context window: an explicit override wins, then
353
+ // the model's OWN declared limit (from /v1/models), then the default. Ignoring the
354
+ // model's value pinned every model to the default even when it supported far more.
355
+ const modelMaxOutput = Number(
356
+ modelEntry.maxOutputTokens
357
+ || modelEntry.max_output_tokens // models.dev `limit.output`, persisted on the entry
358
+ || modelEntry.max_tokens
359
+ || 0,
360
+ );
361
+ const maxOutput = Number(
362
+ process.env.HNCODE_MAX_OUTPUT
363
+ || root.max_output_size
364
+ || (modelMaxOutput > 0 ? modelMaxOutput : DEFAULTS.maxOutputTokens),
365
+ );
348
366
  const reasoning = process.env.HNCODE_REASONING ? /^(1|true|yes)$/i.test(process.env.HNCODE_REASONING) : !!root.reasoning;
349
367
  const allowExternal = process.env.HNCODE_ALLOW_EXTERNAL === '1' || !!root.tool_allow_external_paths;
350
368
 
@@ -387,13 +405,31 @@ const b = baseUrl.replace(/\/+$/, '');
387
405
  // Backwards-compat: read legacy cool_mode key if calm_mode is not set.
388
406
  : (root.calm_mode === true || root.calm_mode === 'true'
389
407
  || root.cool_mode === true || root.cool_mode === 'true'),
390
- // SUBAGENT MODEL (/swarm-sub-agent): the model each subagent runs on. Empty
391
- // (the default) means "follow this session's model", which is what the Agent
392
- // and AgentSwarm tools fall back to. Set from config.toml so it persists.
393
408
  subagentModel: process.env.HNCODE_SUBAGENT_MODEL || root.subagent_model || '',
394
- // Plugin directory (default: ~/.hncode/plugins). Set to "" or "false" to
395
- // disable plugin loading entirely.
396
- pluginDir: process.env.HNCODE_PLUGINS || root.plugins_dir || '',
409
+ // SECONDARY MODEL (/secondary-model): the cheap model for side work — /btw,
410
+ // /recap and the compaction summary run on it, so a long main-context model is
411
+ // not billed for a question that needed none of that context. Empty means
412
+ // "use the session's model", which is the safe default: a model choice the user
413
+ // never made should not be invented from a model list.
414
+ secondaryModel: process.env.HNCODE_SECONDARY_MODEL || root.secondary_model || '',
415
+ // OUTPUT STYLE (/output-style): the NAME of a style file under
416
+ // ~/.hncode/output-styles/. Stored as a name, not as the text, so editing the
417
+ // file changes the style without touching config.toml.
418
+ outputStyle: process.env.HNCODE_OUTPUT_STYLE || root.output_style || '',
419
+ // FEATURE FLAGS (/experiments): a table of name -> boolean. Read by
420
+ // experiments.js, which owns the registry and the defaults.
421
+ experiments: (root.experiments && typeof root.experiments === 'object') ? root.experiments : {},
422
+ // SCHEDULED PROMPTS (/schedule): cron-like entries that queue a prompt on a
423
+ // running session. Stored as TOML inline tables under [schedule.<id>].
424
+ schedule: (root.schedule && typeof root.schedule === 'object') ? root.schedule : {},
425
+ // Plugin directory (default: ~/.hncode/plugins). Loaded on every launch so
426
+ // plugins installed via /plugins actually take effect. Set to "" or "false"
427
+ // (config or env) to disable plugin loading entirely; unset means the default.
428
+ pluginDir: (() => {
429
+ const v = process.env.HNCODE_PLUGINS || root.plugins_dir;
430
+ if (v === 'false' || v === false || v === '' || v === null || v === undefined) return v === 'false' || v === false || v === '' ? '' : path.join(homeDir(), '.hncode', 'plugins');
431
+ return v;
432
+ })(),
397
433
  // AUTO-UPDATE (/auto-update): when true, hncode checks npm for a newer
398
434
  // version at startup and then every 30 minutes, installing in the background
399
435
  // without interrupting the session.
@@ -444,10 +480,25 @@ const b = baseUrl.replace(/\/+$/, '');
444
480
  promptCache: process.env.HNCODE_PROMPT_CACHE
445
481
  ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_PROMPT_CACHE)
446
482
  : (root.prompt_cache === false || root.prompt_cache === 'false' ? false : true),
483
+ // REDUCED MOTION (/reduced-motion, config.toml or the environment). When on,
484
+ // every animation is replaced by a static or slow-breathing form: the spinner
485
+ // does not rotate, the sweeps do not run, and the pulse does not cycle. For a
486
+ // user who finds the motion distracting or nauseating, and for a terminal that
487
+ // repaints badly, this is the difference between usable and not. Default OFF —
488
+ // motion is the intended experience, so this is an opt-out.
489
+ reducedMotion: process.env.HNCODE_REDUCED_MOTION
490
+ ? /^(1|true|yes|on)$/i.test(process.env.HNCODE_REDUCED_MOTION)
491
+ : (root.reduced_motion === true || root.reduced_motion === 'true'),
492
+ // SHIMMER EDGE (`shimmer_edge`): `cosine` gives the sweep a soft LEADING edge
493
+ // like codex's, `linear` keeps the original hard-on/soft-off ramp. Exposed
494
+ // because the two are a matter of taste and the cost is one branch.
495
+ shimmerEdge: String(root.shimmer_edge || process.env.HNCODE_SHIMMER_EDGE || 'cosine').toLowerCase() === 'linear'
496
+ ? 'linear' : 'cosine',
447
497
  raw: root,
448
498
  };
449
499
  }
450
500
 
501
+
451
502
  export function saveConfig(overlay) {
452
503
  const dir = hncodeConfigFile();
453
504
  const entries = Object.entries(overlay || {});
@@ -617,7 +668,7 @@ function renderTomlString(key, value) {
617
668
 
618
669
  // Append (or replace) a [providers.<name>] table in config.toml without
619
670
  // clobbering unrelated content. Used by /provider's "Add provider…" flow.
620
- export function addProvider(name, { base_url, api_key, protocol } = {}) {
671
+ export function addProvider(name, { base_url, api_key, protocol, known, catalog } = {}) {
621
672
  const file = hncodeConfigFile();
622
673
  let text = '';
623
674
  try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
@@ -646,6 +697,14 @@ export function addProvider(name, { base_url, api_key, protocol } = {}) {
646
697
  if (base_url) out.push(`base_url = ${q(base_url)}`);
647
698
  if (api_key) out.push(`api_key = ${q(api_key)}`);
648
699
  if (protocol) out.push(`protocol = ${q(protocol)}`);
700
+ // `known` records the SOURCE: true = added from the models.dev catalog, false =
701
+ // a plain endpoint the user typed in. It is written even when false, so the two
702
+ // cases are distinguishable instead of relying on a missing key.
703
+ if (known !== undefined) out.push(`known = ${known ? 'true' : 'false'}`);
704
+ // `catalog` records WHICH models.dev entry this provider maps to, so a renamed
705
+ // provider (e.g. "anthropic-work") still resolves the real catalog id. Only
706
+ // meaningful when `known` is true.
707
+ if (catalog) out.push(`catalog = ${q(catalog)}`);
649
708
  fs.mkdirSync(path.dirname(file), { recursive: true });
650
709
  fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
651
710
  return file;
@@ -718,13 +777,246 @@ export function addModel(provider, modelId, opts = {}) {
718
777
  // size the context bar per model (not a global 128k).
719
778
  if (opts.contextLength) lines.push(`context_length = ${Number(opts.contextLength)}`);
720
779
  if (opts.maxTokens) lines.push(`max_tokens = ${Number(opts.maxTokens)}`);
780
+ // Per-model output limit (from models.dev `limit.output`, or a provider's
781
+ // `max_output_tokens`). Its own key so it is not confused with `max_tokens`,
782
+ // which some endpoints use for the same thing and others do not.
783
+ if (opts.maxOutputTokens) lines.push(`max_output_tokens = ${Number(opts.maxOutputTokens)}`);
784
+ if (opts.reasoning !== undefined && opts.reasoning !== null) lines.push(`reasoning = ${opts.reasoning ? 'true' : 'false'}`);
785
+ // Thinking levels, from models.dev `reasoning_options` (or a user's own list).
786
+ if (Array.isArray(opts.efforts) && opts.efforts.length) {
787
+ lines.push(`efforts = [ ${opts.efforts.map((e) => `"${String(e).replace(/"/g, '\\"')}"`).join(', ')} ]`);
788
+ }
789
+ lines.push(...costLines(opts.cost));
721
790
  fs.mkdirSync(path.dirname(file), { recursive: true });
722
791
  fs.appendFileSync(file, '\n' + lines.join('\n') + '\n', 'utf8');
723
792
  return file;
724
793
  }
725
794
 
795
+ // ---- pricing ----------------------------------------------------------------
796
+ // Four flat keys rather than a nested table, because the TOML writer here is
797
+ // hand-rolled and a nested `[models."k".cost]` table would have to be tracked
798
+ // through three separate writers. Prices are USD per 1M tokens, which is the unit
799
+ // models.dev publishes and the unit the cost readout divides by.
800
+ const COST_KEYS = {
801
+ input: 'cost_input',
802
+ output: 'cost_output',
803
+ cacheRead: 'cost_cache_read',
804
+ cacheWrite: 'cost_cache_write',
805
+ };
806
+
807
+ /** `{ input, output, cacheRead, cacheWrite }` USD-per-1M -> TOML lines. */
808
+ function costLines(cost) {
809
+ if (!cost || typeof cost !== 'object') return [];
810
+ const out = [];
811
+ for (const [field, key] of Object.entries(COST_KEYS)) {
812
+ const n = Number(cost[field]);
813
+ if (Number.isFinite(n) && n > 0) out.push(`${key} = ${n}`);
814
+ }
815
+ return out;
816
+ }
817
+
818
+ /**
819
+ * Convert a models.dev catalog `cost` object into our shape, USD per 1M tokens.
820
+ *
821
+ * ZERO IS A PRICE. The catalog marks a free model as `{input: 0, output: 0}` — 637
822
+ * entries do — and there is no `free` flag to look for. Treating 0 as "missing" (the
823
+ * `n > 0` test) turned every free model into "no pricing", and the readout then fell
824
+ * through to a guess. So the test is `>= 0` and a zero is carried through as a real
825
+ * price: `usageCost` multiplies out to exactly 0 and the readout shows `$0.00`, which
826
+ * is true for a free service.
827
+ */
828
+ export function costFromCatalog(cost) {
829
+ if (!cost || typeof cost !== 'object') return undefined;
830
+ const pick = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : undefined; };
831
+ const out = {
832
+ input: pick(cost.input),
833
+ output: pick(cost.output ?? cost.reasoning),
834
+ cacheRead: pick(cost.cache_read ?? cost.cacheRead),
835
+ cacheWrite: pick(cost.cache_write ?? cost.cacheWrite),
836
+ };
837
+ return Object.values(out).some((v) => v !== undefined) ? out : undefined;
838
+ }
839
+
840
+ /**
841
+ * A stored model entry's pricing, or null when the model has none.
842
+ *
843
+ * Falls back to the ALREADY-FETCHED models.dev catalog. Every model entry written
844
+ * before pricing existed has no cost keys, and re-adding each provider to backfill
845
+ * them is a migration the user should not have to perform — the catalog that
846
+ * produced the entry in the first place already knows the price. The fallback is
847
+ * free (a Map lookup into a memoized object) and silent: when the catalog has not
848
+ * been fetched this session there is simply no fallback, and the readout says the
849
+ * price is unknown rather than guessing.
850
+ */
851
+ export function modelCost(cfg, modelKeyName) {
852
+ const models = (cfg && cfg.raw && cfg.raw.models) || {};
853
+ const key = modelKeyName || (cfg && cfg.model);
854
+ const entry = models[key] || {};
855
+ // `>= 0`, like costFromCatalog: a model the user priced at ZERO is priced, and must
856
+ // `>= 0`, like costFromCatalog: a model the user priced at ZERO is priced, and must
857
+ // not fall through to the catalog (which would charge it someone else's rate).
858
+ const pick = (v) => { const n = Number(v); return Number.isFinite(n) && n >= 0 ? n : undefined; };
859
+ const stored = {
860
+ input: pick(entry.cost_input ?? entry.costInput),
861
+ output: pick(entry.cost_output ?? entry.costOutput),
862
+ cacheRead: pick(entry.cost_cache_read ?? entry.costCacheRead),
863
+ cacheWrite: pick(entry.cost_cache_write ?? entry.costCacheWrite),
864
+ };
865
+ if (Object.values(stored).some((v) => v !== undefined)) return stored;
866
+ return catalogCostFor(cfg, entry, key);
867
+ }
868
+
869
+ // ---- catalog price lookup ----------------------------------------------------
870
+ // A model entry with no price keys of its own: look it up in models.dev by its EXACT
871
+ // provider bucket and model id. That is the whole rule.
872
+ //
873
+ // WHY THERE IS NO GUESSING BEYOND THAT. An unmatched model belongs to a provider
874
+ // models.dev does not list — a local proxy, a company endpoint, someone's free
875
+ // gateway — and there is NO WAY to know what it costs. Earlier versions tried to infer
876
+ // a price from the model id (a `deepseek-` prefix looked like DeepSeek's vendor entry;
877
+ // or the same bare name appearing under other providers, taking the most common rate).
878
+ // That produced a CONFIDENT, WRONG number rather than an honest gap: a FREE local
879
+ // proxy serving `workbuddy/deepseek-v4.1-flash` was billed DeepSeek's official rate,
880
+ // and the status line showed $2.57 for a service that charges nothing.
881
+ //
882
+ // An invented price is worse than a missing one. The readout omits the cost segment
883
+ // entirely when there is no price, which is visibly "unknown"; a wrong number is not
884
+ // detectable at all. A provider that resells under a different name can still be
885
+ // priced, but only by SAYING SO: `catalog = "<models.dev id>"` on the provider.
886
+ //
887
+ // The second lookup is the provider's own id inside the bucket: some endpoints report
888
+ // a model as `deepseek-ai/deepseek-flash` where the catalog keys it as `deepseek-flash`,
889
+ // and taking the last path segment matches that without matching a different model.
890
+ function catalogCostFor(cfg, entry, key) {
891
+ if (!catalogCache) return null;
892
+ const providerName = entry.provider || String(key || '').split('/')[0];
893
+ const prov = ((cfg && cfg.raw && cfg.raw.providers) || {})[providerName] || {};
894
+ // `catalog` records which models.dev entry a renamed provider maps to, so a gateway
895
+ // serving a vendor's models under its own name can be priced — because the user
896
+ // declared the mapping, not because we guessed it from the model's name.
897
+ const catalogId = prov.catalog || providerName;
898
+ const modelId = entry.model || String(key || '').split('/').slice(1).join('/');
899
+ const bucket = catalogCache[catalogId];
900
+ if (!bucket || !bucket.models) return null;
901
+ const hit = bucket.models[modelId] || bucket.models[String(modelId).split('/').pop()];
902
+ if (!hit) return null;
903
+ return costFromCatalog(hit.cost) || null;
904
+ }
905
+
906
+
907
+ /**
908
+ * USD spent for a token total, given pricing. `usage` is the normalised
909
+ * accumulator shape ({ input, output, cached, cacheWrite }), with `cached` being
910
+ * the part of `input` that was a cache READ — so it is billed at the cache price
911
+ * and the remainder at the input price.
912
+ *
913
+ * Returns null when the model has no pricing: an unknown model must report
914
+ * "unknown", never a made-up $0.00 that reads like "this was free".
915
+ */
916
+ export function usageCost(usage, cost) {
917
+ if (!usage || !cost) return null;
918
+ const per = (tokens, price) => (Number(tokens) || 0) / 1e6 * (Number(price) || 0);
919
+ const cached = Number(usage.cached) || 0;
920
+ const input = Number(usage.input) || 0;
921
+ // Cache READS are a subset of the reported input; bill the difference at the
922
+ // plain input rate. Without this the input side was billed twice.
923
+ const freshInput = Math.max(0, input - cached);
924
+ let total = per(freshInput, cost.input) + per(Number(usage.output) || 0, cost.output);
925
+ if (cached) total += per(cached, cost.cacheRead !== undefined ? cost.cacheRead : cost.input);
926
+ if (usage.cacheWrite) total += per(usage.cacheWrite, cost.cacheWrite !== undefined ? cost.cacheWrite : cost.input);
927
+ return total;
928
+ }
929
+
930
+
931
+ // Replace (or create) a single `[models."<key>"]` block. Unlike addModel, which is
932
+ // append-only and a no-op when the key exists, this REWRITES the block so the
933
+ // /model editor can change display name / context / output limits. A key passed as
934
+ // undefined or '' is OMITTED, so clearing a field removes it from the file rather
935
+ // than writing an empty value. Other tables are left untouched.
936
+ export function upsertModel(provider, modelId, opts = {}) {
937
+ const file = hncodeConfigFile();
938
+ let text = '';
939
+ try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
940
+ const key = modelKey(provider, modelId);
941
+ const unq = (s) => String(s).replace(/\\(.)/g, '$1');
942
+ const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
943
+ const lines = text.split(/\r?\n/);
944
+ const out = [];
945
+ let skipping = false;
946
+ for (const ln of lines) {
947
+ const mm = modelRe.exec(ln);
948
+ if (mm) { skipping = unq(mm[1]) === key; if (skipping) continue; }
949
+ else if (/^\s*\[/.test(ln)) skipping = false;
950
+ if (!skipping) out.push(ln);
951
+ }
952
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
953
+ out.push('');
954
+ const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
955
+ out.push(`[models.${q(key)}]`);
956
+ out.push(`provider = ${q(provider)}`);
957
+ out.push(`model = ${q(modelId)}`);
958
+ if (opts.display_name) out.push(`display_name = ${q(opts.display_name)}`);
959
+ if (opts.contextLength) out.push(`context_length = ${Number(opts.contextLength)}`);
960
+ if (opts.maxTokens) out.push(`max_tokens = ${Number(opts.maxTokens)}`);
961
+ if (opts.maxOutputTokens) out.push(`max_output_tokens = ${Number(opts.maxOutputTokens)}`);
962
+ // Pricing, when the caller knows it. A blank means "leave the entry without
963
+ // prices", which is what an entry edited by hand in config.toml keeps.
964
+ out.push(...costLines(opts.cost));
965
+ fs.mkdirSync(path.dirname(file), { recursive: true });
966
+ fs.mkdirSync(path.dirname(file), { recursive: true });
967
+ fs.writeFileSync(file, out.join('\n') + '\n', 'utf8');
968
+ return file;
969
+ }
970
+
971
+ // Replace EVERY `[models."<provider>/*"]` block with the given list, persisting a
972
+ // provider's model set after a refresh. `entries` is an array of
973
+ // { id, display_name, contextLength, maxTokens, maxOutputTokens, reasoning, efforts }.
974
+ // Unlike upsertModel (one key) this is the bulk form: entries the provider no longer
975
+ // offers DISAPPEAR from the file, which is what makes Ctrl+R in /provider a real
976
+ // refresh rather than an in-memory-only illusion that the next launch undoes.
977
+ export function replaceProviderModels(provider, entries) {
978
+ const file = hncodeConfigFile();
979
+ let text = '';
980
+ try { text = fs.readFileSync(file, 'utf8'); } catch { /* new file */ }
981
+ const unq = (s) => String(s).replace(/\\(.)/g, '$1');
982
+ const modelRe = /^\s*\[models\.\s*"((?:[^"\\]|\\.)*)"\s*\]\s*$/;
983
+ const lines = text.split(/\r?\n/);
984
+ const out = [];
985
+ let skipping = false;
986
+ for (const ln of lines) {
987
+ const mm = modelRe.exec(ln);
988
+ if (mm) { skipping = unq(mm[1]).split('/')[0] === provider; if (skipping) continue; }
989
+ else if (/^\s*\[/.test(ln)) skipping = false;
990
+ if (!skipping) out.push(ln);
991
+ }
992
+ while (out.length && out[out.length - 1].trim() === '') out.pop();
993
+ const q = (v) => `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
994
+ for (const e of (entries || [])) {
995
+ const id = String(e.id || '').trim();
996
+ if (!id) continue;
997
+ out.push('');
998
+ out.push(`[models.${q(modelKey(provider, id))}]`);
999
+ out.push(`provider = ${q(provider)}`);
1000
+ out.push(`model = ${q(id)}`);
1001
+ if (e.display_name) out.push(`display_name = ${q(e.display_name)}`);
1002
+ if (e.contextLength) out.push(`context_length = ${Number(e.contextLength)}`);
1003
+ if (e.maxTokens) out.push(`max_tokens = ${Number(e.maxTokens)}`);
1004
+ if (e.maxOutputTokens) out.push(`max_output_tokens = ${Number(e.maxOutputTokens)}`);
1005
+ if (e.reasoning !== undefined && e.reasoning !== null) out.push(`reasoning = ${e.reasoning ? 'true' : 'false'}`);
1006
+ if (Array.isArray(e.efforts) && e.efforts.length) {
1007
+ out.push(`efforts = [ ${e.efforts.map((x) => `"${String(x).replace(/"/g, '\\"')}"`).join(', ')} ]`);
1008
+ }
1009
+ out.push(...costLines(e.cost));
1010
+ }
1011
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1012
+ fs.writeFileSync(file, out.join('\n') + (out.length ? '\n' : ''), 'utf8');
1013
+ return file;
1014
+ }
1015
+
726
1016
  function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
727
1017
 
1018
+
1019
+
728
1020
  // Fetch the model list from a provider's API. The base URL is used as given and
729
1021
  // we append only `/models` (so `https://host/v1` → `https://host/v1/models`).
730
1022
  // Anthropic sends x-api-key + anthropic-version. Returns [{id, display}] or [].
@@ -777,37 +1069,80 @@ export async function fetchModels({ baseUrl, apiKey, protocol, throwOnError = fa
777
1069
  } finally { clearTimeout(timer); }
778
1070
  }
779
1071
 
780
- // ---- thinking effort capability ----
1072
+ /**
1073
+ * Turn models.dev `reasoning_options` into a level list, or null when there is
1074
+ * nothing usable. This is the ONLY source of graded thinking levels — we never
1075
+ * derive a grade from the model id.
1076
+ *
1077
+ * The catalog shape is an array of typed entries:
1078
+ * { type: 'effort', values: ['low','medium','high'] } -> use `values`
1079
+ * { type: 'budget_tokens', min: 1024 } -> on/off
1080
+ * { type: 'toggle' } -> on/off
1081
+ * `none` in an effort list means "off"; it is normalised to 'off' and kept first.
1082
+ * A model may declare SEVERAL entries and they are not ordered, so the whole array
1083
+ * is scanned and an `effort` list wins over the plain toggle. Returns null (not [])
1084
+ * when the model declares no options, so the caller can tell "no catalog data" from
1085
+ * "explicitly no reasoning".
1086
+ */
1087
+ export function effortsFromReasoning(reasoningOptions) {
1088
+ const opts = Array.isArray(reasoningOptions) ? reasoningOptions : [];
1089
+ let graded = null;
1090
+ let toggle = false;
1091
+ // Read every entry before deciding. DeepSeek is what made this necessary: its
1092
+ // catalog data is `[{ type: 'toggle' }, { type: 'effort', values: [low, high,
1093
+ // max] }]`, and returning at the FIRST entry read only the toggle — so a model
1094
+ // with three grades showed a plain off/on and `max` was unreachable.
1095
+ for (const o of opts) {
1096
+ if (!o || typeof o !== 'object') continue;
1097
+ if (!graded && o.type === 'effort' && Array.isArray(o.values) && o.values.length) {
1098
+ const vals = o.values.map((v) => (String(v).toLowerCase() === 'none' ? 'off' : String(v).toLowerCase()));
1099
+ // De-dup and make sure 'off' is first when present.
1100
+ const uniq = [...new Set(vals)];
1101
+ uniq.sort((a, b) => (a === 'off' ? -1 : b === 'off' ? 1 : 0));
1102
+ graded = uniq;
1103
+ }
1104
+ if (o.type === 'budget_tokens' || o.type === 'toggle') toggle = true;
1105
+ }
1106
+ if (graded) {
1107
+ // A `toggle` beside the grades means thinking can also be switched off, which
1108
+ // the effort list does not always spell out itself.
1109
+ return toggle && !graded.includes('off') ? ['off', ...graded] : graded;
1110
+ }
1111
+ return toggle ? ['off', 'on'] : null;
1112
+ }
781
1113
 
782
1114
  // ---- thinking effort capability ----
783
1115
  // The set of selectable efforts for a model. Honours, in order:
784
- // 1. an explicit `efforts` / `reasoning_efforts` list on the model entry
785
- // 2. an explicit `reasoning = false` (model cannot think)
786
- // 3. a name heuristic for models known to expose graded effort
787
- // Returns [] when the model has no thinking control (then /model shows no
788
- // effort row and /effort reports it is unsupported).
789
- // Selectable thinking efforts for a model, mirroring kimi's `segmentsFor`:
790
- // * a model that declares `efforts` uses exactly those (plus a leading 'off'
791
- // unless it is `always_thinking`, which cannot be turned off)
792
- // * `reasoning = false` / `thinking = false` -> no control at all
793
- // * otherwise fall back to id heuristics: graded OpenAI models get
794
- // off/low/medium/high, Anthropic and everything else get off/on.
1116
+ // 1. an explicit `efforts` list on the model entry — this is what a catalog
1117
+ // install writes (from models.dev `reasoning_options`), and what a user can
1118
+ // also set by hand in config.toml (e.g. to name their own "max"/"xhigh")
1119
+ // 2. an explicit `reasoning = false` (model cannot think) -> no control
1120
+ // 3. `reasoning = true` with no declared levels -> the plain off/on toggle
1121
+ // Returns [] when the model has no thinking control (then /model shows no effort
1122
+ // row and /effort reports it is unsupported). Grades are NEVER guessed from the
1123
+ // model id: models.dev is the source of truth, and a model it does not know simply
1124
+ // gets the on/off toggle rather than an invented level list.
795
1125
  export function effortOptions(cfg, modelKeyName) {
796
1126
  const models = (cfg.raw && cfg.raw.models) || {};
797
1127
  const entry = models[modelKeyName || cfg.model] || {};
1128
+ // 1. A user-declared list wins outright (config.toml `efforts = [...]`), so a
1129
+ // user can name their own levels (max, xhigh, …) even for a model the catalog
1130
+ // does not know.
798
1131
  const declared = Array.isArray(entry.efforts) ? entry.efforts.filter(Boolean).slice() : [];
799
1132
  const alwaysOn = entry.always_thinking === true || entry.alwaysThinking === true;
800
- if (declared.length) return alwaysOn ? declared : (declared[0] === 'off' ? declared : ['off', ...declared]);
801
- if (alwaysOn) return ['on'];
802
- if (entry.reasoning === false || entry.thinking === false) return [];
803
- const id = String(entry.model || modelKeyName || cfg.innerModel || '').toLowerCase();
804
- // OpenAI-style graded efforts.
805
- if (/(^|\/)(gpt-5|o1|o3|o4)/.test(id) || /gpt-5|o1-|o3-|o4-/.test(id)) {
806
- return ['off', 'low', 'medium', 'high'];
1133
+ if (declared.length) {
1134
+ // Prepend 'off' unless the list already has it or thinking cannot be turned off.
1135
+ return alwaysOn || declared[0] === 'off' ? declared : ['off', ...declared];
807
1136
  }
808
- // Anthropic extended thinking is a budget, not a grade — expose on/off only.
809
- if (cfg.protocol === 'anthropic') return ['off', 'on'];
810
- // Default: thinking is on/off.
1137
+ // 2. An explicit `reasoning = false` / `thinking = false` means no control.
1138
+ if (entry.reasoning === false || entry.thinking === false) return [];
1139
+ if (alwaysOn) return ['on'];
1140
+ // 3. No declared levels. `reasoning = true` (supported) AND a missing field
1141
+ // (unknown — e.g. an entry created before the catalog stored efforts) both get
1142
+ // the plain off/on toggle. Grades are NEVER guessed from the model id: the
1143
+ // graded list is models.dev's to declare, stored as `efforts` when the provider
1144
+ // was added from the catalog. An entry with no reasoning field is UNKNOWN, not
1145
+ // "unsupported", so it must not lose its toggle.
811
1146
  return ['off', 'on'];
812
1147
  }
813
1148
 
@@ -818,6 +1153,11 @@ export function effortWire(cfg, effort) {
818
1153
  const budget = { on: 8000, low: 2000, medium: 8000, high: 16000, max: 32000 }[effort] || 8000;
819
1154
  return { thinking: { type: 'enabled', budget_tokens: budget } };
820
1155
  }
1156
+ // Responses API nests the grade: `reasoning: { effort }`.
1157
+ if (cfg.protocol === 'responses') {
1158
+ const grade = effort === 'on' ? 'medium' : effort;
1159
+ return { reasoning: { effort: grade } };
1160
+ }
821
1161
  // OpenAI-compatible: `reasoning_effort`. "on" is not a valid grade, so map it
822
1162
  // to "medium"; concrete grades pass through.
823
1163
  const grade = effort === 'on' ? 'medium' : effort;
@@ -941,7 +1281,9 @@ export function resolveProvider(cfg, providerName, innerModel) {
941
1281
  const protocol = (prov.protocol) || cfg.protocol;
942
1282
  const inner = prov.model || (innerModel != null ? innerModel : cfg.innerModel);
943
1283
  const b = String(baseUrl || '').replace(/\/+$/, '');
944
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages` : `${b}/chat/completions`);
1284
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
1285
+ : protocol === 'responses' ? `${b}/responses`
1286
+ : `${b}/chat/completions`);
945
1287
  // Provider switch may move the active model too; refresh the context window
946
1288
  // so the gauge reflects the newly active model, not the old one.
947
1289
  const maxContextTokens = contextForModel(cfg, cfg.model);
@@ -962,4 +1304,4 @@ export function resolveModelArg(cfg, modelName) {
962
1304
  return { ...resolveProvider(cfg, provider, inner), model: modelName, maxContextTokens };
963
1305
  }
964
1306
 
965
- export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile };
1307
+ export default { resolveConfig, saveConfig, effectiveSnapshot, resolveProvider, resolveModelArg, addProvider, removeProvider, addModel, upsertModel, replaceProviderModels, fetchModels, fetchCatalog, modelKey, modelLabel, bareModelId, rememberModel, hncodeConfigFile, effortsFromReasoning, effortOptions, costFromCatalog, modelCost, usageCost };