@krtclcdy/tersio 2.25.3 → 2.25.5
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 +14 -31
- package/dashboard/dist/index.html +18 -16
- package/dist/cli/backups.js +194 -0
- package/dist/cli/common.js +2 -2
- package/dist/cli/dashboard.js +125 -98
- package/dist/cli/doctor-fix.js +42 -45
- package/dist/cli/doctor.js +70 -8
- package/dist/cli/hosts.js +4 -4
- package/dist/cli/install.js +106 -38
- package/dist/cli/manifest.js +45 -16
- package/dist/cli/profile.js +1 -1
- package/dist/cli/uninstall.js +21 -5
- package/dist/cli/update.js +3 -3
- package/dist/cli/usage.js +75 -4
- package/dist/extensions/{caveman-session → caveman}/index.js +10 -3
- package/dist/extensions/{combo-toggle → combo}/index.js +3 -3
- package/dist/extensions/{tersio-commands → commands}/index.js +1 -1
- package/dist/extensions/rtk/filter.js +87 -0
- package/dist/extensions/{rtk-session/index.js → rtk/session.js} +4 -3
- package/dist/extensions/{opencode/server.js → server.js} +52 -8
- package/dist/extensions/shared/agent-stats.js +1262 -0
- package/dist/extensions/shared/brand-marks.js +154 -0
- package/dist/extensions/shared/host.js +18 -12
- package/dist/extensions/shared/plugin-settings.js +1 -1
- package/dist/extensions/shared/pricing.js +91 -27
- package/dist/extensions/shared/session-state.js +3 -1
- package/dist/extensions/shared/usage-ledger.js +79 -17
- package/dist/extensions/shared/usage-store.js +106 -22
- package/dist/extensions/{lib → shared}/utils.js +1 -1
- package/dist/extensions/{ai-addons-updater → updater}/index.js +15 -5
- package/dist/probe-catalog.js +48 -0
- package/dist/probe-catalog188.js +44 -0
- package/dist/tersio.js +2 -2
- package/extensions/{caveman-session → caveman}/index.ts +10 -3
- package/extensions/{combo-toggle → combo}/index.ts +3 -3
- package/extensions/{tersio-commands → commands}/index.ts +1 -1
- package/extensions/rtk/filter.ts +90 -0
- package/extensions/{rtk-session/index.ts → rtk/session.ts} +4 -3
- package/extensions/{opencode/server.ts → server.ts} +46 -9
- package/extensions/shared/agent-stats.ts +1657 -0
- package/extensions/shared/brand-marks.ts +163 -0
- package/extensions/shared/host.ts +26 -13
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/pricing.ts +107 -28
- package/extensions/shared/session-state.ts +3 -1
- package/extensions/shared/types.ts +10 -2
- package/extensions/shared/usage-ledger.ts +94 -19
- package/extensions/shared/usage-store.ts +106 -22
- package/extensions/{lib → shared}/utils.ts +1 -1
- package/extensions/{ai-addons-updater → updater}/index.ts +17 -7
- package/package.json +13 -12
- package/dist/cli/headroom-install.js +0 -266
- package/dist/cli/headroom.js +0 -492
- package/dist/extensions/headroom-session/index.js +0 -93
- package/dist/extensions/shared/headroom.js +0 -131
- /package/dist/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/dist/extensions/{caveman-session → caveman}/rule.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-megacave.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule-ultra.md +0 -0
- /package/extensions/{caveman-session → caveman}/rule.md +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Brand mark catalog: names, not bytes. One list serves both consumers — the dashboard draws from
|
|
2
|
+
// it at runtime, and the CLI export inlines the same URLs so a single-file dashboard keeps its
|
|
3
|
+
// logos with the network off. Simple Icons files are CC0-1.0; the marks stay their owners'
|
|
4
|
+
// property and the dashboard uses them nominatively, to name the service that served a request.
|
|
5
|
+
// A vendor with no Simple Icons entry keeps its owner-hosted mark, one with no hosted mark is
|
|
6
|
+
// drawn inline (`dashboard/app/src/lib/marks/inline.ts`), and bumping `SIMPLE_ICONS` once moves
|
|
7
|
+
// every Simple Icons entry with it.
|
|
8
|
+
const SIMPLE_ICONS = "https://cdn.jsdelivr.net/npm/simple-icons@16/icons";
|
|
9
|
+
|
|
10
|
+
/** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
|
|
11
|
+
export const REMOTE_MARKS: Readonly<Record<string, string>> = {
|
|
12
|
+
// Simple Icons, CC0-1.0.
|
|
13
|
+
anthropic: `${SIMPLE_ICONS}/anthropic.svg`,
|
|
14
|
+
google: `${SIMPLE_ICONS}/google.svg`,
|
|
15
|
+
meta: `${SIMPLE_ICONS}/meta.svg`,
|
|
16
|
+
deepseek: `${SIMPLE_ICONS}/deepseek.svg`,
|
|
17
|
+
qwen: `${SIMPLE_ICONS}/qwen.svg`,
|
|
18
|
+
x: `${SIMPLE_ICONS}/x.svg`,
|
|
19
|
+
mistralai: `${SIMPLE_ICONS}/mistralai.svg`,
|
|
20
|
+
minimax: `${SIMPLE_ICONS}/minimax.svg`,
|
|
21
|
+
zdotai: `${SIMPLE_ICONS}/zdotai.svg`,
|
|
22
|
+
xiaomi: `${SIMPLE_ICONS}/xiaomi.svg`,
|
|
23
|
+
kimi: `${SIMPLE_ICONS}/kimi.svg`,
|
|
24
|
+
moonshotai: `${SIMPLE_ICONS}/moonshotai.svg`,
|
|
25
|
+
nvidia: `${SIMPLE_ICONS}/nvidia.svg`,
|
|
26
|
+
amd: `${SIMPLE_ICONS}/amd.svg`,
|
|
27
|
+
ollama: `${SIMPLE_ICONS}/ollama.svg`,
|
|
28
|
+
cline: `${SIMPLE_ICONS}/cline.svg`,
|
|
29
|
+
githubcopilot: `${SIMPLE_ICONS}/githubcopilot.svg`,
|
|
30
|
+
openrouter: `${SIMPLE_ICONS}/openrouter.svg`,
|
|
31
|
+
github: `${SIMPLE_ICONS}/github.svg`,
|
|
32
|
+
instagram: `${SIMPLE_ICONS}/instagram.svg`,
|
|
33
|
+
reddit: `${SIMPLE_ICONS}/reddit.svg`,
|
|
34
|
+
// Owner-hosted, nominative use.
|
|
35
|
+
groq: "https://groq.com/favicon.svg",
|
|
36
|
+
poolside: "https://poolside.ai/favicon/favicon.svg",
|
|
37
|
+
cerebras:
|
|
38
|
+
"https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
|
|
39
|
+
inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
|
|
40
|
+
// Owner avatars: the vendor or gateway posts no standalone icon, so its account picture stands in.
|
|
41
|
+
stepfun: "https://cdn-avatars.huggingface.co/v1/production/uploads/644f7e6233ac8f46fa0b9e26/CmF2ocXhkr2UtHXgmwq7-.png",
|
|
42
|
+
// typesafe.ai serves one full-bleed tile: a dark square with pink art inside it.
|
|
43
|
+
typesafe: "https://framerusercontent.com/images/aNFzSFxM4fjICmnibw7npfZjcQ.png",
|
|
44
|
+
// A gateway's own brand, which names the service that served the request. Never another vendor's logo.
|
|
45
|
+
opencode: "https://opencode.ai/favicon.svg",
|
|
46
|
+
commandcode: "https://commandcode.ai/favicon.ico",
|
|
47
|
+
kilo: "https://kilo.ai/favicon.ico",
|
|
48
|
+
magpie: "https://usemagpie.ai/favicon.png",
|
|
49
|
+
gmicloud: "https://gmicloud.ai/favicon.ico",
|
|
50
|
+
charmhyper: "https://github.com/charmbracelet.png?size=80",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Tile fill, not text. A mark whose tile carries its brand color reads as the brand, and a mark
|
|
55
|
+
* whose logo already fills its own box never shows the fill. Values come from the brand (the
|
|
56
|
+
* service's own page or its own asset), never from a guess; a slug absent here keeps the plain
|
|
57
|
+
* panel and the theme ink.
|
|
58
|
+
*/
|
|
59
|
+
export const MARK_BG: Readonly<Record<string, string>> = {
|
|
60
|
+
// Simple Icons marks: the vendor's brand color, taken from its own brand page.
|
|
61
|
+
anthropic: "#d97757",
|
|
62
|
+
meta: "#0082fb",
|
|
63
|
+
google: "#4285f4",
|
|
64
|
+
deepseek: "#4d6bfe",
|
|
65
|
+
qwen: "#6950ef",
|
|
66
|
+
mistralai: "#ff7000",
|
|
67
|
+
minimax: "#e11d48",
|
|
68
|
+
// Kimi's own favicon carries this blue, not the Moonshot violet.
|
|
69
|
+
kimi: "#1782fe",
|
|
70
|
+
xiaomi: "#ff6900",
|
|
71
|
+
nvidia: "#76b900",
|
|
72
|
+
amd: "#ed1c24",
|
|
73
|
+
x: "#000000",
|
|
74
|
+
ollama: "#000000",
|
|
75
|
+
zdotai: "#2d2d2d",
|
|
76
|
+
github: "#181717",
|
|
77
|
+
githubcopilot: "#181717",
|
|
78
|
+
cline: "#9f58fa",
|
|
79
|
+
openrouter: "#03080a",
|
|
80
|
+
instagram: "#e4405f",
|
|
81
|
+
reddit: "#ff4500",
|
|
82
|
+
linkedin: "#0a66c2",
|
|
83
|
+
// Owner-hosted marks: the tile matches the asset's own square, and a slug whose artwork already
|
|
84
|
+
// carries its own color takes a white tile so the art reads.
|
|
85
|
+
groq: "#f43e01",
|
|
86
|
+
poolside: "#4137ff",
|
|
87
|
+
cerebras: "#ffffff",
|
|
88
|
+
typesafe: "#171717",
|
|
89
|
+
charmhyper: "#ffffff",
|
|
90
|
+
stepfun: "#ffffff",
|
|
91
|
+
kilo: "#f7f576",
|
|
92
|
+
opencode: "#131010",
|
|
93
|
+
// The mark's own artwork is white, so the tile is white and the glyph is black.
|
|
94
|
+
openai: "#ffffff",
|
|
95
|
+
commandcode: "#ffffff",
|
|
96
|
+
gmicloud: "#ffffff",
|
|
97
|
+
magpie: "#0b0b0c",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Glyph scale per slug, applied to the glyph box the tile clips. Simple Icons art fills its 24 box
|
|
102
|
+
* by very different amounts, so the measured ink box decides the scale: a logo that runs to its
|
|
103
|
+
* box edge shrinks and one with an invisible margin grows. One is the default.
|
|
104
|
+
*/
|
|
105
|
+
export const MARK_SCALE: Readonly<Record<string, number>> = {
|
|
106
|
+
kimi: 0.72,
|
|
107
|
+
zdotai: 0.72,
|
|
108
|
+
openrouter: 0.9,
|
|
109
|
+
magpie: 1.15,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** WCAG relative luminance of a `#rrggbb` string. */
|
|
113
|
+
export function luminance(hex: string): number {
|
|
114
|
+
const n = parseInt(hex.slice(1), 16);
|
|
115
|
+
const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
|
|
116
|
+
const s = v / 255;
|
|
117
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
118
|
+
});
|
|
119
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The glyph color that stays legible on a tile fill. The cut sits at 0.35, which keeps white ink
|
|
124
|
+
* on a brand color: Meta blue, DeepSeek blue, Xiaomi orange, Mistral orange, and Kimi blue all
|
|
125
|
+
* carry a white logo, which is how their own brand sheets print them. Only a light neutral takes
|
|
126
|
+
* black: Kilo yellow, StepFun teal, and Lightbend orange.
|
|
127
|
+
*/
|
|
128
|
+
export function contrastInk(hex: string): string {
|
|
129
|
+
return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Marks that carry their own color or their own background. Everything else is opaque black, so
|
|
134
|
+
* it draws as an alpha mask filled with the chosen ink instead of an image. A slug whose asset is
|
|
135
|
+
* a filled tile must draw as an image, or the mask paints one flat block and the logo vanishes.
|
|
136
|
+
*/
|
|
137
|
+
export const COLORED_MARKS: ReadonlySet<string> = new Set([
|
|
138
|
+
"cerebras",
|
|
139
|
+
"inclusionai",
|
|
140
|
+
"typesafe",
|
|
141
|
+
"opencode",
|
|
142
|
+
"commandcode",
|
|
143
|
+
"kilo",
|
|
144
|
+
"magpie",
|
|
145
|
+
"charmhyper",
|
|
146
|
+
"stepfun",
|
|
147
|
+
// The ICO and the two inline PNGs are dark tiles with light art drawn inside them. The Groq
|
|
148
|
+
// favicon is one flat orange, so it draws as a mask instead: a masked glyph on a Groq-orange
|
|
149
|
+
// tile is a logo, and the same art as an image on the same orange tile is a blank tile.
|
|
150
|
+
"gmicloud",
|
|
151
|
+
"stealth",
|
|
152
|
+
"cognition",
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
/** The URL a mark comes from, or null when the name has no hosted mark. */
|
|
156
|
+
export function markUrl(slug: string): string | null {
|
|
157
|
+
return REMOTE_MARKS[slug] ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when the mark carries its own color and must draw as an image. */
|
|
161
|
+
export function isColoredMark(slug: string): boolean {
|
|
162
|
+
return COLORED_MARKS.has(slug);
|
|
163
|
+
}
|
|
@@ -36,7 +36,7 @@ interface HostAdapter {
|
|
|
36
36
|
label(pi: HostHandle, text: string): void;
|
|
37
37
|
on<E>(pi: ExtensionApi, event: string, handler: (event: E, ctx: ExtensionCtx) => unknown): void;
|
|
38
38
|
select(ui: UiApi | undefined, title: string, options: SelectOption[], dialogOptions?: Record<string, unknown>): Promise<string | undefined>;
|
|
39
|
-
inject(event: SystemPromptEvent,
|
|
39
|
+
inject(event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined;
|
|
40
40
|
stringArray(pi: ExtensionApi, name: string, spec: StringArraySpec): ToolParams;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -53,15 +53,21 @@ const piAdapter: HostAdapter = {
|
|
|
53
53
|
if (picked === undefined) return undefined;
|
|
54
54
|
return options[shown.indexOf(picked)]?.label ?? picked;
|
|
55
55
|
},
|
|
56
|
-
inject(event,
|
|
57
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
56
|
+
inject(event, block) {
|
|
57
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
58
58
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
59
59
|
const options = event.systemPromptOptions;
|
|
60
|
+
// A named section is replaced in place; returning systemPrompt replaces the whole prompt.
|
|
61
|
+
const sections = options?.sections;
|
|
62
|
+
if (block.sectionTag && sections && typeof sections === 'object') {
|
|
63
|
+
sections[block.sectionTag] = block.text;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
60
66
|
if (options && typeof options.appendSystemPrompt === 'string') {
|
|
61
|
-
options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(text).filter(Boolean).join('\n\n');
|
|
67
|
+
options.appendSystemPrompt = dropStale([options.appendSystemPrompt]).concat(block.text).filter(Boolean).join('\n\n');
|
|
62
68
|
return undefined;
|
|
63
69
|
}
|
|
64
|
-
return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${text}` };
|
|
70
|
+
return { systemPrompt: `${dropStale(asPromptArray(event.systemPrompt)).join('\n\n')}\n\n${block.text}` };
|
|
65
71
|
},
|
|
66
72
|
stringArray(_pi, name, spec) {
|
|
67
73
|
const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
|
|
@@ -78,10 +84,10 @@ const ompAdapter: HostAdapter = {
|
|
|
78
84
|
if (!ui?.select) return undefined;
|
|
79
85
|
return ui.select(title, options, dialogOptions);
|
|
80
86
|
},
|
|
81
|
-
inject(event,
|
|
82
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
87
|
+
inject(event, block) {
|
|
88
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
83
89
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
84
|
-
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
|
|
90
|
+
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
|
|
85
91
|
},
|
|
86
92
|
stringArray(pi, name, spec) {
|
|
87
93
|
const { z } = pi.zod as { z: ZodFactory['z'] };
|
|
@@ -98,10 +104,10 @@ const opencodeAdapter: HostAdapter = {
|
|
|
98
104
|
if (!ui?.select) return undefined;
|
|
99
105
|
return ui.select(title, options, dialogOptions);
|
|
100
106
|
},
|
|
101
|
-
inject(event,
|
|
102
|
-
const marker = staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
107
|
+
inject(event, block) {
|
|
108
|
+
const marker = block.staleMarker?.replace(/^[^\w]+/, '').trim();
|
|
103
109
|
const dropStale = (parts: string[]): string[] => (marker ? parts.filter((part) => !part.includes(marker)) : parts);
|
|
104
|
-
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), text] };
|
|
110
|
+
return { systemPrompt: [...dropStale(asPromptArray(event.systemPrompt)), block.text] };
|
|
105
111
|
},
|
|
106
112
|
stringArray(_pi, name, spec) {
|
|
107
113
|
const array: Record<string, unknown> = { type: 'array', items: { type: 'string' } };
|
|
@@ -141,8 +147,15 @@ export async function hostSelect(
|
|
|
141
147
|
return adapterFor(pi).select(ui, title, options, dialogOptions);
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
/** One prompt block: its text, the section tag that owns it, and prior text to drop. */
|
|
151
|
+
export interface PromptBlock {
|
|
152
|
+
text: string;
|
|
153
|
+
sectionTag?: string;
|
|
154
|
+
staleMarker?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function injectPromptText(pi: HostHandle, event: SystemPromptEvent, block: PromptBlock): PromptInjection | undefined {
|
|
158
|
+
return adapterFor(pi).inject(event, block);
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
export function stringArrayToolParams(pi: ExtensionApi, name: string, spec: StringArraySpec = {}): ToolParams {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import { homeDir, tersioHome } from '
|
|
5
|
+
import { homeDir, tersioHome } from './utils.ts';
|
|
6
6
|
|
|
7
7
|
export const PLUGIN_NAME = '@krtclcdy/tersio';
|
|
8
8
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { tersioDataPath } from '
|
|
4
|
+
import { tersioDataPath } from './utils.ts';
|
|
5
5
|
|
|
6
6
|
export interface ModelPrice {
|
|
7
7
|
input: number;
|
|
@@ -13,6 +13,27 @@ export interface ModelPrice {
|
|
|
13
13
|
// Sonnet-class default for unpriced models; always paired with known:false.
|
|
14
14
|
export const DEFAULT_PRICE: ModelPrice = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* How a price was found, strongest first. `tail` and `default` are estimates.
|
|
18
|
+
*
|
|
19
|
+
* - `measured`: the host reported it. The only tier that can be zero without being a guess.
|
|
20
|
+
* - `override`: the operator's `prices.local.json`, exact for the gateway it names.
|
|
21
|
+
* - `exact`: the feed holds the id verbatim, or case-folded. `alias`: via `MODEL_ALIASES`.
|
|
22
|
+
* - `tail`: the feed holds the bare name under some provider, which need not be the one billed.
|
|
23
|
+
* - `default`: no match. `DEFAULT_PRICE`, an estimate by definition.
|
|
24
|
+
*/
|
|
25
|
+
export type PriceTier = 'measured' | 'override' | 'exact' | 'alias' | 'tail' | 'default';
|
|
26
|
+
|
|
27
|
+
/** Tiers firm enough to call a price. `tail` and `default` are estimates, so they are not. */
|
|
28
|
+
export const PRICE_TIERS_FIRM: readonly PriceTier[] = ['measured', 'override', 'exact', 'alias'];
|
|
29
|
+
|
|
30
|
+
export interface PriceHit {
|
|
31
|
+
price: ModelPrice;
|
|
32
|
+
known: boolean;
|
|
33
|
+
live: boolean;
|
|
34
|
+
tier: PriceTier;
|
|
35
|
+
}
|
|
36
|
+
|
|
16
37
|
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
|
|
17
38
|
// Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
|
|
18
39
|
const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
|
|
@@ -24,6 +45,49 @@ export function pricesCachePath(): string {
|
|
|
24
45
|
return tersioDataPath('prices.json', 'tersio-prices.json');
|
|
25
46
|
}
|
|
26
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The operator's own prices, merged over the feed and keyed exactly as the transcript spells a
|
|
50
|
+
* model. A `*` prefix matches any gateway suffix. This is where a model the feed keys under a
|
|
51
|
+
* different provider gets a real price.
|
|
52
|
+
*/
|
|
53
|
+
export function pricesOverridePath(): string {
|
|
54
|
+
const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
|
|
55
|
+
if (override) return override;
|
|
56
|
+
return tersioDataPath('prices.local.json', 'tersio-prices.local.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The override file's mtime, or 0 when absent. Callers key their memo on it. */
|
|
60
|
+
function overrideStamp(): number {
|
|
61
|
+
try {
|
|
62
|
+
return fs.statSync(pricesOverridePath()).mtimeMs;
|
|
63
|
+
} catch {
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let overrideCache: { stamp: number; exact: Map<string, ModelPrice>; tails: Array<[string, ModelPrice]> } | null = null;
|
|
69
|
+
|
|
70
|
+
/** Reads the override file, keeping a `*`-prefixed entry as a suffix matcher. */
|
|
71
|
+
function loadOverrides(stamp = overrideStamp()): { exact: Map<string, ModelPrice>; tails: Array<[string, ModelPrice]> } | null {
|
|
72
|
+
if (stamp === 0) return null;
|
|
73
|
+
const file = pricesOverridePath();
|
|
74
|
+
if (overrideCache && overrideCache.stamp === stamp) return overrideCache;
|
|
75
|
+
const exact = new Map<string, ModelPrice>();
|
|
76
|
+
const tails: Array<[string, ModelPrice]> = [];
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;
|
|
79
|
+
for (const [id, row] of Object.entries(parsed ?? {})) {
|
|
80
|
+
const price = asPrice(row);
|
|
81
|
+
if (!price) continue;
|
|
82
|
+
// A `*` prefix matches any gateway prefix, so one entry covers every spelling of a model.
|
|
83
|
+
if (id.startsWith('*')) tails.push([id.slice(1).toLowerCase(), price]);
|
|
84
|
+
else exact.set(id, price);
|
|
85
|
+
}
|
|
86
|
+
} catch { /* an unreadable override is empty, not fatal */ }
|
|
87
|
+
overrideCache = { stamp, exact, tails };
|
|
88
|
+
return overrideCache;
|
|
89
|
+
}
|
|
90
|
+
|
|
27
91
|
export function pricesUrl(): string {
|
|
28
92
|
return process.env.TERSIO_PRICES_URL || LITELLM_URL;
|
|
29
93
|
}
|
|
@@ -109,30 +173,29 @@ export function canonicalPriceId(model: string): string {
|
|
|
109
173
|
return MODEL_ALIASES.find((a) => a.id === key || a.feed === key || key.endsWith(`/${a.id}`) || key.endsWith(`/${a.feed}`))?.id ?? key;
|
|
110
174
|
}
|
|
111
175
|
|
|
112
|
-
export function priceFor(model: string, live?: LivePrices | null):
|
|
176
|
+
export function priceFor(model: string, live?: LivePrices | null): PriceHit {
|
|
113
177
|
const table = live === undefined ? loadLivePrices() : live;
|
|
114
178
|
if (live === undefined) {
|
|
115
|
-
|
|
179
|
+
// An operator who edits the override file expects the new price at once, not after a refresh.
|
|
180
|
+
const stamp = overrideStamp();
|
|
181
|
+
if (memoTable !== table || memoStamp !== stamp) {
|
|
182
|
+
memoTable = table;
|
|
183
|
+
memoStamp = stamp;
|
|
184
|
+
memo = new Map();
|
|
185
|
+
}
|
|
116
186
|
const hit = memo.get(model);
|
|
117
187
|
if (hit) return hit;
|
|
118
|
-
const resolved = resolvePrice(model, table);
|
|
188
|
+
const resolved = resolvePrice(model, table, stamp);
|
|
119
189
|
memo.set(model, resolved);
|
|
120
190
|
return resolved;
|
|
121
191
|
}
|
|
122
|
-
return resolvePrice(model, table);
|
|
192
|
+
return resolvePrice(model, table, overrideStamp());
|
|
123
193
|
}
|
|
124
194
|
|
|
125
195
|
// Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
|
|
126
196
|
let memoTable: LivePrices | null | undefined;
|
|
127
|
-
let
|
|
128
|
-
|
|
129
|
-
function priceMemo(table: LivePrices | null): Map<string, { price: ModelPrice; known: boolean; live: boolean }> {
|
|
130
|
-
if (memoTable !== table) {
|
|
131
|
-
memoTable = table;
|
|
132
|
-
memo = new Map();
|
|
133
|
-
}
|
|
134
|
-
return memo;
|
|
135
|
-
}
|
|
197
|
+
let memoStamp = 0;
|
|
198
|
+
let memo = new Map<string, PriceHit>();
|
|
136
199
|
|
|
137
200
|
// Validated once per table instead of once per lookup.
|
|
138
201
|
let compiledTable: LivePrices | null = null;
|
|
@@ -150,26 +213,42 @@ function priceEntries(table: LivePrices): Array<[string, ModelPrice]> {
|
|
|
150
213
|
return entries;
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
function resolvePrice(model: string, table: LivePrices | null
|
|
154
|
-
//
|
|
155
|
-
const
|
|
216
|
+
function resolvePrice(model: string, table: LivePrices | null, stamp = overrideStamp()): PriceHit {
|
|
217
|
+
// The operator's file wins outright: it is the only source that can name what a gateway charges.
|
|
218
|
+
const overrides = loadOverrides(stamp);
|
|
219
|
+
if (overrides) {
|
|
220
|
+
const own = overrides.exact.get(model) ?? overrides.exact.get(model.toLowerCase());
|
|
221
|
+
if (own) return { price: own, known: true, live: false, tier: 'override' };
|
|
222
|
+
const low = model.toLowerCase();
|
|
223
|
+
for (const [suffix, price] of overrides.tails) {
|
|
224
|
+
if (low === suffix || low.endsWith(`/${suffix}`)) {
|
|
225
|
+
return { price, known: true, live: false, tier: 'override' };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// An alias goes through the same chain as any other id, so it still resolves when the feed keys
|
|
231
|
+
// it under a provider prefix.
|
|
232
|
+
const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
|
|
233
|
+
const id = alias?.feed ?? model;
|
|
156
234
|
if (table) {
|
|
157
235
|
const entries = priceEntries(table);
|
|
236
|
+
// 1. The id verbatim, then case-folded. Nothing is being guessed here.
|
|
237
|
+
const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
|
|
238
|
+
if (hit) return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
|
|
239
|
+
|
|
158
240
|
const lower = id.toLowerCase();
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
const name = lower;
|
|
165
|
-
const tail = name.split('/').pop() ?? name;
|
|
166
|
-
const key = entries.find(([k]) => {
|
|
241
|
+
const tail = lower.split('/').pop() ?? lower;
|
|
242
|
+
// The bare model name under any provider, which need not be the provider that billed the row.
|
|
243
|
+
// Reported as tier `tail`, so a caller keeps it out of a firm total; `prices.local.json` fixes
|
|
244
|
+
// a specific case.
|
|
245
|
+
const bare = entries.find(([k]) => {
|
|
167
246
|
const lk = k.toLowerCase();
|
|
168
|
-
return lk ===
|
|
247
|
+
return lk === tail || lk.endsWith(`/${tail}`);
|
|
169
248
|
});
|
|
170
|
-
if (
|
|
249
|
+
if (bare) return { price: bare[1], known: true, live: true, tier: 'tail' };
|
|
171
250
|
}
|
|
172
|
-
return { price: DEFAULT_PRICE, known: false, live: false };
|
|
251
|
+
return { price: DEFAULT_PRICE, known: false, live: false, tier: 'default' };
|
|
173
252
|
}
|
|
174
253
|
// Fetch LiteLLM pricing and write the cache. False on any failure — callers fall back silently.
|
|
175
254
|
export async function refreshPrices(): Promise<boolean> {
|
|
@@ -95,7 +95,9 @@ export function normalizeInputCommand(value: unknown): string {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export function asPromptArray(systemPrompt: string | string[]): string[] {
|
|
98
|
-
|
|
98
|
+
const parts = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt];
|
|
99
|
+
// OMP can hand back a hole in the array; a non-string breaks every marker check.
|
|
100
|
+
return parts.filter((part): part is string => typeof part === 'string');
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
export function systemPromptIncludes(systemPrompt: string | string[], marker: string): boolean {
|
|
@@ -36,6 +36,7 @@ export interface UiApi {
|
|
|
36
36
|
export interface ExtensionCtx {
|
|
37
37
|
hasUI?: boolean;
|
|
38
38
|
cwd?: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
39
40
|
sessionManager?: {
|
|
40
41
|
getBranch?: () => SessionEntry[];
|
|
41
42
|
getEntries?: () => SessionEntry[];
|
|
@@ -46,13 +47,20 @@ export interface ExtensionCtx {
|
|
|
46
47
|
|
|
47
48
|
export interface SystemPromptEvent {
|
|
48
49
|
systemPrompt: string | string[];
|
|
49
|
-
// pi only: the mutable prompt sections. OMP does not provide
|
|
50
|
-
systemPromptOptions?: { appendSystemPrompt?: string };
|
|
50
|
+
// pi only: the mutable prompt sections and append slot. OMP does not provide them.
|
|
51
|
+
systemPromptOptions?: { appendSystemPrompt?: string; sections?: Record<string, string> };
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/** What a `before_agent_start` handler hands back. OMP takes an array, pi a string. */
|
|
54
55
|
export type PromptInjection = { systemPrompt: string[] } | { systemPrompt: string };
|
|
55
56
|
|
|
57
|
+
/** A finished tool call, before its result reaches the model. pi and OMP provide it. */
|
|
58
|
+
export interface ToolResultEvent {
|
|
59
|
+
toolName?: string;
|
|
60
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
61
|
+
isError?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
/** A tool `parameters` object. OMP wants zod, pi wants JSON Schema. */
|
|
57
65
|
export type ToolParams = Record<string, unknown>;
|
|
58
66
|
|