@danypops/jittor 0.5.1 → 0.6.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 +51 -7
- package/docs/BENCHMARK_SOURCES.md +32 -0
- package/docs/OUTPUT_CHANNELS.md +31 -0
- package/docs/PROVIDER_RESEARCH.md +38 -0
- package/extension/src/benchmark-tui.ts +103 -0
- package/extension/src/footer.ts +43 -3
- package/extension/src/index.ts +196 -33
- package/extension/src/tui.ts +32 -7
- package/extension/src/usage.ts +147 -38
- package/package.json +1 -1
- package/src/adapters/metric-benchmark-store.ts +99 -0
- package/src/adapters/openrouter-benchmark-index-source.ts +93 -0
- package/src/adapters/openrouter-benchmark-source.ts +109 -0
- package/src/cli.ts +531 -9
- package/src/constants.ts +38 -1
- package/src/daemon.ts +23 -2
- package/src/domain/benchmark.ts +264 -0
- package/src/domain/context-telemetry.ts +31 -0
- package/src/domain/metric.ts +46 -5
- package/src/domain/model-observation.ts +181 -0
- package/src/domain/model-ranking-service.ts +40 -0
- package/src/domain/model-ranking.ts +213 -0
- package/src/domain/usage.ts +84 -7
- package/src/ports/benchmark-controller.ts +11 -0
- package/src/ports/benchmark-source.ts +7 -0
- package/src/ports/benchmark-store.ts +7 -0
- package/src/ports/router-controller.ts +1 -0
- package/src/providers/anthropic-contracts.ts +116 -0
- package/src/providers/google-vertex-contracts.ts +104 -0
- package/src/router.ts +12 -0
- package/src/service.ts +60 -3
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT, MAX_DYNAMIC_ROUTES, MODEL_AGGREGATE_MAX_ROWS } from "../constants.ts";
|
|
2
|
+
import { normalizeModelIdentity, type BenchmarkObservation } from "./benchmark.ts";
|
|
3
|
+
import { TASK_CLASSES, type ModelMetricAggregate, type ModelTaskClass } from "./model-observation.ts";
|
|
4
|
+
|
|
5
|
+
export type ScopeAuthority = "exact-session" | "available-models";
|
|
6
|
+
export type UtilityComponentName = "quality" | "cost" | "latency" | "context" | "reliability";
|
|
7
|
+
|
|
8
|
+
export interface ModelCandidate {
|
|
9
|
+
provider: string;
|
|
10
|
+
model: string;
|
|
11
|
+
thinking: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UtilityWeights {
|
|
15
|
+
quality: number;
|
|
16
|
+
cost: number;
|
|
17
|
+
latency: number;
|
|
18
|
+
context: number;
|
|
19
|
+
reliability: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ModelRankingInput {
|
|
23
|
+
candidates: ModelCandidate[];
|
|
24
|
+
scopeAuthority: ScopeAuthority;
|
|
25
|
+
taskClass: ModelTaskClass;
|
|
26
|
+
budgetPressure: number;
|
|
27
|
+
weights: UtilityWeights;
|
|
28
|
+
externalEvidence: BenchmarkObservation[];
|
|
29
|
+
localEvidence: ModelMetricAggregate[];
|
|
30
|
+
now: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UtilityComponent {
|
|
34
|
+
name: UtilityComponentName;
|
|
35
|
+
score: number | null;
|
|
36
|
+
confidence: number;
|
|
37
|
+
weight: number;
|
|
38
|
+
evidenceCount: number;
|
|
39
|
+
reason: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RankingProvenance {
|
|
43
|
+
sourceId: string;
|
|
44
|
+
publisher: string;
|
|
45
|
+
url: string;
|
|
46
|
+
revision: string;
|
|
47
|
+
freshness: "fresh" | "stale";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface RankedModel {
|
|
51
|
+
candidate: ModelCandidate;
|
|
52
|
+
identity: string;
|
|
53
|
+
utility: number | null;
|
|
54
|
+
confidence: number;
|
|
55
|
+
components: UtilityComponent[];
|
|
56
|
+
provenance: RankingProvenance[];
|
|
57
|
+
trace: string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ModelRankingResult {
|
|
61
|
+
scopeAuthority: ScopeAuthority;
|
|
62
|
+
scopeWarning: string | null;
|
|
63
|
+
taskClass: ModelTaskClass;
|
|
64
|
+
completeness: "complete" | "partial" | "insufficient-evidence";
|
|
65
|
+
ranked: RankedModel[];
|
|
66
|
+
automaticSelection: ModelCandidate | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RawComponent {
|
|
70
|
+
value: number | null;
|
|
71
|
+
confidence: number;
|
|
72
|
+
evidenceCount: number;
|
|
73
|
+
reason: string;
|
|
74
|
+
lowerIsBetter: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const COMPONENTS: UtilityComponentName[] = ["quality", "cost", "latency", "context", "reliability"];
|
|
78
|
+
|
|
79
|
+
function finiteBound(value: number, name: string, minimum: number, maximum: number): number {
|
|
80
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) throw new Error(`${name} is outside its supported range`);
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function candidateIdentity(candidate: ModelCandidate): string {
|
|
85
|
+
const identity = normalizeModelIdentity(candidate.provider, candidate.model);
|
|
86
|
+
if (typeof candidate.thinking !== "string" || candidate.thinking.length === 0 || candidate.thinking.length > 160) throw new Error("candidate thinking level is invalid");
|
|
87
|
+
return `${identity.canonical}:${candidate.thinking}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function average(values: number[]): number {
|
|
91
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function externalValues(candidate: ModelCandidate, evidence: BenchmarkObservation[], dimensions: string[], now: number): { values: number[]; confidences: number[]; provenance: RankingProvenance[] } {
|
|
95
|
+
const identity = normalizeModelIdentity(candidate.provider, candidate.model);
|
|
96
|
+
const matching = evidence.filter((item) => (item.model.canonical === identity.canonical || item.model.aliases.includes(identity.canonical)) && dimensions.includes(item.dimension));
|
|
97
|
+
return {
|
|
98
|
+
values: matching.map((item) => item.value),
|
|
99
|
+
confidences: matching.map((item) => item.provenance.confidence * (now <= item.provenance.freshUntil ? 1 : 0.25)),
|
|
100
|
+
provenance: matching.map((item) => ({ sourceId: item.provenance.sourceId, publisher: item.provenance.publisher, url: item.provenance.url, revision: item.provenance.revision, freshness: now <= item.provenance.freshUntil ? "fresh" : "stale" })),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function localValues(candidate: ModelCandidate, taskClass: ModelTaskClass, evidence: ModelMetricAggregate[], dimension: string): ModelMetricAggregate[] {
|
|
105
|
+
const identity = normalizeModelIdentity(candidate.provider, candidate.model);
|
|
106
|
+
return evidence.filter((item) => item.provider === identity.provider && item.model === identity.model && item.thinking === candidate.thinking && item.taskClass === taskClass && item.dimension === dimension);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function rawComponents(candidate: ModelCandidate, input: ModelRankingInput): { components: Record<UtilityComponentName, RawComponent>; provenance: RankingProvenance[] } {
|
|
110
|
+
const quality = externalValues(candidate, input.externalEvidence, [`quality-${input.taskClass}`, "quality-general"], input.now);
|
|
111
|
+
const priceInput = externalValues(candidate, input.externalEvidence, ["price-input"], input.now);
|
|
112
|
+
const priceOutput = externalValues(candidate, input.externalEvidence, ["price-output"], input.now);
|
|
113
|
+
const measuredLatency = externalValues(candidate, input.externalEvidence, ["latency"], input.now);
|
|
114
|
+
const rankedLatency = externalValues(candidate, input.externalEvidence, ["latency-rank", "throughput-rank"], input.now);
|
|
115
|
+
const latency = measuredLatency.values.length > 0 ? measuredLatency : rankedLatency;
|
|
116
|
+
const context = externalValues(candidate, input.externalEvidence, ["context-window"], input.now);
|
|
117
|
+
const localLatency = localValues(candidate, input.taskClass, input.localEvidence, "wall-latency");
|
|
118
|
+
const failures = localValues(candidate, input.taskClass, input.localEvidence, "failure");
|
|
119
|
+
const outcomes = localValues(candidate, input.taskClass, input.localEvidence, "outcome-accepted");
|
|
120
|
+
const qualityValues = quality.values;
|
|
121
|
+
const prices = [...priceInput.values, ...priceOutput.values];
|
|
122
|
+
const latencyValues = localLatency.length > 0 ? localLatency.map((item) => item.median) : latency.values;
|
|
123
|
+
const latencyConfidences = localLatency.length > 0 ? localLatency.map((item) => item.confidence) : latency.confidences;
|
|
124
|
+
const reliabilityValues = [...failures.map((item) => 1 - item.median), ...outcomes.map((item) => item.median)];
|
|
125
|
+
const withEvidence = (values: number[], confidences: number[], lowerIsBetter: boolean, label: string): RawComponent => values.length === 0
|
|
126
|
+
? { value: null, confidence: 0, evidenceCount: 0, reason: `${label} evidence is missing`, lowerIsBetter }
|
|
127
|
+
: {
|
|
128
|
+
value: average(values),
|
|
129
|
+
confidence: average(confidences) / (1 + ((Math.max(...values) - Math.min(...values)) / Math.max(Math.abs(average(values)), Number.EPSILON))),
|
|
130
|
+
evidenceCount: values.length,
|
|
131
|
+
reason: `${values.length} ${label} observation${values.length === 1 ? "" : "s"}`,
|
|
132
|
+
lowerIsBetter,
|
|
133
|
+
};
|
|
134
|
+
const components: Record<UtilityComponentName, RawComponent> = {
|
|
135
|
+
quality: withEvidence(qualityValues, quality.confidences, false, "task quality"),
|
|
136
|
+
cost: withEvidence(prices, [...priceInput.confidences, ...priceOutput.confidences], true, "price"),
|
|
137
|
+
latency: withEvidence(latencyValues, latencyConfidences, true, "latency"),
|
|
138
|
+
context: withEvidence(context.values, context.confidences, false, "context window"),
|
|
139
|
+
reliability: withEvidence(reliabilityValues, [...failures, ...outcomes].map((item) => item.confidence), false, "local reliability"),
|
|
140
|
+
};
|
|
141
|
+
return { components, provenance: [...quality.provenance, ...priceInput.provenance, ...priceOutput.provenance, ...latency.provenance, ...context.provenance] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizedScore(value: number, values: number[], lowerIsBetter: boolean): number {
|
|
145
|
+
const minimum = Math.min(...values);
|
|
146
|
+
const maximum = Math.max(...values);
|
|
147
|
+
if (maximum === minimum) return 0.5;
|
|
148
|
+
const score = (value - minimum) / (maximum - minimum);
|
|
149
|
+
return lowerIsBetter ? 1 - score : score;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function rankModelCandidates(value: ModelRankingInput): ModelRankingResult {
|
|
153
|
+
if (!Array.isArray(value.candidates) || value.candidates.length === 0 || value.candidates.length > MAX_DYNAMIC_ROUTES) throw new Error("candidate count is outside its supported range");
|
|
154
|
+
if (!Array.isArray(value.externalEvidence) || value.externalEvidence.length > BENCHMARK_MAX_OBSERVATIONS_PER_SNAPSHOT * 4) throw new Error("external evidence exceeds the supported bound");
|
|
155
|
+
if (!Array.isArray(value.localEvidence) || value.localEvidence.length > MODEL_AGGREGATE_MAX_ROWS) throw new Error("local evidence exceeds the supported bound");
|
|
156
|
+
if (value.scopeAuthority !== "exact-session" && value.scopeAuthority !== "available-models") throw new Error("scope authority is invalid");
|
|
157
|
+
if (!TASK_CLASSES.includes(value.taskClass)) throw new Error("task class is invalid");
|
|
158
|
+
if (!Number.isSafeInteger(value.now) || value.now <= 0) throw new Error("ranking time is invalid");
|
|
159
|
+
const budgetPressure = finiteBound(value.budgetPressure, "budget pressure", 0, 2);
|
|
160
|
+
const weights = Object.fromEntries(COMPONENTS.map((name) => [name, finiteBound(value.weights[name], `${name} weight`, 0, 10)])) as unknown as UtilityWeights;
|
|
161
|
+
const seen = new Set<string>();
|
|
162
|
+
const candidates = value.candidates.map((candidate) => ({ ...candidate })).filter((candidate) => {
|
|
163
|
+
const identity = candidateIdentity(candidate);
|
|
164
|
+
if (seen.has(identity)) return false;
|
|
165
|
+
seen.add(identity);
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
const raw = candidates.map((candidate) => rawComponents(candidate, value));
|
|
169
|
+
const effectiveWeights: UtilityWeights = { ...weights, cost: weights.cost * (1 + budgetPressure) };
|
|
170
|
+
const ranked = candidates.map((candidate, index): RankedModel => {
|
|
171
|
+
const source = raw[index]!;
|
|
172
|
+
const components = COMPONENTS.map((name): UtilityComponent => {
|
|
173
|
+
const component = source.components[name];
|
|
174
|
+
const comparable = raw.map((item) => item.components[name].value).filter((item): item is number => item !== null);
|
|
175
|
+
return {
|
|
176
|
+
name,
|
|
177
|
+
score: component.value === null ? null : normalizedScore(component.value, comparable, component.lowerIsBetter),
|
|
178
|
+
confidence: component.confidence,
|
|
179
|
+
weight: effectiveWeights[name],
|
|
180
|
+
evidenceCount: component.evidenceCount,
|
|
181
|
+
reason: component.reason,
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
const known = components.filter((component): component is UtilityComponent & { score: number } => component.score !== null && component.weight > 0);
|
|
185
|
+
const knownWeight = known.reduce((sum, component) => sum + component.weight, 0);
|
|
186
|
+
const totalWeight = components.reduce((sum, component) => sum + component.weight, 0);
|
|
187
|
+
const utility = knownWeight === 0 ? null : known.reduce((sum, component) => sum + (component.score * component.weight), 0) / knownWeight;
|
|
188
|
+
const confidence = totalWeight === 0 ? 0 : known.reduce((sum, component) => sum + (component.confidence * component.weight), 0) / totalWeight;
|
|
189
|
+
const provenance = [...new Map(source.provenance.map((item) => [`${item.sourceId}:${item.revision}:${item.url}`, item])).values()]
|
|
190
|
+
.sort((left, right) => left.sourceId.localeCompare(right.sourceId) || left.revision.localeCompare(right.revision));
|
|
191
|
+
return {
|
|
192
|
+
candidate,
|
|
193
|
+
identity: candidateIdentity(candidate),
|
|
194
|
+
utility,
|
|
195
|
+
confidence,
|
|
196
|
+
components,
|
|
197
|
+
provenance,
|
|
198
|
+
trace: [`task class ${value.taskClass}`, `budget pressure ${budgetPressure.toFixed(3)} makes cost weight ${effectiveWeights.cost.toFixed(3)}`, `${known.length}/${components.length} utility components have evidence`, `scope authority ${value.scopeAuthority}`],
|
|
199
|
+
};
|
|
200
|
+
}).sort((left, right) => (right.utility ?? -1) - (left.utility ?? -1) || right.confidence - left.confidence || left.identity.localeCompare(right.identity));
|
|
201
|
+
const knownComponents = ranked.reduce((sum, item) => sum + item.components.filter((component) => component.score !== null).length, 0);
|
|
202
|
+
const possibleComponents = ranked.length * COMPONENTS.length;
|
|
203
|
+
const completeness = knownComponents === 0 ? "insufficient-evidence" : knownComponents === possibleComponents ? "complete" : "partial";
|
|
204
|
+
const exact = value.scopeAuthority === "exact-session";
|
|
205
|
+
return {
|
|
206
|
+
scopeAuthority: value.scopeAuthority,
|
|
207
|
+
scopeWarning: exact ? null : "Pi available models are not the exact session scope; automatic selection is disabled",
|
|
208
|
+
taskClass: value.taskClass,
|
|
209
|
+
completeness,
|
|
210
|
+
ranked,
|
|
211
|
+
automaticSelection: exact && ranked[0]?.utility !== null ? ranked[0]!.candidate : null,
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/domain/usage.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const USAGE_PERIODS = [
|
|
|
6
6
|
{ id: "daily", label: "Daily", windowMs: MILLISECONDS_PER_DAY, bucketCount: 24 },
|
|
7
7
|
{ id: "weekly", label: "Weekly", windowMs: 7 * MILLISECONDS_PER_DAY, bucketCount: 28 },
|
|
8
8
|
{ id: "monthly", label: "Monthly", windowMs: 30 * MILLISECONDS_PER_DAY, bucketCount: 30 },
|
|
9
|
+
{ id: "quarterly", label: "Quarterly", windowMs: 90 * MILLISECONDS_PER_DAY, bucketCount: 90 },
|
|
9
10
|
] as const;
|
|
10
11
|
export type UsagePeriod = typeof USAGE_PERIODS[number]["id"];
|
|
11
12
|
|
|
@@ -63,7 +64,7 @@ export function usagePeriodStart(period: UsagePeriod, now: number): number {
|
|
|
63
64
|
return Math.max(0, now - usagePeriod(period).windowMs);
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
|
|
67
|
+
export function identity(row: StoredMetricObservation): { key: string; provider: string; model: string } {
|
|
67
68
|
const separator = row.scope.indexOf(":");
|
|
68
69
|
const fallbackProvider = separator >= 0 ? row.scope.slice(0, separator) : row.scope;
|
|
69
70
|
const fallbackModel = separator >= 0 ? row.scope.slice(separator + 1) : "unknown";
|
|
@@ -72,18 +73,34 @@ function identity(row: StoredMetricObservation): { key: string; provider: string
|
|
|
72
73
|
return { key: `${provider}/${model}`, provider, model };
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
+
interface BucketWindow { start: number; end: number; bucketCount: number; bucketSize: number }
|
|
77
|
+
|
|
78
|
+
function bucketWindow(options: UsageGraphOptions): BucketWindow {
|
|
76
79
|
const end = options.now;
|
|
77
80
|
const start = usagePeriodStart(options.period, end);
|
|
78
81
|
const requestedBuckets = options.bucketCount ?? usagePeriod(options.period).bucketCount;
|
|
79
82
|
const bucketCount = Math.max(1, Math.min(MAX_USAGE_BUCKETS, Math.floor(requestedBuckets)));
|
|
80
83
|
const bucketSize = Math.max(1, (end - start) / bucketCount);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
return { start, end, bucketCount, bucketSize };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function emptyBuckets(window: BucketWindow): { start: number; end: number; total: number; series: Record<string, number> }[] {
|
|
88
|
+
return Array.from({ length: window.bucketCount }, (_, index) => ({
|
|
89
|
+
start: window.start + index * window.bucketSize,
|
|
90
|
+
end: index === window.bucketCount - 1 ? window.end : window.start + (index + 1) * window.bucketSize,
|
|
84
91
|
total: 0,
|
|
85
92
|
series: {},
|
|
86
93
|
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bucketIndexFor(observedAt: number, window: BucketWindow): number {
|
|
97
|
+
return Math.min(window.bucketCount - 1, Math.floor((observedAt - window.start) / window.bucketSize));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): UsageGraph {
|
|
101
|
+
const window = bucketWindow(options);
|
|
102
|
+
const { start, end } = window;
|
|
103
|
+
const buckets: UsageBucket[] = emptyBuckets(window);
|
|
87
104
|
const breakdown: UsageBreakdown = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
88
105
|
const identities = new Map<string, UsageSeries>();
|
|
89
106
|
|
|
@@ -91,8 +108,7 @@ export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageG
|
|
|
91
108
|
const breakdownKey = BREAKDOWN_KEYS[row.metric as keyof typeof BREAKDOWN_KEYS];
|
|
92
109
|
if (row.source !== "pi" || row.unit !== "tokens" || !breakdownKey || typeof row.value !== "number" || row.value < 0) continue;
|
|
93
110
|
if (row.observedAt < start || row.observedAt > end) continue;
|
|
94
|
-
const
|
|
95
|
-
const bucket = buckets[bucketIndex]!;
|
|
111
|
+
const bucket = buckets[bucketIndexFor(row.observedAt, window)]!;
|
|
96
112
|
const series = identity(row);
|
|
97
113
|
bucket.total += row.value;
|
|
98
114
|
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
|
|
@@ -114,3 +130,64 @@ export function buildUsageGraph(rows: StoredMetricObservation[], options: UsageG
|
|
|
114
130
|
truncated: options.truncated === true,
|
|
115
131
|
};
|
|
116
132
|
}
|
|
133
|
+
|
|
134
|
+
export interface CostSeries {
|
|
135
|
+
key: string;
|
|
136
|
+
provider: string;
|
|
137
|
+
model: string;
|
|
138
|
+
total: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface CostBucket {
|
|
142
|
+
start: number;
|
|
143
|
+
end: number;
|
|
144
|
+
total: number;
|
|
145
|
+
series: Record<string, number>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface CostGraph {
|
|
149
|
+
period: UsagePeriod;
|
|
150
|
+
start: number;
|
|
151
|
+
end: number;
|
|
152
|
+
buckets: CostBucket[];
|
|
153
|
+
series: CostSeries[];
|
|
154
|
+
totalUsd: number;
|
|
155
|
+
truncated: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Mirrors buildUsageGraph but for the "cost" (unit "usd") metric already recorded content-free on
|
|
160
|
+
* every finalized Pi assistant message (see assistantUsageMetrics), so this needs no new
|
|
161
|
+
* instrumentation. Aggregated spend by model/time is one of the metrics LLM usage dashboards
|
|
162
|
+
* surface from day one (alongside raw token counts), since token counts alone do not reflect that
|
|
163
|
+
* output tokens and premium models cost disproportionately more per token.
|
|
164
|
+
*/
|
|
165
|
+
export function buildCostGraph(rows: StoredMetricObservation[], options: UsageGraphOptions): CostGraph {
|
|
166
|
+
const window = bucketWindow(options);
|
|
167
|
+
const { start, end } = window;
|
|
168
|
+
const buckets: CostBucket[] = emptyBuckets(window);
|
|
169
|
+
const identities = new Map<string, CostSeries>();
|
|
170
|
+
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
if (row.source !== "pi" || row.unit !== "usd" || row.metric !== "cost" || typeof row.value !== "number" || row.value < 0) continue;
|
|
173
|
+
if (row.observedAt < start || row.observedAt > end) continue;
|
|
174
|
+
const bucket = buckets[bucketIndexFor(row.observedAt, window)]!;
|
|
175
|
+
const series = identity(row);
|
|
176
|
+
bucket.total += row.value;
|
|
177
|
+
bucket.series[series.key] = (bucket.series[series.key] ?? 0) + row.value;
|
|
178
|
+
const current = identities.get(series.key) ?? { ...series, total: 0 };
|
|
179
|
+
current.total += row.value;
|
|
180
|
+
identities.set(series.key, current);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const series = [...identities.values()].sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
184
|
+
return {
|
|
185
|
+
period: options.period,
|
|
186
|
+
start,
|
|
187
|
+
end,
|
|
188
|
+
buckets,
|
|
189
|
+
series,
|
|
190
|
+
totalUsd: buckets.reduce((sum, bucket) => sum + bucket.total, 0),
|
|
191
|
+
truncated: options.truncated === true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BenchmarkQuery,
|
|
3
|
+
BenchmarkQueryResult,
|
|
4
|
+
BenchmarkRefreshResult,
|
|
5
|
+
} from "../domain/benchmark.ts";
|
|
6
|
+
|
|
7
|
+
export interface BenchmarkController {
|
|
8
|
+
refresh(force?: boolean): Promise<BenchmarkRefreshResult>;
|
|
9
|
+
status(): BenchmarkRefreshResult;
|
|
10
|
+
query(input: BenchmarkQuery): BenchmarkQueryResult;
|
|
11
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BenchmarkSourceSnapshot } from "../domain/benchmark.ts";
|
|
2
|
+
|
|
3
|
+
/** External evidence boundary. Implementations validate one source-specific schema. */
|
|
4
|
+
export interface BenchmarkSource {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
fetch(): Promise<BenchmarkSourceSnapshot>;
|
|
7
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BenchmarkObservation, BenchmarkSnapshot } from "../domain/benchmark.ts";
|
|
2
|
+
|
|
3
|
+
/** Immutable evidence snapshot boundary. Only complete snapshots become visible. */
|
|
4
|
+
export interface BenchmarkStore {
|
|
5
|
+
publish(sourceId: string, snapshotId: string, observations: BenchmarkObservation[]): BenchmarkSnapshot;
|
|
6
|
+
latest(sourceId: string): BenchmarkSnapshot | null;
|
|
7
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Official Anthropic Messages API rate-limit response headers, verified against
|
|
5
|
+
* https://platform.claude.com/docs/en/api/rate-limits (fetched 2026-07-21). These headers are
|
|
6
|
+
* returned on every Messages API response (per-request, not from a standalone polling endpoint —
|
|
7
|
+
* Anthropic's Admin/Rate Limits API is documented as "unavailable for individual accounts"), so
|
|
8
|
+
* Jittor observes them from Pi's own `after_provider_response` event rather than daemon-side
|
|
9
|
+
* polling. Priority Tier buckets are optional and only present for organizations enrolled in it.
|
|
10
|
+
*/
|
|
11
|
+
export interface AnthropicRateLimitWindow {
|
|
12
|
+
limit: number | null;
|
|
13
|
+
remaining: number | null;
|
|
14
|
+
resetsAt: number | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AnthropicRateLimitSnapshot {
|
|
18
|
+
requests: AnthropicRateLimitWindow | null;
|
|
19
|
+
tokens: AnthropicRateLimitWindow | null;
|
|
20
|
+
inputTokens: AnthropicRateLimitWindow | null;
|
|
21
|
+
outputTokens: AnthropicRateLimitWindow | null;
|
|
22
|
+
priorityInputTokens: AnthropicRateLimitWindow | null;
|
|
23
|
+
priorityOutputTokens: AnthropicRateLimitWindow | null;
|
|
24
|
+
retryAfterMs: number | null;
|
|
25
|
+
observedAt: number;
|
|
26
|
+
metrics: MetricObservation[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function metric(
|
|
30
|
+
scope: string,
|
|
31
|
+
name: string,
|
|
32
|
+
value: number,
|
|
33
|
+
unit: MetricObservation["unit"],
|
|
34
|
+
observedAt: number,
|
|
35
|
+
attributes: Record<string, unknown> = {},
|
|
36
|
+
): MetricObservation {
|
|
37
|
+
return { source: "anthropic", scope, metric: name, value, unit, observedAt, attributes };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function headerInteger(headers: Headers, name: string): number | null {
|
|
41
|
+
const raw = headers.get(name);
|
|
42
|
+
if (raw === null) return null;
|
|
43
|
+
const value = Number(raw);
|
|
44
|
+
if (!Number.isFinite(value) || !Number.isSafeInteger(value) || value < 0) throw new Error(`Anthropic rate-limit header schema changed: ${name}`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function headerResetTime(headers: Headers, name: string): number | null {
|
|
49
|
+
const raw = headers.get(name);
|
|
50
|
+
if (raw === null) return null;
|
|
51
|
+
const parsed = Date.parse(raw);
|
|
52
|
+
if (!Number.isFinite(parsed)) throw new Error(`Anthropic rate-limit header schema changed: ${name} is not RFC 3339`);
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseWindow(headers: Headers, prefix: string): AnthropicRateLimitWindow | null {
|
|
57
|
+
const limit = headerInteger(headers, `${prefix}-limit`);
|
|
58
|
+
const remaining = headerInteger(headers, `${prefix}-remaining`);
|
|
59
|
+
const resetsAt = headerResetTime(headers, `${prefix}-reset`);
|
|
60
|
+
if (limit === null && remaining === null && resetsAt === null) return null;
|
|
61
|
+
return { limit, remaining, resetsAt };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function windowMetrics(scope: string, window: AnthropicRateLimitWindow | null, observedAt: number): MetricObservation[] {
|
|
65
|
+
if (!window) return [];
|
|
66
|
+
const metrics: MetricObservation[] = [];
|
|
67
|
+
const attributes = { limit: window.limit, remaining: window.remaining, resetsAt: window.resetsAt };
|
|
68
|
+
if (window.limit !== null && window.limit > 0 && window.remaining !== null) {
|
|
69
|
+
const remainingFraction = window.remaining / window.limit;
|
|
70
|
+
if (remainingFraction < 0 || remainingFraction > 1) throw new Error(`Anthropic ${scope} remaining exceeds its configured limit`);
|
|
71
|
+
metrics.push(metric(scope, "remaining-fraction", remainingFraction, "ratio", observedAt, attributes));
|
|
72
|
+
metrics.push(metric(scope, "used-fraction", 1 - remainingFraction, "ratio", observedAt, attributes));
|
|
73
|
+
}
|
|
74
|
+
return metrics;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function parseAnthropicRateLimitHeaders(headers: Headers, observedAt = Date.now()): AnthropicRateLimitSnapshot {
|
|
78
|
+
const requests = parseWindow(headers, "anthropic-ratelimit-requests");
|
|
79
|
+
const tokens = parseWindow(headers, "anthropic-ratelimit-tokens");
|
|
80
|
+
const inputTokens = parseWindow(headers, "anthropic-ratelimit-input-tokens");
|
|
81
|
+
const outputTokens = parseWindow(headers, "anthropic-ratelimit-output-tokens");
|
|
82
|
+
const priorityInputTokens = parseWindow(headers, "anthropic-priority-input-tokens");
|
|
83
|
+
const priorityOutputTokens = parseWindow(headers, "anthropic-priority-output-tokens");
|
|
84
|
+
const retryAfterRaw = headers.get("retry-after");
|
|
85
|
+
const retryAfterSeconds = retryAfterRaw === null ? null : Number(retryAfterRaw);
|
|
86
|
+
if (retryAfterRaw !== null && (!Number.isFinite(retryAfterSeconds) || (retryAfterSeconds as number) < 0)) {
|
|
87
|
+
throw new Error("Anthropic rate-limit header schema changed: retry-after");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
requests,
|
|
91
|
+
tokens,
|
|
92
|
+
inputTokens,
|
|
93
|
+
outputTokens,
|
|
94
|
+
priorityInputTokens,
|
|
95
|
+
priorityOutputTokens,
|
|
96
|
+
retryAfterMs: retryAfterSeconds === null ? null : Math.round(retryAfterSeconds * 1_000),
|
|
97
|
+
observedAt,
|
|
98
|
+
metrics: [
|
|
99
|
+
...windowMetrics("requests", requests, observedAt),
|
|
100
|
+
...windowMetrics("tokens", tokens, observedAt),
|
|
101
|
+
...windowMetrics("input-tokens", inputTokens, observedAt),
|
|
102
|
+
...windowMetrics("output-tokens", outputTokens, observedAt),
|
|
103
|
+
...windowMetrics("priority-input-tokens", priorityInputTokens, observedAt),
|
|
104
|
+
...windowMetrics("priority-output-tokens", priorityOutputTokens, observedAt),
|
|
105
|
+
],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** True when the response carries at least one recognized Anthropic rate-limit header. */
|
|
110
|
+
export function hasAnthropicRateLimitHeaders(headers: Headers): boolean {
|
|
111
|
+
for (const name of headers.keys()) {
|
|
112
|
+
const lower = name.toLowerCase();
|
|
113
|
+
if (lower.startsWith("anthropic-ratelimit-") || lower.startsWith("anthropic-priority-")) return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { MetricObservation } from "../domain/metric.ts";
|
|
2
|
+
import { MILLISECONDS_PER_MINUTE, MILLISECONDS_PER_SECOND } from "../constants.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Google Vertex AI has no documented per-response rate-limit or remaining-quota header, and no
|
|
6
|
+
* personal polling endpoint Jittor could daemon-poll: quota lives in AWS/GCP-style account-level
|
|
7
|
+
* Service Usage configuration, and errors surface as a `google.rpc.Status` shape
|
|
8
|
+
* (`{error: {code, message, status, details[]}}`, `status` one of the canonical gRPC codes such as
|
|
9
|
+
* `RESOURCE_EXHAUSTED`, `PERMISSION_DENIED`, `UNAVAILABLE`) rather than a header Jittor can read
|
|
10
|
+
* before a request fails (verified against Google Cloud/Gemini API error reports fetched
|
|
11
|
+
* 2026-07-21; no `x-goog-quota-*` or equivalent response header is documented for Vertex
|
|
12
|
+
* generateContent). Jittor therefore does not fabricate a remaining-budget bar for this provider.
|
|
13
|
+
* What it can honestly observe is classified failure pressure: how often and what kind of
|
|
14
|
+
* capacity/auth/request failures Pi is seeing, from the same bounded, content-free
|
|
15
|
+
* `errorMessage` string Pi already exposes for every provider (see classifyCodexFailure for the
|
|
16
|
+
* established pattern this mirrors).
|
|
17
|
+
*/
|
|
18
|
+
export type GoogleVertexFailureKind =
|
|
19
|
+
| "quota"
|
|
20
|
+
| "authentication"
|
|
21
|
+
| "invalid-request"
|
|
22
|
+
| "overload"
|
|
23
|
+
| "transport"
|
|
24
|
+
| "unknown";
|
|
25
|
+
|
|
26
|
+
export interface GoogleVertexFailure {
|
|
27
|
+
kind: GoogleVertexFailureKind;
|
|
28
|
+
transient: boolean;
|
|
29
|
+
status?: string;
|
|
30
|
+
code?: number;
|
|
31
|
+
message?: string;
|
|
32
|
+
retryAfterMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GoogleVertexFailureMetadata {
|
|
36
|
+
status?: number;
|
|
37
|
+
retryAfter?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GOOGLE_VERTEX_ERROR_MESSAGE_LIMIT = 160;
|
|
41
|
+
const GOOGLE_VERTEX_RETRY_AFTER_MAX_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
42
|
+
|
|
43
|
+
function matches(value: string, patterns: readonly string[]): boolean {
|
|
44
|
+
return patterns.some((pattern) => value.includes(pattern));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function retryAfterMs(value: string | undefined): number | undefined {
|
|
48
|
+
if (!value) return undefined;
|
|
49
|
+
const seconds = Number(value.trim().replace(/s$/i, ""));
|
|
50
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined;
|
|
51
|
+
return Math.min(GOOGLE_VERTEX_RETRY_AFTER_MAX_MS, Math.round(seconds * MILLISECONDS_PER_SECOND));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extracts a `google.rpc.RetryInfo.retryDelay` (e.g. `"16s"`) if the stringified error embeds one. */
|
|
55
|
+
function embeddedRetryDelay(evidence: string): string | undefined {
|
|
56
|
+
const match = evidence.match(/"retrydelay"\s*:\s*"(\d+(?:\.\d+)?s)"/);
|
|
57
|
+
return match?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function classifyGoogleVertexFailure(value: unknown, metadata: GoogleVertexFailureMetadata = {}): GoogleVertexFailure {
|
|
61
|
+
const rawMessage = typeof value === "string" ? value : undefined;
|
|
62
|
+
const message = rawMessage?.slice(0, GOOGLE_VERTEX_ERROR_MESSAGE_LIMIT);
|
|
63
|
+
const evidence = (rawMessage ?? "").toLowerCase();
|
|
64
|
+
const retry = retryAfterMs(metadata.retryAfter) ?? retryAfterMs(embeddedRetryDelay(evidence));
|
|
65
|
+
const base = {
|
|
66
|
+
...(message ? { message } : {}),
|
|
67
|
+
...(metadata.status !== undefined ? { code: metadata.status } : {}),
|
|
68
|
+
...(retry !== undefined ? { retryAfterMs: retry } : {}),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (matches(evidence, ["resource_exhausted", "quota"]) || metadata.status === 429) {
|
|
72
|
+
return { kind: "quota", transient: true, status: "RESOURCE_EXHAUSTED", ...base };
|
|
73
|
+
}
|
|
74
|
+
if (matches(evidence, ["unauthenticated", "permission_denied"]) || metadata.status === 401 || metadata.status === 403) {
|
|
75
|
+
return { kind: "authentication", transient: false, status: matches(evidence, ["unauthenticated"]) ? "UNAUTHENTICATED" : "PERMISSION_DENIED", ...base };
|
|
76
|
+
}
|
|
77
|
+
if (matches(evidence, ["invalid_argument", "failed_precondition", "out_of_range"]) || metadata.status === 400 || metadata.status === 422) {
|
|
78
|
+
return { kind: "invalid-request", transient: false, status: "INVALID_ARGUMENT", ...base };
|
|
79
|
+
}
|
|
80
|
+
if (matches(evidence, ["unavailable", "internal", "aborted"]) || (metadata.status !== undefined && metadata.status >= 500 && metadata.status <= 599)) {
|
|
81
|
+
return { kind: "overload", transient: true, status: "UNAVAILABLE", ...base };
|
|
82
|
+
}
|
|
83
|
+
if (matches(evidence, ["deadline_exceeded", "timeout", "timed out", "network", "connection", "fetch failed", "cancelled"])) {
|
|
84
|
+
return { kind: "transport", transient: true, status: "DEADLINE_EXCEEDED", ...base };
|
|
85
|
+
}
|
|
86
|
+
return { kind: "unknown", transient: false, ...base };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A bounded failure-count observation; never a fabricated remaining-budget fraction. */
|
|
90
|
+
export function googleVertexFailureMetrics(failure: GoogleVertexFailure, observedAt: number): MetricObservation[] {
|
|
91
|
+
return [{
|
|
92
|
+
source: "google-vertex",
|
|
93
|
+
scope: "failure",
|
|
94
|
+
metric: failure.kind,
|
|
95
|
+
value: 1,
|
|
96
|
+
unit: "count",
|
|
97
|
+
observedAt,
|
|
98
|
+
attributes: {
|
|
99
|
+
transient: failure.transient,
|
|
100
|
+
...(failure.status ? { status: failure.status } : {}),
|
|
101
|
+
...(failure.code !== undefined ? { code: failure.code } : {}),
|
|
102
|
+
},
|
|
103
|
+
}];
|
|
104
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -120,6 +120,18 @@ export class JittorRouter implements RouterController {
|
|
|
120
120
|
return this.status();
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
applyModelRanking(candidates: Route[]): RouterStatus {
|
|
124
|
+
if (!Array.isArray(candidates) || candidates.length === 0) throw new Error("model ranking must contain candidates");
|
|
125
|
+
const ranked = candidates
|
|
126
|
+
.filter((candidate, index) => candidates.findIndex((other) => sameRoute(other, candidate)) === index)
|
|
127
|
+
.map((candidate) => this.availableRoutes.find((route) => sameRoute(route, candidate)))
|
|
128
|
+
.filter((route): route is Route => route !== undefined);
|
|
129
|
+
const current = ranked.find((route) => sameRoute(route, this.currentRoute));
|
|
130
|
+
if (!current) throw new Error("model ranking does not contain the current available route");
|
|
131
|
+
this.availableRoutes = [structuredClone(current), ...ranked.filter((route) => !sameRoute(route, current)).map((route) => structuredClone(route))];
|
|
132
|
+
return this.status();
|
|
133
|
+
}
|
|
134
|
+
|
|
123
135
|
private async runPoll(): Promise<TelemetryPollResult> {
|
|
124
136
|
const statuses = await Promise.all(this.options.sources.map(async (source): Promise<TelemetrySourceStatus> => {
|
|
125
137
|
try {
|