@ashlr/stack-core 0.1.1
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 +57 -0
- package/package.json +57 -0
- package/src/__tests__/_harness.ts +146 -0
- package/src/__tests__/api-key-providers.test.ts +237 -0
- package/src/__tests__/catalog-index.test.ts +103 -0
- package/src/__tests__/circuit-breaker.test.ts +112 -0
- package/src/__tests__/clone-url-validation.test.ts +47 -0
- package/src/__tests__/cloudflare-provider.test.ts +74 -0
- package/src/__tests__/completion-ci.test.ts +62 -0
- package/src/__tests__/config-split.test.ts +138 -0
- package/src/__tests__/config.test.ts +66 -0
- package/src/__tests__/deps.test.ts +58 -0
- package/src/__tests__/detect-source.test.ts +147 -0
- package/src/__tests__/detect.test.ts +67 -0
- package/src/__tests__/github-provider.test.ts +65 -0
- package/src/__tests__/helpers.test.ts +38 -0
- package/src/__tests__/http.test.ts +235 -0
- package/src/__tests__/import-integration.test.ts +87 -0
- package/src/__tests__/inference.test.ts +326 -0
- package/src/__tests__/neon-provider.test.ts +79 -0
- package/src/__tests__/openai-provider.test.ts +54 -0
- package/src/__tests__/phantom-harness.test.ts +44 -0
- package/src/__tests__/phantom-wire.test.ts +85 -0
- package/src/__tests__/pipeline-integration.test.ts +95 -0
- package/src/__tests__/pipeline-recovery.test.ts +106 -0
- package/src/__tests__/pipeline.test.ts +92 -0
- package/src/__tests__/recipe.test.ts +164 -0
- package/src/__tests__/recommend-apply-e2e.test.ts +222 -0
- package/src/__tests__/registry.test.ts +78 -0
- package/src/__tests__/secret-redaction.test.ts +170 -0
- package/src/__tests__/security.test.ts +80 -0
- package/src/__tests__/supabase-provider.test.ts +108 -0
- package/src/__tests__/templates.test.ts +26 -0
- package/src/__tests__/turso-provider.test.ts +71 -0
- package/src/ai/catalog-index.ts +256 -0
- package/src/ai/circuit-breaker.ts +116 -0
- package/src/ai/cost-tracker.ts +103 -0
- package/src/ai/inference.ts +470 -0
- package/src/ai/phantom-wire.ts +184 -0
- package/src/ai/recipe.ts +178 -0
- package/src/catalog.ts +430 -0
- package/src/config.ts +293 -0
- package/src/detect-source.ts +344 -0
- package/src/detect.ts +207 -0
- package/src/env.ts +35 -0
- package/src/errors.ts +29 -0
- package/src/http.ts +212 -0
- package/src/index.ts +61 -0
- package/src/mcp-writer.ts +50 -0
- package/src/oauth.ts +184 -0
- package/src/phantom.ts +156 -0
- package/src/pipeline.ts +131 -0
- package/src/providers/_api-key.ts +122 -0
- package/src/providers/_base.ts +96 -0
- package/src/providers/_helpers.ts +81 -0
- package/src/providers/anthropic.ts +27 -0
- package/src/providers/aws.ts +198 -0
- package/src/providers/braintrust.ts +33 -0
- package/src/providers/clerk.ts +23 -0
- package/src/providers/cloudflare.ts +146 -0
- package/src/providers/convex.ts +28 -0
- package/src/providers/deepseek.ts +24 -0
- package/src/providers/firebase.ts +35 -0
- package/src/providers/fly.ts +24 -0
- package/src/providers/github.ts +180 -0
- package/src/providers/index.ts +59 -0
- package/src/providers/linear.ts +46 -0
- package/src/providers/mailgun.ts +26 -0
- package/src/providers/modal.ts +27 -0
- package/src/providers/neon.ts +175 -0
- package/src/providers/openai.ts +24 -0
- package/src/providers/posthog.ts +30 -0
- package/src/providers/postmark.ts +27 -0
- package/src/providers/railway.ts +35 -0
- package/src/providers/render.ts +31 -0
- package/src/providers/replicate.ts +30 -0
- package/src/providers/resend.ts +24 -0
- package/src/providers/sendgrid.ts +24 -0
- package/src/providers/sentry.ts +183 -0
- package/src/providers/stripe.ts +33 -0
- package/src/providers/supabase.ts +309 -0
- package/src/providers/turso.ts +197 -0
- package/src/providers/upstash.ts +32 -0
- package/src/providers/vercel.ts +134 -0
- package/src/providers/xai.ts +24 -0
- package/src/registry.ts +114 -0
- package/src/templates.ts +39 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight retrieval index over the provider catalog.
|
|
3
|
+
*
|
|
4
|
+
* Given a free-text query (e.g. "B2B SaaS with auth + payments + AI"), return
|
|
5
|
+
* the most relevant providers ranked by a BM25-like score over weighted fields.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately zero-dep — 27 curated providers is small enough that a naive
|
|
8
|
+
* in-memory scorer is faster and simpler than bringing in an embedding store.
|
|
9
|
+
* If relevance quality becomes a bottleneck, swap `scoreProvider` for a
|
|
10
|
+
* vector-based retriever; the index/retrieve surface stays the same.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { PROVIDERS_REF, type ProviderRef } from "../catalog.ts";
|
|
14
|
+
|
|
15
|
+
export interface RetrievalHit {
|
|
16
|
+
provider: ProviderRef;
|
|
17
|
+
score: number;
|
|
18
|
+
/** Matched query terms (after normalization + synonym expansion). */
|
|
19
|
+
matched: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const FIELD_WEIGHTS = {
|
|
23
|
+
name: 4,
|
|
24
|
+
displayName: 3.5,
|
|
25
|
+
blurb: 2.2,
|
|
26
|
+
category: 2.0,
|
|
27
|
+
secrets: 0.8,
|
|
28
|
+
mcp: 1,
|
|
29
|
+
howTo: 0.6,
|
|
30
|
+
notes: 0.5,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
type FieldName = keyof typeof FIELD_WEIGHTS;
|
|
34
|
+
|
|
35
|
+
const STOPWORDS = new Set([
|
|
36
|
+
"a", "an", "and", "are", "as", "at", "be", "but", "by", "for", "from",
|
|
37
|
+
"has", "have", "he", "i", "if", "in", "into", "is", "it", "its", "me",
|
|
38
|
+
"my", "need", "of", "on", "or", "some", "such", "that", "the", "their",
|
|
39
|
+
"then", "there", "these", "they", "this", "to", "us", "want", "was",
|
|
40
|
+
"we", "were", "will", "with", "you", "your", "build", "building", "make",
|
|
41
|
+
"making", "use", "using", "app", "apps", "application", "project",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Expand common dev synonyms so everyday phrasing matches curated metadata.
|
|
46
|
+
* Keep this list tight — false positives are worse than a missed synonym
|
|
47
|
+
* because they drown out the correct provider.
|
|
48
|
+
*/
|
|
49
|
+
const SYNONYMS: Record<string, string[]> = {
|
|
50
|
+
db: ["database"],
|
|
51
|
+
dbs: ["database"],
|
|
52
|
+
postgres: ["database", "postgres", "sql"],
|
|
53
|
+
postgresql: ["database", "postgres", "sql"],
|
|
54
|
+
sqlite: ["database", "sqlite"],
|
|
55
|
+
mysql: ["database", "sql"],
|
|
56
|
+
redis: ["database", "cache"],
|
|
57
|
+
cache: ["database", "cache", "redis"],
|
|
58
|
+
authentication: ["auth"],
|
|
59
|
+
login: ["auth"],
|
|
60
|
+
users: ["auth"],
|
|
61
|
+
payment: ["payments", "billing"],
|
|
62
|
+
billing: ["payments", "billing"],
|
|
63
|
+
subscription: ["payments", "billing"],
|
|
64
|
+
subscriptions: ["payments", "billing"],
|
|
65
|
+
checkout: ["payments"],
|
|
66
|
+
llm: ["ai"],
|
|
67
|
+
llms: ["ai"],
|
|
68
|
+
model: ["ai"],
|
|
69
|
+
models: ["ai"],
|
|
70
|
+
chatbot: ["ai"],
|
|
71
|
+
agent: ["ai"],
|
|
72
|
+
agents: ["ai"],
|
|
73
|
+
gpt: ["ai", "openai"],
|
|
74
|
+
claude: ["ai", "anthropic"],
|
|
75
|
+
grok: ["ai", "xai"],
|
|
76
|
+
frontend: ["deploy"],
|
|
77
|
+
hosting: ["deploy"],
|
|
78
|
+
host: ["deploy"],
|
|
79
|
+
deploy: ["deploy"],
|
|
80
|
+
deployment: ["deploy"],
|
|
81
|
+
monitoring: ["errors"],
|
|
82
|
+
monitor: ["errors"],
|
|
83
|
+
observability: ["errors", "analytics"],
|
|
84
|
+
logs: ["errors"],
|
|
85
|
+
tracking: ["errors", "analytics"],
|
|
86
|
+
metrics: ["analytics"],
|
|
87
|
+
events: ["analytics"],
|
|
88
|
+
ticket: ["tickets"],
|
|
89
|
+
tickets: ["tickets"],
|
|
90
|
+
issues: ["tickets"],
|
|
91
|
+
issue: ["tickets"],
|
|
92
|
+
email: ["email"],
|
|
93
|
+
transactional: ["email"],
|
|
94
|
+
mail: ["email"],
|
|
95
|
+
edge: ["edge", "deploy"],
|
|
96
|
+
serverless: ["serverless", "deploy"],
|
|
97
|
+
kv: ["database"],
|
|
98
|
+
saas: [],
|
|
99
|
+
b2b: [],
|
|
100
|
+
b2c: [],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function tokenize(text: string): string[] {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
106
|
+
if (!raw || raw.length < 2) continue;
|
|
107
|
+
if (STOPWORDS.has(raw)) continue;
|
|
108
|
+
out.push(raw);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function expandQuery(tokens: string[]): string[] {
|
|
114
|
+
const expanded = new Set<string>(tokens);
|
|
115
|
+
for (const tok of tokens) {
|
|
116
|
+
const syns = SYNONYMS[tok];
|
|
117
|
+
if (!syns) continue;
|
|
118
|
+
for (const s of syns) expanded.add(s);
|
|
119
|
+
}
|
|
120
|
+
return [...expanded];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface IndexedProvider {
|
|
124
|
+
provider: ProviderRef;
|
|
125
|
+
fields: Record<FieldName, string[]>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function indexProvider(p: ProviderRef): IndexedProvider {
|
|
129
|
+
const mcpText = p.mcp ? `${p.mcp.name} ${p.mcp.detail}` : "";
|
|
130
|
+
return {
|
|
131
|
+
provider: p,
|
|
132
|
+
fields: {
|
|
133
|
+
name: tokenize(p.name),
|
|
134
|
+
displayName: tokenize(p.displayName),
|
|
135
|
+
category: tokenize(p.category),
|
|
136
|
+
secrets: p.secrets.flatMap(tokenize),
|
|
137
|
+
blurb: tokenize(p.blurb),
|
|
138
|
+
mcp: tokenize(mcpText),
|
|
139
|
+
howTo: tokenize(p.howTo),
|
|
140
|
+
notes: tokenize(p.notes ?? ""),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let INDEX_CACHE: IndexedProvider[] | null = null;
|
|
146
|
+
let IDF_CACHE: Map<string, number> | null = null;
|
|
147
|
+
|
|
148
|
+
function getIndex(): IndexedProvider[] {
|
|
149
|
+
if (!INDEX_CACHE) {
|
|
150
|
+
INDEX_CACHE = PROVIDERS_REF.map(indexProvider);
|
|
151
|
+
IDF_CACHE = buildIdf(INDEX_CACHE);
|
|
152
|
+
}
|
|
153
|
+
return INDEX_CACHE;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function buildIdf(index: IndexedProvider[]): Map<string, number> {
|
|
157
|
+
const N = index.length;
|
|
158
|
+
const df = new Map<string, number>();
|
|
159
|
+
for (const idx of index) {
|
|
160
|
+
const seen = new Set<string>();
|
|
161
|
+
for (const field of Object.keys(FIELD_WEIGHTS) as FieldName[]) {
|
|
162
|
+
for (const t of idx.fields[field]) seen.add(t);
|
|
163
|
+
}
|
|
164
|
+
for (const t of seen) df.set(t, (df.get(t) ?? 0) + 1);
|
|
165
|
+
}
|
|
166
|
+
const idfMap = new Map<string, number>();
|
|
167
|
+
for (const [term, n] of df) {
|
|
168
|
+
// Smoothed IDF (classic BM25): rare terms get big weight, common terms ≈ 0.
|
|
169
|
+
idfMap.set(term, Math.log(1 + (N - n + 0.5) / (n + 0.5)));
|
|
170
|
+
}
|
|
171
|
+
return idfMap;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function idf(term: string): number {
|
|
175
|
+
// Default ≈ IDF of a term appearing in 1 doc out of N — unknown terms still
|
|
176
|
+
// contribute, but less than a known rare term would.
|
|
177
|
+
const N = PROVIDERS_REF.length;
|
|
178
|
+
return IDF_CACHE?.get(term) ?? Math.log(1 + (N + 0.5) / 1.5);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function scoreProvider(idx: IndexedProvider, qTokens: string[]): { score: number; matched: string[] } {
|
|
182
|
+
let score = 0;
|
|
183
|
+
const matched = new Set<string>();
|
|
184
|
+
for (const token of qTokens) {
|
|
185
|
+
const tokenIdf = idf(token);
|
|
186
|
+
for (const field of Object.keys(FIELD_WEIGHTS) as FieldName[]) {
|
|
187
|
+
const terms = idx.fields[field];
|
|
188
|
+
let tf = 0;
|
|
189
|
+
for (const t of terms) if (t === token) tf += 1;
|
|
190
|
+
if (tf === 0) continue;
|
|
191
|
+
// Saturating TF: first hit weighted full, diminishing returns after.
|
|
192
|
+
const saturated = tf / (tf + 0.5);
|
|
193
|
+
score += FIELD_WEIGHTS[field] * saturated * tokenIdf;
|
|
194
|
+
matched.add(token);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { score, matched: [...matched] };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface RetrieveOptions {
|
|
201
|
+
/** Max results to return (default 6). */
|
|
202
|
+
k?: number;
|
|
203
|
+
/** Categories to restrict results to, e.g. ["Database", "Auth"]. */
|
|
204
|
+
categories?: ProviderRef["category"][];
|
|
205
|
+
/** Minimum score threshold (default 0.5 — below this we treat as non-match). */
|
|
206
|
+
minScore?: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Retrieve top-k relevant providers for a free-text query.
|
|
211
|
+
*
|
|
212
|
+
* Returns hits sorted by score descending. If no hits clear `minScore`, returns
|
|
213
|
+
* an empty array — callers should treat that as "no confident match" and
|
|
214
|
+
* either fall back to showing all providers or prompt the user to refine.
|
|
215
|
+
*/
|
|
216
|
+
export function retrieve(query: string, opts: RetrieveOptions = {}): RetrievalHit[] {
|
|
217
|
+
const k = opts.k ?? 6;
|
|
218
|
+
const minScore = opts.minScore ?? 0.5;
|
|
219
|
+
const rawTokens = tokenize(query);
|
|
220
|
+
const qTokens = expandQuery(rawTokens);
|
|
221
|
+
if (qTokens.length === 0) return [];
|
|
222
|
+
|
|
223
|
+
const candidates = opts.categories?.length
|
|
224
|
+
? getIndex().filter((idx) => opts.categories!.includes(idx.provider.category))
|
|
225
|
+
: getIndex();
|
|
226
|
+
|
|
227
|
+
const hits: RetrievalHit[] = [];
|
|
228
|
+
for (const idx of candidates) {
|
|
229
|
+
const { score, matched } = scoreProvider(idx, qTokens);
|
|
230
|
+
if (score >= minScore) {
|
|
231
|
+
hits.push({ provider: idx.provider, score, matched });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
hits.sort((a, b) => b.score - a.score);
|
|
236
|
+
return hits.slice(0, k);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Group the top hits by category. Handy for the MCP tool response shape —
|
|
241
|
+
* Claude can reason over "one from each category" instead of a flat list.
|
|
242
|
+
*/
|
|
243
|
+
export function retrieveByCategory(query: string, opts: RetrieveOptions = {}): Record<string, RetrievalHit[]> {
|
|
244
|
+
const hits = retrieve(query, { ...opts, k: opts.k ?? 20 });
|
|
245
|
+
const grouped: Record<string, RetrievalHit[]> = {};
|
|
246
|
+
for (const hit of hits) {
|
|
247
|
+
(grouped[hit.provider.category] ??= []).push(hit);
|
|
248
|
+
}
|
|
249
|
+
return grouped;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Reset the in-memory index. Used by tests. */
|
|
253
|
+
export function __resetIndex(): void {
|
|
254
|
+
INDEX_CACHE = null;
|
|
255
|
+
IDF_CACHE = null;
|
|
256
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal circuit breaker — stop hammering an inference backend after it
|
|
3
|
+
* racks up consecutive failures inside a rolling window.
|
|
4
|
+
*
|
|
5
|
+
* Zero deps. Shape adapted from `ashlrcode/src/providers/retry.ts` but
|
|
6
|
+
* trimmed: we only need a rolling failure window and three states.
|
|
7
|
+
*
|
|
8
|
+
* State machine:
|
|
9
|
+
* closed → normal traffic. Failures are counted inside `windowMs`.
|
|
10
|
+
* open → `canRequest()` returns false until `resetTimeMs` elapses
|
|
11
|
+
* since the last recorded failure.
|
|
12
|
+
* half-open → one probe is allowed. Success closes the circuit;
|
|
13
|
+
* failure re-opens it and restarts the cooldown.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
17
|
+
|
|
18
|
+
export interface CircuitBreakerOptions {
|
|
19
|
+
/** Consecutive failures in `windowMs` required to trip the breaker. */
|
|
20
|
+
threshold?: number;
|
|
21
|
+
/** Rolling window in ms for counting failures before tripping. */
|
|
22
|
+
windowMs?: number;
|
|
23
|
+
/** Ms to stay open before allowing a half-open probe. */
|
|
24
|
+
resetTimeMs?: number;
|
|
25
|
+
/** Override time source — tests pass a fake clock. */
|
|
26
|
+
now?: () => number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class CircuitBreaker {
|
|
30
|
+
private readonly threshold: number;
|
|
31
|
+
private readonly windowMs: number;
|
|
32
|
+
private readonly resetTimeMs: number;
|
|
33
|
+
private readonly now: () => number;
|
|
34
|
+
|
|
35
|
+
private state: CircuitState = "closed";
|
|
36
|
+
private failureTimes: number[] = [];
|
|
37
|
+
private lastFailureTime = 0;
|
|
38
|
+
private probeInFlight = false;
|
|
39
|
+
|
|
40
|
+
constructor(opts: CircuitBreakerOptions = {}) {
|
|
41
|
+
this.threshold = opts.threshold ?? 3;
|
|
42
|
+
this.windowMs = opts.windowMs ?? 60_000;
|
|
43
|
+
// Default reset matches the failure window: once we've stayed quiet for
|
|
44
|
+
// the window we probe again. Callers can override independently.
|
|
45
|
+
this.resetTimeMs = opts.resetTimeMs ?? this.windowMs;
|
|
46
|
+
this.now = opts.now ?? Date.now;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Returns true if a new request should be attempted. */
|
|
50
|
+
canRequest(): boolean {
|
|
51
|
+
if (this.state === "closed") return true;
|
|
52
|
+
|
|
53
|
+
if (this.state === "open") {
|
|
54
|
+
if (this.now() - this.lastFailureTime >= this.resetTimeMs) {
|
|
55
|
+
this.state = "half-open";
|
|
56
|
+
this.probeInFlight = true;
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// half-open: only one probe at a time.
|
|
63
|
+
if (this.probeInFlight) return false;
|
|
64
|
+
this.probeInFlight = true;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
recordSuccess(): void {
|
|
69
|
+
this.failureTimes = [];
|
|
70
|
+
this.state = "closed";
|
|
71
|
+
this.probeInFlight = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
recordFailure(): void {
|
|
75
|
+
const now = this.now();
|
|
76
|
+
this.probeInFlight = false;
|
|
77
|
+
this.lastFailureTime = now;
|
|
78
|
+
|
|
79
|
+
// Half-open failures re-open immediately without needing to re-hit
|
|
80
|
+
// the threshold — one bad probe is enough.
|
|
81
|
+
if (this.state === "half-open") {
|
|
82
|
+
this.state = "open";
|
|
83
|
+
this.failureTimes = [now];
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.failureTimes.push(now);
|
|
88
|
+
const cutoff = now - this.windowMs;
|
|
89
|
+
while (this.failureTimes.length && this.failureTimes[0]! < cutoff) {
|
|
90
|
+
this.failureTimes.shift();
|
|
91
|
+
}
|
|
92
|
+
if (this.failureTimes.length >= this.threshold) {
|
|
93
|
+
this.state = "open";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Observable state. Resolves time-based transitions lazily. */
|
|
98
|
+
getState(): CircuitState {
|
|
99
|
+
if (
|
|
100
|
+
this.state === "open" &&
|
|
101
|
+
this.now() - this.lastFailureTime >= this.resetTimeMs
|
|
102
|
+
) {
|
|
103
|
+
this.state = "half-open";
|
|
104
|
+
}
|
|
105
|
+
return this.state;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Current failure count inside the rolling window. */
|
|
109
|
+
getFailureCount(): number {
|
|
110
|
+
const cutoff = this.now() - this.windowMs;
|
|
111
|
+
while (this.failureTimes.length && this.failureTimes[0]! < cutoff) {
|
|
112
|
+
this.failureTimes.shift();
|
|
113
|
+
}
|
|
114
|
+
return this.failureTimes.length;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny cost tracker for inference backends.
|
|
3
|
+
*
|
|
4
|
+
* Stack only talks to local SLMs (LM Studio / Ollama) or delegates synth
|
|
5
|
+
* back to Claude over MCP — both cost $0 at the Stack layer. We still track
|
|
6
|
+
* token counts so the CLI can surface "you offloaded N tokens of synthesis
|
|
7
|
+
* to your laptop" and so future remote backends can plug in a non-zero
|
|
8
|
+
* rate card without touching callers.
|
|
9
|
+
*
|
|
10
|
+
* Rates are per-million tokens. Local backends register at [0, 0].
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface UsageRecord {
|
|
14
|
+
backend: string;
|
|
15
|
+
inputTokens: number;
|
|
16
|
+
outputTokens: number;
|
|
17
|
+
costUsd: number;
|
|
18
|
+
at: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CostSummary {
|
|
22
|
+
totalInputTokens: number;
|
|
23
|
+
totalOutputTokens: number;
|
|
24
|
+
totalCostUsd: number;
|
|
25
|
+
perBackend: Record<
|
|
26
|
+
string,
|
|
27
|
+
{ inputTokens: number; outputTokens: number; costUsd: number; calls: number }
|
|
28
|
+
>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Rate card entry — USD per 1M tokens. */
|
|
32
|
+
export interface RateCard {
|
|
33
|
+
inputPer1M: number;
|
|
34
|
+
outputPer1M: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class CostTracker {
|
|
38
|
+
private records: UsageRecord[] = [];
|
|
39
|
+
private rates = new Map<string, RateCard>();
|
|
40
|
+
|
|
41
|
+
/** Register per-1M-token pricing for a backend. Defaults to free. */
|
|
42
|
+
setRate(backend: string, rate: RateCard): void {
|
|
43
|
+
this.rates.set(backend, rate);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
recordUsage(
|
|
47
|
+
backend: string,
|
|
48
|
+
inputTokens: number,
|
|
49
|
+
outputTokens: number,
|
|
50
|
+
): UsageRecord {
|
|
51
|
+
const rate = this.rates.get(backend) ?? { inputPer1M: 0, outputPer1M: 0 };
|
|
52
|
+
const costUsd =
|
|
53
|
+
(inputTokens / 1_000_000) * rate.inputPer1M +
|
|
54
|
+
(outputTokens / 1_000_000) * rate.outputPer1M;
|
|
55
|
+
const entry: UsageRecord = {
|
|
56
|
+
backend,
|
|
57
|
+
inputTokens,
|
|
58
|
+
outputTokens,
|
|
59
|
+
costUsd,
|
|
60
|
+
at: Date.now(),
|
|
61
|
+
};
|
|
62
|
+
this.records.push(entry);
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getSummary(): CostSummary {
|
|
67
|
+
const summary: CostSummary = {
|
|
68
|
+
totalInputTokens: 0,
|
|
69
|
+
totalOutputTokens: 0,
|
|
70
|
+
totalCostUsd: 0,
|
|
71
|
+
perBackend: {},
|
|
72
|
+
};
|
|
73
|
+
for (const r of this.records) {
|
|
74
|
+
summary.totalInputTokens += r.inputTokens;
|
|
75
|
+
summary.totalOutputTokens += r.outputTokens;
|
|
76
|
+
summary.totalCostUsd += r.costUsd;
|
|
77
|
+
const slot =
|
|
78
|
+
summary.perBackend[r.backend] ??
|
|
79
|
+
(summary.perBackend[r.backend] = {
|
|
80
|
+
inputTokens: 0,
|
|
81
|
+
outputTokens: 0,
|
|
82
|
+
costUsd: 0,
|
|
83
|
+
calls: 0,
|
|
84
|
+
});
|
|
85
|
+
slot.inputTokens += r.inputTokens;
|
|
86
|
+
slot.outputTokens += r.outputTokens;
|
|
87
|
+
slot.costUsd += r.costUsd;
|
|
88
|
+
slot.calls += 1;
|
|
89
|
+
}
|
|
90
|
+
return summary;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Discard all recorded usage. */
|
|
94
|
+
reset(): void {
|
|
95
|
+
this.records = [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Module-level default tracker. Callers can either share this singleton or
|
|
101
|
+
* construct their own when isolation is needed (tests, nested invocations).
|
|
102
|
+
*/
|
|
103
|
+
export const defaultCostTracker = new CostTracker();
|