pi-kimi-keepalive 0.1.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/LICENSE +21 -0
- package/README.md +122 -0
- package/README.zh-CN.md +118 -0
- package/package.json +46 -0
- package/src/index.ts +844 -0
- package/src/lib.ts +245 -0
package/src/lib.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for pi-kimi-keepalive — no pi imports here so the logic is
|
|
3
|
+
* trivially unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface CostPerM {
|
|
7
|
+
/** USD per 1M input tokens (cache miss). */
|
|
8
|
+
input: number;
|
|
9
|
+
/** USD / 1M output tokens. */
|
|
10
|
+
output: number;
|
|
11
|
+
/** USD / 1M cache-read input tokens. */
|
|
12
|
+
cacheRead: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Headers that must never be forwarded to a re-issued request. */
|
|
16
|
+
const FORBIDDEN_HEADERS = new Set([
|
|
17
|
+
"content-length",
|
|
18
|
+
"host",
|
|
19
|
+
"connection",
|
|
20
|
+
"transfer-encoding",
|
|
21
|
+
"accept-encoding",
|
|
22
|
+
"keep-alive",
|
|
23
|
+
"expect",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build headers for a probe request from the headers captured on the last
|
|
28
|
+
* real provider request. Auth / routing headers are forwarded verbatim;
|
|
29
|
+
* hop-by-hop and length headers are dropped (fetch sets its own).
|
|
30
|
+
*/
|
|
31
|
+
export function buildProbeHeaders(captured: Record<string, string>): Record<string, string> {
|
|
32
|
+
const out: Record<string, string> = {};
|
|
33
|
+
for (const [key, value] of Object.entries(captured)) {
|
|
34
|
+
const lower = key.toLowerCase();
|
|
35
|
+
if (FORBIDDEN_HEADERS.has(lower) || lower === "content-type") continue;
|
|
36
|
+
out[key] = value;
|
|
37
|
+
}
|
|
38
|
+
out["content-type"] = "application/json";
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a keepalive probe body from a captured provider payload.
|
|
44
|
+
*
|
|
45
|
+
* The conversation prefix (messages / tools / system role, plus Kimi's
|
|
46
|
+
* prompt_cache_key / prompt_cache_retention) is kept byte-identical so the
|
|
47
|
+
* request hits the same automatic prefix cache. Only terminal parameters
|
|
48
|
+
* change: non-streaming and a tiny output clamp.
|
|
49
|
+
*
|
|
50
|
+
* `api` selects the payload dialect: anthropic-messages uses max_tokens /
|
|
51
|
+
* optional tool_choice:{type:"none"}; everything else is treated as the
|
|
52
|
+
* OpenAI-completions style that kimi-openai-completions actually uses
|
|
53
|
+
* (max_completion_tokens; stream_options / thinking / store removed).
|
|
54
|
+
* attempt 2 drops prompt_cache_retention when the endpoint rejects a
|
|
55
|
+
* terminal parameter (HTTP 400) — the prefix is never modified.
|
|
56
|
+
*/
|
|
57
|
+
export function buildProbeBody(
|
|
58
|
+
payload: unknown,
|
|
59
|
+
maxOutputTokens: number,
|
|
60
|
+
api: string | undefined,
|
|
61
|
+
attempt: 1 | 2,
|
|
62
|
+
): { ok: true; body: Record<string, unknown> } | { ok: false; reason: string } {
|
|
63
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
64
|
+
return { ok: false, reason: "captured payload is not an object" };
|
|
65
|
+
}
|
|
66
|
+
const raw = payload as Record<string, unknown>;
|
|
67
|
+
if (!Array.isArray(raw.messages) || raw.messages.length === 0) {
|
|
68
|
+
return { ok: false, reason: "captured payload has no messages array" };
|
|
69
|
+
}
|
|
70
|
+
if (
|
|
71
|
+
raw.system !== undefined &&
|
|
72
|
+
!Array.isArray(raw.system) &&
|
|
73
|
+
typeof raw.system !== "string"
|
|
74
|
+
) {
|
|
75
|
+
return { ok: false, reason: "captured payload system field has an unexpected shape" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const body: Record<string, unknown> = structuredClone(raw);
|
|
79
|
+
if (api === "anthropic-messages") {
|
|
80
|
+
delete body.stream;
|
|
81
|
+
delete body.stream_options;
|
|
82
|
+
// thinking must go with a tiny max_tokens: Anthropic-style APIs require
|
|
83
|
+
// max_tokens > thinking.budget_tokens, and thinking is not part of the
|
|
84
|
+
// prompt prefix that feeds the cache key.
|
|
85
|
+
delete body.thinking;
|
|
86
|
+
body.max_tokens = Math.max(1, Math.floor(maxOutputTokens));
|
|
87
|
+
if (attempt === 1) body.tool_choice = { type: "none" };
|
|
88
|
+
else delete body.tool_choice;
|
|
89
|
+
return { ok: true, body };
|
|
90
|
+
}
|
|
91
|
+
// OpenAI-completions style (kimi-openai-completions and friends).
|
|
92
|
+
delete body.stream;
|
|
93
|
+
delete body.stream_options;
|
|
94
|
+
delete body.thinking;
|
|
95
|
+
delete body.store;
|
|
96
|
+
delete body.tool_choice;
|
|
97
|
+
body.max_completion_tokens = Math.max(1, Math.floor(maxOutputTokens));
|
|
98
|
+
if (attempt === 2) delete body.prompt_cache_retention;
|
|
99
|
+
return { ok: true, body };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ParsedUsage {
|
|
103
|
+
inputTokens: number;
|
|
104
|
+
outputTokens: number;
|
|
105
|
+
cacheReadTokens: number;
|
|
106
|
+
cacheWriteTokens: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract usage from a non-streaming response, tolerating both the
|
|
111
|
+
* Anthropic-style field names (cache_read_input_tokens) pi uses for
|
|
112
|
+
* anthropic-messages routes and OpenAI-style cached_tokens just in case a
|
|
113
|
+
* gateway renames fields.
|
|
114
|
+
*/
|
|
115
|
+
export function parseUsage(data: unknown): ParsedUsage {
|
|
116
|
+
const out: ParsedUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
117
|
+
if (!data || typeof data !== "object") return out;
|
|
118
|
+
const usageRaw = (data as { usage?: unknown }).usage;
|
|
119
|
+
if (!usageRaw || typeof usageRaw !== "object") return out;
|
|
120
|
+
const u = usageRaw as Record<string, unknown>;
|
|
121
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
|
|
122
|
+
|
|
123
|
+
out.inputTokens = num(u.input_tokens) || num(u.prompt_tokens);
|
|
124
|
+
out.outputTokens = num(u.output_tokens) || num(u.completion_tokens);
|
|
125
|
+
out.cacheReadTokens = num(u.cache_read_input_tokens);
|
|
126
|
+
out.cacheWriteTokens = num(u.cache_creation_input_tokens);
|
|
127
|
+
if (out.cacheReadTokens === 0) {
|
|
128
|
+
const details = u.prompt_tokens_details;
|
|
129
|
+
if (details && typeof details === "object") {
|
|
130
|
+
out.cacheReadTokens = num((details as Record<string, unknown>).cached_tokens);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (out.cacheReadTokens === 0) {
|
|
134
|
+
out.cacheReadTokens = num(u.cached_tokens);
|
|
135
|
+
}
|
|
136
|
+
// Anthropic counts cached tokens inside input_tokens; OpenAI-style too.
|
|
137
|
+
// Keep both interpretations consistent for miss detection below.
|
|
138
|
+
if (out.inputTokens === 0 && out.cacheReadTokens > 0) out.inputTokens = out.cacheReadTokens;
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Best-effort usage extraction from an unexpectedly streamed (SSE) body. */
|
|
143
|
+
export function parseUsageFromSse(text: string): ParsedUsage {
|
|
144
|
+
// Scan text/event-stream lines for the richest usage object seen.
|
|
145
|
+
const result: ParsedUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
|
|
146
|
+
const re = /"usage"\s*:\s*\{[^{}]*\}/g;
|
|
147
|
+
for (const match of text.matchAll(re)) {
|
|
148
|
+
try {
|
|
149
|
+
const parsed = parseUsage(JSON.parse(`{${match[0]}}`));
|
|
150
|
+
result.inputTokens = Math.max(result.inputTokens, parsed.inputTokens);
|
|
151
|
+
result.outputTokens = Math.max(result.outputTokens, parsed.outputTokens);
|
|
152
|
+
result.cacheReadTokens = Math.max(result.cacheReadTokens, parsed.cacheReadTokens);
|
|
153
|
+
result.cacheWriteTokens = Math.max(result.cacheWriteTokens, parsed.cacheWriteTokens);
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore malformed fragments
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** True when the probe returned but the prefix cache did not serve it. */
|
|
162
|
+
export function isCacheMiss(inputTokens: number, cacheReadTokens: number, minPromptTokens: number): boolean {
|
|
163
|
+
return cacheReadTokens === 0 && inputTokens >= minPromptTokens;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** USD saved by this probe hitting cache instead of a cold full-price read. */
|
|
167
|
+
export function estimateSavedUsd(cacheReadTokens: number, cost: Partial<CostPerM> | undefined): number {
|
|
168
|
+
if (!cost || cacheReadTokens <= 0) return 0;
|
|
169
|
+
const input = typeof cost.input === "number" ? cost.input : 0;
|
|
170
|
+
const cacheRead = typeof cost.cacheRead === "number" ? cost.cacheRead : 0;
|
|
171
|
+
const delta = input - cacheRead;
|
|
172
|
+
if (delta <= 0) return 0;
|
|
173
|
+
return (cacheReadTokens / 1_000_000) * delta;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Actual USD the probe itself is expected to cost (read + output pricing). */
|
|
177
|
+
export function estimateProbeSpendUsd(usage: ParsedUsage, cost: Partial<CostPerM> | undefined): number {
|
|
178
|
+
if (!cost) return 0;
|
|
179
|
+
const input = typeof cost.input === "number" ? cost.input : 0;
|
|
180
|
+
const outputP = typeof cost.output === "number" ? cost.output : 0;
|
|
181
|
+
const cacheRead = typeof cost.cacheRead === "number" ? cost.cacheRead : input;
|
|
182
|
+
const uncached = Math.max(0, usage.inputTokens - usage.cacheReadTokens);
|
|
183
|
+
const usd =
|
|
184
|
+
(usage.cacheReadTokens / 1_000_000) * cacheRead +
|
|
185
|
+
(uncached / 1_000_000) * input +
|
|
186
|
+
(usage.outputTokens / 1_000_000) * outputP;
|
|
187
|
+
return usd;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function hasPricing(cost: Partial<CostPerM> | undefined): boolean {
|
|
191
|
+
return Boolean(cost && typeof cost.input === "number" && cost.input > 0);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Parse "90s" | "4m" | "1h" | "2.5m" | bare minutes. Returns ms or null. */
|
|
195
|
+
export function parseDurationMs(raw: string): number | null {
|
|
196
|
+
const text = raw.trim().toLowerCase();
|
|
197
|
+
if (text.length === 0) return null;
|
|
198
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
199
|
+
if (!match) return null;
|
|
200
|
+
const value = Number(match[1]);
|
|
201
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
202
|
+
switch (match[2]) {
|
|
203
|
+
case "ms": return Math.round(value);
|
|
204
|
+
case "s": return Math.round(value * 1000);
|
|
205
|
+
case "m": return Math.round(value * 60_000);
|
|
206
|
+
case "h": return Math.round(value * 3_600_000);
|
|
207
|
+
case undefined: return Math.round(value * 60_000); // bare number = minutes
|
|
208
|
+
default: return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Parse "$1.5" | "1.5" into USD, or null. */
|
|
213
|
+
export function parseUsd(raw: string): number | null {
|
|
214
|
+
const text = raw.trim().replace(/^\$/, "");
|
|
215
|
+
if (!/^\d+(?:\.\d+)?$/.test(text))
|
|
216
|
+
return null;
|
|
217
|
+
const value = Number(text);
|
|
218
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** "3m12s" style formatting. */
|
|
222
|
+
export function formatDuration(ms: number): string {
|
|
223
|
+
if (!Number.isFinite(ms) || ms < 0) return "0s";
|
|
224
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
225
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
226
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
227
|
+
const seconds = totalSeconds % 60;
|
|
228
|
+
if (hours > 0) return `${hours}h${minutes}m`;
|
|
229
|
+
if (minutes > 0) return `${minutes}m${seconds}s`;
|
|
230
|
+
return `${seconds}s`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function formatUsd(usd: number): string {
|
|
234
|
+
const abs = Math.abs(usd);
|
|
235
|
+
if (abs >= 1) return `$${usd.toFixed(2)}`;
|
|
236
|
+
if (abs === 0) return "$0.00";
|
|
237
|
+
return `$${usd.toFixed(4)}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function formatClock(ts: number | null): string {
|
|
241
|
+
if (ts === null) return "--:--:--";
|
|
242
|
+
const date = new Date(ts);
|
|
243
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
244
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
245
|
+
}
|