@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.
- package/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- 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
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
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
|
-
|
|
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`
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
//
|
|
791
|
-
//
|
|
792
|
-
//
|
|
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)
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
//
|
|
809
|
-
if (
|
|
810
|
-
|
|
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`
|
|
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 };
|