@kontextmind/kxm 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/.claude-plugin/marketplace.json +19 -0
- package/.kxm/README.md +14 -0
- package/.kxm/assets/README.md +5 -0
- package/.kxm/assets/retrospectives/README.md +5 -0
- package/.kxm/config/README.md +5 -0
- package/.kxm/config/agents.json +43 -0
- package/.kxm/config/env.example +56 -0
- package/.kxm/config/update.example.yaml +9 -0
- package/.kxm/config/workflows/fix.json +160 -0
- package/.kxm/config/workflows/jira-development.json +116 -0
- package/.kxm/config/workflows/provenance-quorum.json +150 -0
- package/.kxm/config/workflows/v04-dogfood.json +72 -0
- package/CHANGELOG.md +465 -0
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/SECURITY.md +72 -0
- package/docs/README.md +48 -0
- package/docs/agent-communication-envelopes-and-gates.md +553 -0
- package/docs/architecture.md +242 -0
- package/docs/assignment-runner.md +241 -0
- package/docs/configuration.md +361 -0
- package/docs/continuous-improvement.md +114 -0
- package/docs/getting-started.md +253 -0
- package/docs/kxm-handbook.md +1090 -0
- package/docs/operations.md +205 -0
- package/docs/provenance-gates.md +291 -0
- package/docs/skills.md +45 -0
- package/docs/templates/README.md +95 -0
- package/docs/templates/adr.md +88 -0
- package/docs/templates/architecture.md +120 -0
- package/docs/templates/bug-fix.md +109 -0
- package/docs/templates/feature.md +108 -0
- package/docs/templates/handoff.md +72 -0
- package/docs/templates/postmortem.md +77 -0
- package/docs/templates/research.md +100 -0
- package/docs/templates/review.md +85 -0
- package/docs/templates/runbook.md +73 -0
- package/docs/templates/test-plan.md +87 -0
- package/docs/templates/test-report.md +72 -0
- package/docs/test-matrix.md +121 -0
- package/docs/troubleshooting.md +249 -0
- package/docs/vnext/README.md +62 -0
- package/docs/vnext/architecture.md +185 -0
- package/docs/vnext/effects-and-recovery.md +172 -0
- package/docs/vnext/lifecycles.md +235 -0
- package/docs/vnext/migration.md +220 -0
- package/docs/vnext/routing.md +184 -0
- package/docs/vnext/synchronization.md +172 -0
- package/docs/vnext/terminology.md +240 -0
- package/docs/vnext/validation.md +335 -0
- package/docs/webhook-workflows.md +240 -0
- package/docs/workflow-guide.md +1150 -0
- package/examples/README.md +102 -0
- package/examples/provenance-workflow.json +40 -0
- package/examples/requester.ts +30 -0
- package/examples/reviewer-agent.ts +29 -0
- package/examples/roundtrip.ts +46 -0
- package/examples/vnext/.kxm/agents/coordinator.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-1.yaml +16 -0
- package/examples/vnext/.kxm/agents/critic-2.yaml +15 -0
- package/examples/vnext/.kxm/agents/critic-3.yaml +15 -0
- package/examples/vnext/.kxm/agents/implementer.yaml +15 -0
- package/examples/vnext/.kxm/agents/planner.yaml +13 -0
- package/examples/vnext/.kxm/agents/reproducer.yaml +15 -0
- package/examples/vnext/.kxm/agents/reviewer.yaml +15 -0
- package/examples/vnext/.kxm/gates.yaml +8 -0
- package/examples/vnext/.kxm/models/critic-claude.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-gemini.yaml +11 -0
- package/examples/vnext/.kxm/models/critic-grok.yaml +12 -0
- package/examples/vnext/.kxm/models/implementation.yaml +14 -0
- package/examples/vnext/.kxm/models/primary.yaml +17 -0
- package/examples/vnext/.kxm/prices.yaml +111 -0
- package/examples/vnext/.kxm/project/env.yaml +7 -0
- package/examples/vnext/.kxm/project.yaml +32 -0
- package/examples/vnext/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/.kxm/workflows/default.yaml +92 -0
- package/examples/vnext/.kxm/workflows/fix.yaml +376 -0
- package/examples/vnext/.kxm/workflows/improve.yaml +57 -0
- package/examples/vnext/README.md +53 -0
- package/examples/vnext/records/assignment-result-recorded.json +63 -0
- package/examples/vnext/records/assignment-result.json +46 -0
- package/examples/vnext/records/context-candidate.json +42 -0
- package/examples/vnext/records/delivery-manifest.json +66 -0
- package/examples/vnext/records/effect-uncertainty-resolved-sync.json +67 -0
- package/examples/vnext/records/effect-uncertainty-resolved.json +62 -0
- package/examples/vnext/records/run-created.json +54 -0
- package/examples/vnext/records/sync-event.json +65 -0
- package/examples/vnext/repositories/api/.kxm/repo/env.yaml +7 -0
- package/examples/vnext/repositories/api/.kxm/repo/repo.yaml +8 -0
- package/examples/vnext/repositories/web/.kxm/repo/repo.yaml +8 -0
- package/examples/workflow-signal.ts +63 -0
- package/package.json +129 -0
- package/plugins/kxm/.claude-plugin/plugin.json +73 -0
- package/plugins/kxm/.mcp.json +19 -0
- package/plugins/kxm/README.md +93 -0
- package/plugins/kxm/dist/cli.js +42853 -0
- package/plugins/kxm/dist/client.js +416 -0
- package/plugins/kxm/dist/core.js +1823 -0
- package/plugins/kxm/dist/extension.js +3797 -0
- package/plugins/kxm/dist/mcp-server.js +17104 -0
- package/plugins/kxm/dist/runtime.js +23361 -0
- package/plugins/kxm/dist/server.js +13640 -0
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +21109 -0
- package/plugins/kxm/package.json +12 -0
- package/plugins/kxm/skills/kxm/SKILL.md +97 -0
- package/plugins/kxm/skills/kxm/references/protocol.md +103 -0
- package/plugins/kxm/skills/kxm-session/SKILL.md +53 -0
- package/plugins/kxm/src/arbiter.ts +355 -0
- package/plugins/kxm/src/artifacts-exist.ts +62 -0
- package/plugins/kxm/src/autocomplete.ts +236 -0
- package/plugins/kxm/src/cli.ts +3707 -0
- package/plugins/kxm/src/client.ts +614 -0
- package/plugins/kxm/src/commands.ts +1063 -0
- package/plugins/kxm/src/config.ts +290 -0
- package/plugins/kxm/src/context/providers.ts +101 -0
- package/plugins/kxm/src/context-packet.ts +332 -0
- package/plugins/kxm/src/context.ts +499 -0
- package/plugins/kxm/src/core.ts +6 -0
- package/plugins/kxm/src/database.ts +563 -0
- package/plugins/kxm/src/diagnostics.ts +184 -0
- package/plugins/kxm/src/envelope.ts +118 -0
- package/plugins/kxm/src/extension.ts +895 -0
- package/plugins/kxm/src/external-effects.ts +299 -0
- package/plugins/kxm/src/github-watch.ts +255 -0
- package/plugins/kxm/src/hub-binding.ts +160 -0
- package/plugins/kxm/src/hub.ts +2502 -0
- package/plugins/kxm/src/improve.ts +383 -0
- package/plugins/kxm/src/inbox.ts +10 -0
- package/plugins/kxm/src/kxm-install-kind.ts +113 -0
- package/plugins/kxm/src/kxm-update-config.ts +39 -0
- package/plugins/kxm/src/kxm-update.ts +238 -0
- package/plugins/kxm/src/local-snapshot.ts +406 -0
- package/plugins/kxm/src/logger.ts +198 -0
- package/plugins/kxm/src/mcp-server.ts +143 -0
- package/plugins/kxm/src/memory.ts +385 -0
- package/plugins/kxm/src/nous-pi.ts +287 -0
- package/plugins/kxm/src/nous-provider.ts +729 -0
- package/plugins/kxm/src/price-calc.ts +87 -0
- package/plugins/kxm/src/prices.ts +121 -0
- package/plugins/kxm/src/protocol.ts +172 -0
- package/plugins/kxm/src/recovery.ts +211 -0
- package/plugins/kxm/src/redact.ts +26 -0
- package/plugins/kxm/src/retrospective.ts +400 -0
- package/plugins/kxm/src/routing.ts +830 -0
- package/plugins/kxm/src/runtime.ts +9 -0
- package/plugins/kxm/src/server.ts +117 -0
- package/plugins/kxm/src/session-work.ts +571 -0
- package/plugins/kxm/src/session.ts +184 -0
- package/plugins/kxm/src/skills.ts +535 -0
- package/plugins/kxm/src/state.ts +326 -0
- package/plugins/kxm/src/store.ts +637 -0
- package/plugins/kxm/src/studio-layout.ts +268 -0
- package/plugins/kxm/src/suggest.ts +162 -0
- package/plugins/kxm/src/task-manager.ts +244 -0
- package/plugins/kxm/src/telemetry.ts +116 -0
- package/plugins/kxm/src/tui.ts +1046 -0
- package/plugins/kxm/src/vnext-bindings.ts +403 -0
- package/plugins/kxm/src/vnext-config.ts +1646 -0
- package/plugins/kxm/src/vnext-engine-artifacts.ts +86 -0
- package/plugins/kxm/src/vnext-engine-command.ts +533 -0
- package/plugins/kxm/src/vnext-engine-compile.ts +722 -0
- package/plugins/kxm/src/vnext-engine-evidence.ts +273 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +1400 -0
- package/plugins/kxm/src/vnext-engine-gate-records.ts +583 -0
- package/plugins/kxm/src/vnext-engine-plan.ts +717 -0
- package/plugins/kxm/src/vnext-engine.ts +2458 -0
- package/plugins/kxm/src/vnext-gate-hash.ts +10 -0
- package/plugins/kxm/src/vnext-harness.ts +1142 -0
- package/plugins/kxm/src/vnext-init.ts +430 -0
- package/plugins/kxm/src/vnext-migrate.ts +1848 -0
- package/plugins/kxm/src/vnext-oneshot-producer.ts +424 -0
- package/plugins/kxm/src/vnext-permission.ts +936 -0
- package/plugins/kxm/src/vnext-pi-producer.ts +628 -0
- package/plugins/kxm/src/vnext-repair.ts +1094 -0
- package/plugins/kxm/src/vnext-runtime-owner.ts +320 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +1560 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +586 -0
- package/plugins/kxm/src/vnext-runtime.ts +663 -0
- package/plugins/kxm/src/vnext-template.ts +247 -0
- package/plugins/kxm/src/wiki.ts +313 -0
- package/plugins/kxm/src/workflow.ts +1548 -0
- package/schemas/vnext/README.md +46 -0
- package/schemas/vnext/agent.schema.json +40 -0
- package/schemas/vnext/assignment-result.schema.json +66 -0
- package/schemas/vnext/backup-manifest.schema.json +89 -0
- package/schemas/vnext/candidate.schema.json +109 -0
- package/schemas/vnext/common.schema.json +422 -0
- package/schemas/vnext/context-candidate.schema.json +76 -0
- package/schemas/vnext/context-packet.schema.json +192 -0
- package/schemas/vnext/delivery-manifest.schema.json +159 -0
- package/schemas/vnext/environment.schema.json +66 -0
- package/schemas/vnext/gate-registry.schema.json +109 -0
- package/schemas/vnext/handoff-manifest.schema.json +146 -0
- package/schemas/vnext/init-operation.schema.json +61 -0
- package/schemas/vnext/local-repository-bindings.schema.json +30 -0
- package/schemas/vnext/memory-record.schema.json +45 -0
- package/schemas/vnext/migration-decision.schema.json +26 -0
- package/schemas/vnext/migration-plan.schema.json +123 -0
- package/schemas/vnext/migration-receipt.schema.json +52 -0
- package/schemas/vnext/model.schema.json +42 -0
- package/schemas/vnext/permission-diff.schema.json +57 -0
- package/schemas/vnext/prices.schema.json +115 -0
- package/schemas/vnext/project.schema.json +85 -0
- package/schemas/vnext/repository.schema.json +24 -0
- package/schemas/vnext/run-event.schema.json +460 -0
- package/schemas/vnext/session-brief.schema.json +153 -0
- package/schemas/vnext/sync-event.schema.json +234 -0
- package/schemas/vnext/template-provenance.schema.json +38 -0
- package/schemas/vnext/workflow.schema.json +248 -0
- package/scripts/assignment-run.d.mts +354 -0
- package/scripts/assignment-run.mjs +4451 -0
- package/scripts/build-runtime.mjs +56 -0
- package/scripts/check-generated.mjs +77 -0
- package/scripts/check-versions.mjs +34 -0
- package/scripts/emit-codex-artifacts.d.mts +9 -0
- package/scripts/emit-codex-artifacts.mjs +91 -0
- package/scripts/harness-run.d.mts +83 -0
- package/scripts/harness-run.mjs +2095 -0
- package/scripts/kxm-hub.mjs +105 -0
- package/scripts/kxm-publish-npm.mjs +327 -0
- package/scripts/kxm-release-github.mjs +472 -0
- package/scripts/kxm-runtime-supervisor.mjs +7 -0
- package/scripts/kxm-worker.mjs +1127 -0
- package/scripts/kxm.mjs +27 -0
- package/scripts/roster-policy.d.mts +20 -0
- package/scripts/roster-policy.mjs +161 -0
- package/scripts/smoke-multi-pi.mjs +479 -0
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { calculateModelCost, type PriceCatalog } from "./price-calc.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Model/harness routing telemetry (v0.5, issue #40).
|
|
6
|
+
*
|
|
7
|
+
* Optimization targets complete verified agent configurations, not model
|
|
8
|
+
* names alone. Every run/stage can carry a metadata-only routing record:
|
|
9
|
+
* the behaviorally relevant configuration tuple is content-addressed so two
|
|
10
|
+
* runs are comparable without ever reading raw private context bodies.
|
|
11
|
+
*
|
|
12
|
+
* The behavioral hash covers: model route, role prompt/config hash, skill
|
|
13
|
+
* versions/hashes, tool policy/version, retrieval/context policy, workflow
|
|
14
|
+
* config hash, and verifier config. Everything else (tokens, cost, retries,
|
|
15
|
+
* outcomes) is outcome telemetry, deliberately outside the hash.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const ROUTING_RECORD_SCHEMA = "kxm.routing-record.v1" as const;
|
|
19
|
+
export const ROUTING_RECORD_V2_SCHEMA = "kxm.routing-record.v2" as const;
|
|
20
|
+
export const BEHAVIORAL_HASH_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
export type RoutingCostBasis = "metered" | "unmetered" | "unknown";
|
|
23
|
+
export type RoutingVerifierOutcome = "passed" | "warning" | "failed";
|
|
24
|
+
export type RoutingFinalOutcome = "accepted" | "blocked" | "failed" | "pending";
|
|
25
|
+
|
|
26
|
+
export interface RoutingRecordV2 {
|
|
27
|
+
schema: typeof ROUTING_RECORD_V2_SCHEMA;
|
|
28
|
+
recordedAt: string;
|
|
29
|
+
project: string;
|
|
30
|
+
runId: string;
|
|
31
|
+
stepId: string;
|
|
32
|
+
assignmentId: string;
|
|
33
|
+
attemptId: string;
|
|
34
|
+
harness: string;
|
|
35
|
+
provider: string;
|
|
36
|
+
requestedModel: string;
|
|
37
|
+
effectiveModel: string;
|
|
38
|
+
thinking?: string | undefined;
|
|
39
|
+
agentRole?: string | undefined;
|
|
40
|
+
behavioralSha256: string;
|
|
41
|
+
contextTokens?: number | null | undefined;
|
|
42
|
+
tokensIn?: number | null | undefined;
|
|
43
|
+
tokensOut?: number | null | undefined;
|
|
44
|
+
cacheReadTokens?: number | null | undefined;
|
|
45
|
+
cacheWriteTokens?: number | null | undefined;
|
|
46
|
+
latencyMs: number;
|
|
47
|
+
costBasis: RoutingCostBasis;
|
|
48
|
+
costUsd?: number | null | undefined;
|
|
49
|
+
priceRef?: string | null | undefined;
|
|
50
|
+
verifierOutcome?: RoutingVerifierOutcome | undefined;
|
|
51
|
+
finalOutcome?: RoutingFinalOutcome | string | undefined;
|
|
52
|
+
retries: number;
|
|
53
|
+
transitions?: number | undefined;
|
|
54
|
+
humanInterventions?: number | undefined;
|
|
55
|
+
providerMetadata?: Record<string, string | number | boolean> | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface SkillVersionRef {
|
|
59
|
+
id: string;
|
|
60
|
+
contentSha256: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BehavioralConfigInput {
|
|
64
|
+
/** Requested model route (e.g. "pi/kimi-k3"), normalized. */
|
|
65
|
+
requestedModel?: string | undefined;
|
|
66
|
+
/** Effective model after fallback/rotation, normalized. */
|
|
67
|
+
effectiveModel?: string | undefined;
|
|
68
|
+
/** Reasoning effort identifier (e.g. "high"). */
|
|
69
|
+
reasoningEffort?: string | undefined;
|
|
70
|
+
/** Agent role (repro, planner, ...). */
|
|
71
|
+
agentRole?: string | undefined;
|
|
72
|
+
/** Role prompt/config content hash. */
|
|
73
|
+
rolePromptSha256?: string | undefined;
|
|
74
|
+
/** Selected skills with pinned content hashes. */
|
|
75
|
+
skills?: SkillVersionRef[] | undefined;
|
|
76
|
+
/** Context/retrieval policy version. */
|
|
77
|
+
contextPolicyVersion?: string | undefined;
|
|
78
|
+
/** Tool policy/schema version. */
|
|
79
|
+
toolPolicyVersion?: string | undefined;
|
|
80
|
+
/** Workflow definition hash. */
|
|
81
|
+
workflowDefinitionSha256?: string | undefined;
|
|
82
|
+
/** Verifier configuration hash. */
|
|
83
|
+
verifierConfigSha256?: string | undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RoutingRecord {
|
|
87
|
+
schema: typeof ROUTING_RECORD_SCHEMA;
|
|
88
|
+
behavioralHashVersion: typeof BEHAVIORAL_HASH_VERSION;
|
|
89
|
+
behavioralSha256: string;
|
|
90
|
+
workflowRunId?: string;
|
|
91
|
+
stageId?: string;
|
|
92
|
+
attempt?: number;
|
|
93
|
+
requestedModel?: string;
|
|
94
|
+
effectiveModel?: string;
|
|
95
|
+
reasoningEffort?: string;
|
|
96
|
+
agentRole?: string;
|
|
97
|
+
rolePromptSha256?: string;
|
|
98
|
+
skills: SkillVersionRef[];
|
|
99
|
+
contextPolicyVersion?: string;
|
|
100
|
+
contextItemIds: string[];
|
|
101
|
+
toolPolicyVersion?: string;
|
|
102
|
+
workflowDefinitionSha256?: string;
|
|
103
|
+
verifierConfigSha256?: string;
|
|
104
|
+
retries: number;
|
|
105
|
+
transitions: number;
|
|
106
|
+
tokensIn?: number;
|
|
107
|
+
tokensOut?: number;
|
|
108
|
+
cacheReadTokens?: number;
|
|
109
|
+
costUsd?: number;
|
|
110
|
+
humanInterventions: number;
|
|
111
|
+
verifierOutcome?: RoutingVerifierOutcome;
|
|
112
|
+
finalOutcome?: RoutingFinalOutcome;
|
|
113
|
+
/** Additive, normalized provider-specific metadata. Never raw bodies. */
|
|
114
|
+
providerMetadata?: Record<string, string | number | boolean>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const MAX_CONTEXT_ITEM_IDS = 256;
|
|
118
|
+
export const MAX_SKILL_REFS = 32;
|
|
119
|
+
export const MAX_PROVIDER_METADATA_FIELDS = 32;
|
|
120
|
+
|
|
121
|
+
function normalizeModel(value: string | undefined): string | undefined {
|
|
122
|
+
if (value === undefined) return undefined;
|
|
123
|
+
const normalized = value.trim().toLowerCase().replace(/\s+/g, "-");
|
|
124
|
+
return normalized === "" ? undefined : normalized;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Content-addressed hash over the behaviorally relevant tuple. Stable for
|
|
128
|
+
* identical configurations; different for any behavioral difference. */
|
|
129
|
+
export function behavioralConfigHash(input: BehavioralConfigInput): string {
|
|
130
|
+
const canonical = {
|
|
131
|
+
version: BEHAVIORAL_HASH_VERSION,
|
|
132
|
+
requestedModel: normalizeModel(input.requestedModel) ?? null,
|
|
133
|
+
effectiveModel: normalizeModel(input.effectiveModel) ?? null,
|
|
134
|
+
reasoningEffort: input.reasoningEffort?.trim() ?? null,
|
|
135
|
+
agentRole: input.agentRole ? input.agentRole.trim().toLowerCase().replace(/\s+/g, "-") : null,
|
|
136
|
+
rolePromptSha256: input.rolePromptSha256?.trim() ?? null,
|
|
137
|
+
skills: (input.skills ?? [])
|
|
138
|
+
.map((skill) => ({ id: skill.id.trim(), sha: skill.contentSha256.trim() }))
|
|
139
|
+
.sort((left, right) => left.id.localeCompare(right.id) || left.sha.localeCompare(right.sha)),
|
|
140
|
+
contextPolicyVersion: input.contextPolicyVersion?.trim() ?? null,
|
|
141
|
+
toolPolicyVersion: input.toolPolicyVersion?.trim() ?? null,
|
|
142
|
+
workflowDefinitionSha256: input.workflowDefinitionSha256?.trim() ?? null,
|
|
143
|
+
verifierConfigSha256: input.verifierConfigSha256?.trim() ?? null,
|
|
144
|
+
};
|
|
145
|
+
return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function boundedInt(value: unknown, field: string): number | undefined {
|
|
149
|
+
if (value === undefined || value === null) return undefined;
|
|
150
|
+
if (!Number.isInteger(value) || (value as number) < 0 || (value as number) > 1_000_000) {
|
|
151
|
+
throw new Error(`${field} must be an integer between 0 and 1000000`);
|
|
152
|
+
}
|
|
153
|
+
return value as number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function boundedString(value: unknown, field: string, max: number): string | undefined {
|
|
157
|
+
if (value === undefined || value === null) return undefined;
|
|
158
|
+
if (typeof value !== "string" || value.length > max) {
|
|
159
|
+
throw new Error(`${field} must be a string of at most ${max} characters`);
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse and validate a routing record from untrusted input. Metadata only:
|
|
165
|
+
* fail closed on unbounded fields, invalid identifiers, or negative costs. */
|
|
166
|
+
export function parseRoutingRecord(value: { schema: typeof ROUTING_RECORD_V2_SCHEMA } & Record<string, unknown>): RoutingRecordV2;
|
|
167
|
+
export function parseRoutingRecord(value: { schema: typeof ROUTING_RECORD_SCHEMA } & Record<string, unknown>): RoutingRecord;
|
|
168
|
+
export function parseRoutingRecord(value: unknown): RoutingRecord | RoutingRecordV2;
|
|
169
|
+
export function parseRoutingRecord(value: unknown): RoutingRecord | RoutingRecordV2 {
|
|
170
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
171
|
+
throw new Error("routing record must be an object");
|
|
172
|
+
}
|
|
173
|
+
const input = value as Record<string, unknown>;
|
|
174
|
+
if (input.schema === ROUTING_RECORD_V2_SCHEMA) {
|
|
175
|
+
return parseRoutingRecordV2(value);
|
|
176
|
+
}
|
|
177
|
+
if (input.schema !== ROUTING_RECORD_SCHEMA) {
|
|
178
|
+
throw new Error(`routing record schema must be ${ROUTING_RECORD_SCHEMA} or ${ROUTING_RECORD_V2_SCHEMA}`);
|
|
179
|
+
}
|
|
180
|
+
const skills = input.skills === undefined || input.skills === null
|
|
181
|
+
? []
|
|
182
|
+
: (() => {
|
|
183
|
+
if (!Array.isArray(input.skills) || input.skills.length > MAX_SKILL_REFS) {
|
|
184
|
+
throw new Error(`routing skills must be an array of at most ${MAX_SKILL_REFS} refs`);
|
|
185
|
+
}
|
|
186
|
+
return input.skills.map((skill) => {
|
|
187
|
+
const ref = skill as Partial<SkillVersionRef>;
|
|
188
|
+
if (typeof ref?.id !== "string" || !ref.id.trim() || ref.id.length > 200
|
|
189
|
+
|| typeof ref?.contentSha256 !== "string" || !/^[a-f0-9]{64}$/.test(ref.contentSha256)) {
|
|
190
|
+
throw new Error("routing skill refs must carry an id and a sha256 content hash");
|
|
191
|
+
}
|
|
192
|
+
return { id: ref.id.trim(), contentSha256: ref.contentSha256 };
|
|
193
|
+
});
|
|
194
|
+
})();
|
|
195
|
+
const contextItemIds = input.contextItemIds === undefined || input.contextItemIds === null
|
|
196
|
+
? []
|
|
197
|
+
: (() => {
|
|
198
|
+
if (!Array.isArray(input.contextItemIds) || input.contextItemIds.length > MAX_CONTEXT_ITEM_IDS) {
|
|
199
|
+
throw new Error(`routing contextItemIds must be an array of at most ${MAX_CONTEXT_ITEM_IDS} ids`);
|
|
200
|
+
}
|
|
201
|
+
return input.contextItemIds.map((id) => {
|
|
202
|
+
if (typeof id !== "string" || !id.trim() || id.length > 200) {
|
|
203
|
+
throw new Error("routing contextItemIds must be bounded non-empty strings");
|
|
204
|
+
}
|
|
205
|
+
return id.trim();
|
|
206
|
+
});
|
|
207
|
+
})();
|
|
208
|
+
const providerMetadata = input.providerMetadata === undefined || input.providerMetadata === null
|
|
209
|
+
? undefined
|
|
210
|
+
: (() => {
|
|
211
|
+
if (!input.providerMetadata || typeof input.providerMetadata !== "object" || Array.isArray(input.providerMetadata)) {
|
|
212
|
+
throw new Error("routing providerMetadata must be an object");
|
|
213
|
+
}
|
|
214
|
+
const entries = Object.entries(input.providerMetadata as Record<string, unknown>);
|
|
215
|
+
if (entries.length > MAX_PROVIDER_METADATA_FIELDS) {
|
|
216
|
+
throw new Error(`routing providerMetadata may carry at most ${MAX_PROVIDER_METADATA_FIELDS} fields`);
|
|
217
|
+
}
|
|
218
|
+
const normalized: Record<string, string | number | boolean> = {};
|
|
219
|
+
for (const [key, field] of entries) {
|
|
220
|
+
if (typeof key !== "string" || key.length > 64 || /prompt|body|content|message/i.test(key)
|
|
221
|
+
|| (typeof field !== "string" && typeof field !== "number" && typeof field !== "boolean")) {
|
|
222
|
+
throw new Error("routing providerMetadata values must be bounded strings, numbers, or booleans; raw bodies are rejected");
|
|
223
|
+
}
|
|
224
|
+
normalized[key] = typeof field === "string" ? field.slice(0, 200) : field;
|
|
225
|
+
}
|
|
226
|
+
return normalized;
|
|
227
|
+
})();
|
|
228
|
+
const verifierOutcome = input.verifierOutcome === undefined || input.verifierOutcome === null
|
|
229
|
+
? undefined
|
|
230
|
+
: (() => {
|
|
231
|
+
if (input.verifierOutcome !== "passed" && input.verifierOutcome !== "warning" && input.verifierOutcome !== "failed") {
|
|
232
|
+
throw new Error("routing verifierOutcome must be passed, warning, or failed");
|
|
233
|
+
}
|
|
234
|
+
return input.verifierOutcome;
|
|
235
|
+
})();
|
|
236
|
+
const finalOutcome = input.finalOutcome === undefined || input.finalOutcome === null
|
|
237
|
+
? undefined
|
|
238
|
+
: (() => {
|
|
239
|
+
if (input.finalOutcome !== "accepted" && input.finalOutcome !== "blocked" && input.finalOutcome !== "failed" && input.finalOutcome !== "pending") {
|
|
240
|
+
throw new Error("routing finalOutcome must be accepted, blocked, failed, or pending");
|
|
241
|
+
}
|
|
242
|
+
return input.finalOutcome;
|
|
243
|
+
})();
|
|
244
|
+
const costUsd = input.costUsd === undefined || input.costUsd === null
|
|
245
|
+
? undefined
|
|
246
|
+
: (() => {
|
|
247
|
+
if (typeof input.costUsd !== "number" || !Number.isFinite(input.costUsd) || input.costUsd < 0 || input.costUsd > 1_000_000) {
|
|
248
|
+
throw new Error("routing costUsd must be a non-negative finite number");
|
|
249
|
+
}
|
|
250
|
+
return input.costUsd;
|
|
251
|
+
})();
|
|
252
|
+
|
|
253
|
+
const behavioralSha256 = boundedString(input.behavioralSha256, "behavioralSha256", 64) ?? "";
|
|
254
|
+
if (!/^[a-f0-9]{64}$/.test(behavioralSha256)) {
|
|
255
|
+
throw new Error("routing behavioralSha256 must be a sha256 hex digest");
|
|
256
|
+
}
|
|
257
|
+
const record: RoutingRecord = {
|
|
258
|
+
schema: ROUTING_RECORD_SCHEMA,
|
|
259
|
+
behavioralHashVersion: BEHAVIORAL_HASH_VERSION,
|
|
260
|
+
behavioralSha256,
|
|
261
|
+
skills,
|
|
262
|
+
contextItemIds,
|
|
263
|
+
retries: boundedInt(input.retries, "retries") ?? 0,
|
|
264
|
+
transitions: boundedInt(input.transitions, "transitions") ?? 0,
|
|
265
|
+
humanInterventions: boundedInt(input.humanInterventions, "humanInterventions") ?? 0,
|
|
266
|
+
};
|
|
267
|
+
const strings: Array<[keyof RoutingRecord, string | undefined]> = [
|
|
268
|
+
["workflowRunId", boundedString(input.workflowRunId, "workflowRunId", 128)],
|
|
269
|
+
["stageId", boundedString(input.stageId, "stageId", 128)],
|
|
270
|
+
["requestedModel", normalizeModel(boundedString(input.requestedModel, "requestedModel", 200))],
|
|
271
|
+
["effectiveModel", normalizeModel(boundedString(input.effectiveModel, "effectiveModel", 200))],
|
|
272
|
+
["reasoningEffort", boundedString(input.reasoningEffort, "reasoningEffort", 64)],
|
|
273
|
+
["agentRole", boundedString(input.agentRole, "agentRole", 64)],
|
|
274
|
+
["rolePromptSha256", boundedString(input.rolePromptSha256, "rolePromptSha256", 64)],
|
|
275
|
+
["contextPolicyVersion", boundedString(input.contextPolicyVersion, "contextPolicyVersion", 64)],
|
|
276
|
+
["toolPolicyVersion", boundedString(input.toolPolicyVersion, "toolPolicyVersion", 64)],
|
|
277
|
+
["workflowDefinitionSha256", boundedString(input.workflowDefinitionSha256, "workflowDefinitionSha256", 64)],
|
|
278
|
+
["verifierConfigSha256", boundedString(input.verifierConfigSha256, "verifierConfigSha256", 64)],
|
|
279
|
+
];
|
|
280
|
+
for (const [key, value] of strings) {
|
|
281
|
+
if (value !== undefined) (record as unknown as Record<string, unknown>)[key] = value;
|
|
282
|
+
}
|
|
283
|
+
for (const [key, value] of [["attempt", boundedInt(input.attempt, "attempt")], ["tokensIn", boundedInt(input.tokensIn, "tokensIn")], ["tokensOut", boundedInt(input.tokensOut, "tokensOut")], ["cacheReadTokens", boundedInt(input.cacheReadTokens, "cacheReadTokens")]] as const) {
|
|
284
|
+
if (value !== undefined) (record as unknown as Record<string, unknown>)[key] = value;
|
|
285
|
+
}
|
|
286
|
+
if (costUsd !== undefined) record.costUsd = costUsd;
|
|
287
|
+
if (verifierOutcome !== undefined) record.verifierOutcome = verifierOutcome;
|
|
288
|
+
if (finalOutcome !== undefined) record.finalOutcome = finalOutcome;
|
|
289
|
+
if (providerMetadata !== undefined) record.providerMetadata = providerMetadata;
|
|
290
|
+
return record;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function parseRoutingRecordV2(value: unknown): RoutingRecordV2 {
|
|
294
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
295
|
+
throw new Error("routing record v2 must be an object");
|
|
296
|
+
}
|
|
297
|
+
const input = value as Record<string, unknown>;
|
|
298
|
+
if (input.schema !== ROUTING_RECORD_V2_SCHEMA) {
|
|
299
|
+
throw new Error(`routing record v2 schema must be ${ROUTING_RECORD_V2_SCHEMA}`);
|
|
300
|
+
}
|
|
301
|
+
const costBasis = input.costBasis;
|
|
302
|
+
if (costBasis !== "metered" && costBasis !== "unmetered" && costBasis !== "unknown") {
|
|
303
|
+
throw new Error("routing record v2 costBasis must be metered, unmetered, or unknown");
|
|
304
|
+
}
|
|
305
|
+
let costUsd: number | null | undefined = undefined;
|
|
306
|
+
if (costBasis === "metered") {
|
|
307
|
+
if (typeof input.costUsd !== "number" || !Number.isFinite(input.costUsd) || input.costUsd < 0 || input.costUsd > 1_000_000) {
|
|
308
|
+
throw new Error("routing record v2 costUsd must be a non-negative finite number when costBasis is metered");
|
|
309
|
+
}
|
|
310
|
+
costUsd = input.costUsd;
|
|
311
|
+
} else if (input.costUsd !== undefined && input.costUsd !== null) {
|
|
312
|
+
if (typeof input.costUsd !== "number" || !Number.isFinite(input.costUsd) || input.costUsd < 0) {
|
|
313
|
+
throw new Error("routing record v2 costUsd must be a non-negative finite number when present");
|
|
314
|
+
}
|
|
315
|
+
costUsd = input.costUsd;
|
|
316
|
+
} else {
|
|
317
|
+
costUsd = null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const rawHash = boundedString(input.behavioralSha256, "behavioralSha256", 72) ?? "";
|
|
321
|
+
if (!/^(?:sha256:)?[a-f0-9]{64}$/.test(rawHash)) {
|
|
322
|
+
throw new Error("routing record v2 behavioralSha256 must be a sha256 hex digest");
|
|
323
|
+
}
|
|
324
|
+
const behavioralSha256 = rawHash.startsWith("sha256:") ? rawHash : `sha256:${rawHash}`;
|
|
325
|
+
|
|
326
|
+
const latencyMs = input.latencyMs;
|
|
327
|
+
if (typeof latencyMs !== "number" || !Number.isFinite(latencyMs) || latencyMs < 0) {
|
|
328
|
+
throw new Error("routing record v2 latencyMs must be a non-negative number");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const providerMetadata = input.providerMetadata === undefined || input.providerMetadata === null
|
|
332
|
+
? undefined
|
|
333
|
+
: (() => {
|
|
334
|
+
if (!input.providerMetadata || typeof input.providerMetadata !== "object" || Array.isArray(input.providerMetadata)) {
|
|
335
|
+
throw new Error("routing providerMetadata must be an object");
|
|
336
|
+
}
|
|
337
|
+
const entries = Object.entries(input.providerMetadata as Record<string, unknown>);
|
|
338
|
+
if (entries.length > MAX_PROVIDER_METADATA_FIELDS) {
|
|
339
|
+
throw new Error(`routing providerMetadata may carry at most ${MAX_PROVIDER_METADATA_FIELDS} fields`);
|
|
340
|
+
}
|
|
341
|
+
const normalized: Record<string, string | number | boolean> = {};
|
|
342
|
+
for (const [key, field] of entries) {
|
|
343
|
+
if (typeof key !== "string" || key.length > 64 || /prompt|body|content|message/i.test(key)
|
|
344
|
+
|| (typeof field !== "string" && typeof field !== "number" && typeof field !== "boolean")) {
|
|
345
|
+
throw new Error("routing providerMetadata values must be bounded strings, numbers, or booleans; raw bodies are rejected");
|
|
346
|
+
}
|
|
347
|
+
normalized[key] = typeof field === "string" ? field.slice(0, 200) : field;
|
|
348
|
+
}
|
|
349
|
+
return normalized;
|
|
350
|
+
})();
|
|
351
|
+
|
|
352
|
+
const record: RoutingRecordV2 = {
|
|
353
|
+
schema: ROUTING_RECORD_V2_SCHEMA,
|
|
354
|
+
recordedAt: boundedString(input.recordedAt, "recordedAt", 64) ?? new Date().toISOString(),
|
|
355
|
+
project: boundedString(input.project, "project", 128) ?? "",
|
|
356
|
+
runId: boundedString(input.runId, "runId", 128) ?? "",
|
|
357
|
+
stepId: boundedString(input.stepId, "stepId", 128) ?? "",
|
|
358
|
+
assignmentId: boundedString(input.assignmentId, "assignmentId", 128) ?? "",
|
|
359
|
+
attemptId: boundedString(input.attemptId, "attemptId", 128) ?? "",
|
|
360
|
+
harness: boundedString(input.harness, "harness", 64) ?? "",
|
|
361
|
+
provider: boundedString(input.provider, "provider", 64) ?? "",
|
|
362
|
+
requestedModel: normalizeModel(boundedString(input.requestedModel, "requestedModel", 200)) ?? "",
|
|
363
|
+
effectiveModel: normalizeModel(boundedString(input.effectiveModel, "effectiveModel", 200)) ?? "",
|
|
364
|
+
behavioralSha256,
|
|
365
|
+
latencyMs,
|
|
366
|
+
costBasis,
|
|
367
|
+
costUsd,
|
|
368
|
+
retries: boundedInt(input.retries, "retries") ?? 0,
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (input.thinking !== undefined && input.thinking !== null) {
|
|
372
|
+
record.thinking = boundedString(input.thinking, "thinking", 64);
|
|
373
|
+
}
|
|
374
|
+
const rawRole = input.agentRole ?? (input as Record<string, unknown>).role;
|
|
375
|
+
if (rawRole !== undefined && rawRole !== null) {
|
|
376
|
+
record.agentRole = boundedString(rawRole, "agentRole", 64);
|
|
377
|
+
}
|
|
378
|
+
if (input.contextTokens !== undefined) {
|
|
379
|
+
record.contextTokens = boundedInt(input.contextTokens, "contextTokens") ?? null;
|
|
380
|
+
}
|
|
381
|
+
if (input.tokensIn !== undefined) {
|
|
382
|
+
record.tokensIn = boundedInt(input.tokensIn, "tokensIn") ?? null;
|
|
383
|
+
}
|
|
384
|
+
if (input.tokensOut !== undefined) {
|
|
385
|
+
record.tokensOut = boundedInt(input.tokensOut, "tokensOut") ?? null;
|
|
386
|
+
}
|
|
387
|
+
if (input.cacheReadTokens !== undefined) {
|
|
388
|
+
record.cacheReadTokens = boundedInt(input.cacheReadTokens, "cacheReadTokens") ?? null;
|
|
389
|
+
}
|
|
390
|
+
if (input.cacheWriteTokens !== undefined) {
|
|
391
|
+
record.cacheWriteTokens = boundedInt(input.cacheWriteTokens, "cacheWriteTokens") ?? null;
|
|
392
|
+
}
|
|
393
|
+
if (input.priceRef !== undefined && input.priceRef !== null) {
|
|
394
|
+
record.priceRef = boundedString(input.priceRef, "priceRef", 128);
|
|
395
|
+
}
|
|
396
|
+
if (input.verifierOutcome !== undefined && input.verifierOutcome !== null) {
|
|
397
|
+
if (input.verifierOutcome !== "passed" && input.verifierOutcome !== "warning" && input.verifierOutcome !== "failed") {
|
|
398
|
+
throw new Error("routing verifierOutcome must be passed, warning, or failed");
|
|
399
|
+
}
|
|
400
|
+
record.verifierOutcome = input.verifierOutcome;
|
|
401
|
+
}
|
|
402
|
+
if (input.finalOutcome !== undefined && input.finalOutcome !== null) {
|
|
403
|
+
record.finalOutcome = typeof input.finalOutcome === "string" ? input.finalOutcome.slice(0, 64) : undefined;
|
|
404
|
+
}
|
|
405
|
+
if (input.transitions !== undefined) {
|
|
406
|
+
record.transitions = boundedInt(input.transitions, "transitions");
|
|
407
|
+
}
|
|
408
|
+
if (input.humanInterventions !== undefined) {
|
|
409
|
+
record.humanInterventions = boundedInt(input.humanInterventions, "humanInterventions");
|
|
410
|
+
}
|
|
411
|
+
if (providerMetadata !== undefined) {
|
|
412
|
+
record.providerMetadata = providerMetadata;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return record;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export interface RoutingComparison {
|
|
419
|
+
behavioralSha256: string;
|
|
420
|
+
runs: number;
|
|
421
|
+
verifiedCompletions: number;
|
|
422
|
+
blocked: number;
|
|
423
|
+
failed: number;
|
|
424
|
+
reworkRate: number;
|
|
425
|
+
totalCostUsd: number;
|
|
426
|
+
totalTokensIn: number;
|
|
427
|
+
totalTokensOut: number;
|
|
428
|
+
totalHumanInterventions: number;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Aggregate comparable outcome metrics for one behavioral configuration.
|
|
432
|
+
* Purely metadata-driven: no raw prompt or reply bodies are read. */
|
|
433
|
+
export function compareRoutingRecords(records: RoutingRecord[]): RoutingComparison {
|
|
434
|
+
if (records.length === 0) {
|
|
435
|
+
throw new Error("compareRoutingRecords requires at least one record");
|
|
436
|
+
}
|
|
437
|
+
const behavioralSha256 = records[0]!.behavioralSha256;
|
|
438
|
+
for (const record of records) {
|
|
439
|
+
if (record.behavioralSha256 !== behavioralSha256) {
|
|
440
|
+
throw new Error("compareRoutingRecords requires records with identical behavioral hashes");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const settled = records.filter((record) => record.finalOutcome !== undefined && record.finalOutcome !== "pending");
|
|
444
|
+
const accepted = settled.filter((record) => record.finalOutcome === "accepted").length;
|
|
445
|
+
const blocked = settled.filter((record) => record.finalOutcome === "blocked").length;
|
|
446
|
+
const failed = settled.filter((record) => record.finalOutcome === "failed").length;
|
|
447
|
+
const reworked = records.filter((record) => record.retries > 0 || record.transitions > 0).length;
|
|
448
|
+
return {
|
|
449
|
+
behavioralSha256,
|
|
450
|
+
runs: records.length,
|
|
451
|
+
verifiedCompletions: accepted,
|
|
452
|
+
blocked,
|
|
453
|
+
failed,
|
|
454
|
+
reworkRate: records.length === 0 ? 0 : Math.round((reworked / records.length) * 100) / 100,
|
|
455
|
+
totalCostUsd: Math.round(records.reduce((sum, record) => sum + (record.costUsd ?? 0), 0) * 10_000) / 10_000,
|
|
456
|
+
totalTokensIn: records.reduce((sum, record) => sum + (record.tokensIn ?? 0), 0),
|
|
457
|
+
totalTokensOut: records.reduce((sum, record) => sum + (record.tokensOut ?? 0), 0),
|
|
458
|
+
totalHumanInterventions: records.reduce((sum, record) => sum + record.humanInterventions, 0),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Group routing records by behavioral configuration for champion/challenger
|
|
463
|
+
* comparisons. */
|
|
464
|
+
export function groupByBehavior(records: RoutingRecord[]): Map<string, RoutingRecord[]> {
|
|
465
|
+
const groups = new Map<string, RoutingRecord[]>();
|
|
466
|
+
for (const record of records) {
|
|
467
|
+
const bucket = groups.get(record.behavioralSha256) ?? [];
|
|
468
|
+
bucket.push(record);
|
|
469
|
+
groups.set(record.behavioralSha256, bucket);
|
|
470
|
+
}
|
|
471
|
+
return groups;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export const ROUTING_REPORT_SCHEMA = "kxm.routing-report.v1" as const;
|
|
475
|
+
|
|
476
|
+
export function isQuotaExhausted(record: RoutingRecord | RoutingRecordV2): boolean {
|
|
477
|
+
if ("providerMetadata" in record && record.providerMetadata) {
|
|
478
|
+
const meta = record.providerMetadata;
|
|
479
|
+
if (meta.failureClass === "quota" || meta.errorCode === "provider_quota" || meta.stopReason === "quota_exhausted" || meta.quotaExhausted === true || meta.quota_exhausted === true) {
|
|
480
|
+
return true;
|
|
481
|
+
}
|
|
482
|
+
for (const [key, val] of Object.entries(meta)) {
|
|
483
|
+
if (/quota/i.test(key) && val === true) return true;
|
|
484
|
+
if (typeof val === "string" && /\b(quota reached|quota exceeded|rate limit(?:ed)?|too many requests|resource exhausted|http 429|quota_exhausted)\b/i.test(val)) {
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (record.finalOutcome === "quota" || record.finalOutcome === "quota_exhausted") return true;
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function computePercentile(sorted: number[], p: number): number {
|
|
494
|
+
if (sorted.length === 0) return 0;
|
|
495
|
+
if (sorted.length === 1) return sorted[0]!;
|
|
496
|
+
const pos = p * (sorted.length - 1);
|
|
497
|
+
const base = Math.floor(pos);
|
|
498
|
+
const rest = pos - base;
|
|
499
|
+
if (sorted[base + 1] !== undefined) {
|
|
500
|
+
return Math.round(sorted[base]! + rest * (sorted[base + 1]! - sorted[base]!));
|
|
501
|
+
}
|
|
502
|
+
return sorted[base]!;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export interface RoutingReportRow {
|
|
506
|
+
harness: string;
|
|
507
|
+
model: string;
|
|
508
|
+
thinking: string;
|
|
509
|
+
role: string;
|
|
510
|
+
attempts: number;
|
|
511
|
+
verifyPassRate: number;
|
|
512
|
+
reworkRate: number;
|
|
513
|
+
latencyP50Ms: number;
|
|
514
|
+
latencyP95Ms: number;
|
|
515
|
+
medianContextTokens: number | null;
|
|
516
|
+
meteredCostUsd: number;
|
|
517
|
+
costPerAcceptedUsd: number | null;
|
|
518
|
+
unmeteredAttempts: number;
|
|
519
|
+
unknownCostAttempts: number;
|
|
520
|
+
quotaExhaustedAttempts: number;
|
|
521
|
+
flagged: boolean;
|
|
522
|
+
equivalentListCostUsd?: number | null | undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export interface RoutingReport {
|
|
526
|
+
schema: typeof ROUTING_REPORT_SCHEMA;
|
|
527
|
+
generatedAt: string;
|
|
528
|
+
totalAttempts: number;
|
|
529
|
+
rows: RoutingReportRow[];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface GenerateRoutingReportOptions {
|
|
533
|
+
catalog?: PriceCatalog | undefined;
|
|
534
|
+
includeEquivalentListCost?: boolean | undefined;
|
|
535
|
+
now?: () => string;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function generateRoutingReport(
|
|
539
|
+
records: Array<RoutingRecord | RoutingRecordV2>,
|
|
540
|
+
options: GenerateRoutingReportOptions = {},
|
|
541
|
+
): RoutingReport {
|
|
542
|
+
const generatedAt = options.now ? options.now() : new Date().toISOString();
|
|
543
|
+
if (records.length === 0) {
|
|
544
|
+
return {
|
|
545
|
+
schema: ROUTING_REPORT_SCHEMA,
|
|
546
|
+
generatedAt,
|
|
547
|
+
totalAttempts: 0,
|
|
548
|
+
rows: [],
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Group by (harness, model, thinking, role)
|
|
553
|
+
const groups = new Map<string, Array<RoutingRecord | RoutingRecordV2>>();
|
|
554
|
+
for (const record of records) {
|
|
555
|
+
const isV2 = record.schema === ROUTING_RECORD_V2_SCHEMA;
|
|
556
|
+
const harness = (isV2 ? (record as RoutingRecordV2).harness : (record.providerMetadata?.harness as string | undefined)) || "unknown";
|
|
557
|
+
const model = (isV2 ? ((record as RoutingRecordV2).effectiveModel || (record as RoutingRecordV2).requestedModel) : (record.effectiveModel || record.requestedModel)) || "unknown";
|
|
558
|
+
const thinking = (isV2 ? (record as RoutingRecordV2).thinking : record.reasoningEffort) || "none";
|
|
559
|
+
const role = (record as any).role || record.agentRole || "unknown";
|
|
560
|
+
|
|
561
|
+
const key = `${harness}\0${model}\0${thinking}\0${role}`;
|
|
562
|
+
let bucket = groups.get(key);
|
|
563
|
+
if (!bucket) {
|
|
564
|
+
bucket = [];
|
|
565
|
+
groups.set(key, bucket);
|
|
566
|
+
}
|
|
567
|
+
bucket.push(record);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const rows: RoutingReportRow[] = [];
|
|
571
|
+
|
|
572
|
+
for (const [key, groupRecords] of groups.entries()) {
|
|
573
|
+
const [harness, model, thinking, role] = key.split("\0") as [string, string, string, string];
|
|
574
|
+
const attempts = groupRecords.length;
|
|
575
|
+
|
|
576
|
+
let verifyPassedCount = 0;
|
|
577
|
+
let reworkCount = 0;
|
|
578
|
+
let acceptedCount = 0;
|
|
579
|
+
let meteredCostTotal = 0;
|
|
580
|
+
let unmeteredAttempts = 0;
|
|
581
|
+
let unknownCostAttempts = 0;
|
|
582
|
+
let quotaExhaustedAttempts = 0;
|
|
583
|
+
|
|
584
|
+
const latencies: number[] = [];
|
|
585
|
+
const contextVals: number[] = [];
|
|
586
|
+
|
|
587
|
+
for (const r of groupRecords) {
|
|
588
|
+
const isV2 = r.schema === ROUTING_RECORD_V2_SCHEMA;
|
|
589
|
+
const v2 = isV2 ? (r as RoutingRecordV2) : undefined;
|
|
590
|
+
|
|
591
|
+
// Verification pass
|
|
592
|
+
if (r.verifierOutcome === "passed" || (!r.verifierOutcome && r.finalOutcome === "accepted")) {
|
|
593
|
+
verifyPassedCount++;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Rework: back-edge re-entries only
|
|
597
|
+
if ((r.transitions ?? 0) > 0) {
|
|
598
|
+
reworkCount++;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Accepted attempts
|
|
602
|
+
if (r.finalOutcome === "accepted" || r.verifierOutcome === "passed") {
|
|
603
|
+
acceptedCount++;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Cost basis
|
|
607
|
+
const costBasis = v2 ? v2.costBasis : (typeof r.costUsd === "number" ? "metered" : "unknown");
|
|
608
|
+
if (costBasis === "metered") {
|
|
609
|
+
if (typeof r.costUsd === "number" && Number.isFinite(r.costUsd)) {
|
|
610
|
+
meteredCostTotal += r.costUsd;
|
|
611
|
+
}
|
|
612
|
+
} else if (costBasis === "unmetered") {
|
|
613
|
+
unmeteredAttempts++;
|
|
614
|
+
} else {
|
|
615
|
+
unknownCostAttempts++;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Quota exhausted
|
|
619
|
+
if (isQuotaExhausted(r)) {
|
|
620
|
+
quotaExhaustedAttempts++;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Latencies
|
|
624
|
+
const lat = v2 ? v2.latencyMs : (typeof r.providerMetadata?.latencyMs === "number" ? (r.providerMetadata.latencyMs as number) : undefined);
|
|
625
|
+
if (typeof lat === "number" && Number.isFinite(lat) && lat >= 0) {
|
|
626
|
+
latencies.push(lat);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Context tokens
|
|
630
|
+
const ctx = v2 ? (v2.contextTokens ?? v2.tokensIn) : (r.tokensIn);
|
|
631
|
+
if (typeof ctx === "number" && Number.isFinite(ctx) && ctx >= 0) {
|
|
632
|
+
contextVals.push(ctx);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const verifyPassRate = attempts > 0 ? Math.round((verifyPassedCount / attempts) * 1000) / 1000 : 0;
|
|
637
|
+
const reworkRate = attempts > 0 ? Math.round((reworkCount / attempts) * 1000) / 1000 : 0;
|
|
638
|
+
|
|
639
|
+
latencies.sort((a, b) => a - b);
|
|
640
|
+
const latencyP50Ms = computePercentile(latencies, 0.50);
|
|
641
|
+
const latencyP95Ms = computePercentile(latencies, 0.95);
|
|
642
|
+
|
|
643
|
+
contextVals.sort((a, b) => a - b);
|
|
644
|
+
let medianContextTokens: number | null = null;
|
|
645
|
+
if (contextVals.length > 0) {
|
|
646
|
+
const mid = Math.floor(contextVals.length / 2);
|
|
647
|
+
medianContextTokens = contextVals.length % 2 !== 0
|
|
648
|
+
? contextVals[mid]!
|
|
649
|
+
: Math.round((contextVals[mid - 1]! + contextVals[mid]!) / 2);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const meteredCostUsd = Math.round(meteredCostTotal * 10_000) / 10_000;
|
|
653
|
+
const costPerAcceptedUsd = acceptedCount > 0
|
|
654
|
+
? (meteredCostUsd > 0 || unmeteredAttempts > 0
|
|
655
|
+
? Math.round((meteredCostUsd / acceptedCount) * 10_000) / 10_000
|
|
656
|
+
: (unknownCostAttempts === attempts ? null : 0))
|
|
657
|
+
: null;
|
|
658
|
+
|
|
659
|
+
const flagged = unknownCostAttempts > 0;
|
|
660
|
+
|
|
661
|
+
let equivalentListCostUsd: number | null | undefined = undefined;
|
|
662
|
+
if (options.includeEquivalentListCost && options.catalog) {
|
|
663
|
+
let equivTotal = 0;
|
|
664
|
+
let calculatedAll = true;
|
|
665
|
+
for (const r of groupRecords) {
|
|
666
|
+
const isV2 = r.schema === ROUTING_RECORD_V2_SCHEMA;
|
|
667
|
+
const v2 = isV2 ? (r as RoutingRecordV2) : undefined;
|
|
668
|
+
const costBasis = v2 ? v2.costBasis : (typeof r.costUsd === "number" ? "metered" : "unknown");
|
|
669
|
+
if (costBasis === "metered" && typeof r.costUsd === "number") {
|
|
670
|
+
equivTotal += r.costUsd;
|
|
671
|
+
} else {
|
|
672
|
+
const calc = calculateModelCost(options.catalog, {
|
|
673
|
+
model,
|
|
674
|
+
...(v2?.provider ? { provider: v2.provider } : {}),
|
|
675
|
+
tokensIn: r.tokensIn ?? null,
|
|
676
|
+
tokensOut: r.tokensOut ?? null,
|
|
677
|
+
cacheReadTokens: r.cacheReadTokens ?? null,
|
|
678
|
+
contextTokens: (v2?.contextTokens ?? r.tokensIn) ?? null,
|
|
679
|
+
});
|
|
680
|
+
if (calc) {
|
|
681
|
+
equivTotal += calc.costUsd;
|
|
682
|
+
} else {
|
|
683
|
+
calculatedAll = false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
equivalentListCostUsd = calculatedAll ? Math.round(equivTotal * 10_000) / 10_000 : null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
rows.push({
|
|
691
|
+
harness,
|
|
692
|
+
model,
|
|
693
|
+
thinking,
|
|
694
|
+
role,
|
|
695
|
+
attempts,
|
|
696
|
+
verifyPassRate,
|
|
697
|
+
reworkRate,
|
|
698
|
+
latencyP50Ms,
|
|
699
|
+
latencyP95Ms,
|
|
700
|
+
medianContextTokens,
|
|
701
|
+
meteredCostUsd,
|
|
702
|
+
costPerAcceptedUsd,
|
|
703
|
+
unmeteredAttempts,
|
|
704
|
+
unknownCostAttempts,
|
|
705
|
+
quotaExhaustedAttempts,
|
|
706
|
+
flagged,
|
|
707
|
+
...(options.includeEquivalentListCost ? { equivalentListCostUsd } : {}),
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Sort quality then cost; unknown is never ranked cheapest
|
|
712
|
+
rows.sort((a, b) => {
|
|
713
|
+
// 1. Quality: higher verifyPassRate is better
|
|
714
|
+
if (a.verifyPassRate !== b.verifyPassRate) {
|
|
715
|
+
return b.verifyPassRate - a.verifyPassRate;
|
|
716
|
+
}
|
|
717
|
+
// Lower reworkRate is better
|
|
718
|
+
if (a.reworkRate !== b.reworkRate) {
|
|
719
|
+
return a.reworkRate - b.reworkRate;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// 2. Cost: unknown is never ranked cheapest
|
|
723
|
+
const aCostUnknown = a.costPerAcceptedUsd === null && a.unknownCostAttempts > 0 && a.meteredCostUsd === 0;
|
|
724
|
+
const bCostUnknown = b.costPerAcceptedUsd === null && b.unknownCostAttempts > 0 && b.meteredCostUsd === 0;
|
|
725
|
+
if (aCostUnknown && !bCostUnknown) return 1;
|
|
726
|
+
if (!aCostUnknown && bCostUnknown) return -1;
|
|
727
|
+
|
|
728
|
+
if (a.costPerAcceptedUsd !== null && b.costPerAcceptedUsd !== null) {
|
|
729
|
+
if (a.costPerAcceptedUsd !== b.costPerAcceptedUsd) {
|
|
730
|
+
return a.costPerAcceptedUsd - b.costPerAcceptedUsd;
|
|
731
|
+
}
|
|
732
|
+
} else if (a.costPerAcceptedUsd !== null) {
|
|
733
|
+
return -1;
|
|
734
|
+
} else if (b.costPerAcceptedUsd !== null) {
|
|
735
|
+
return 1;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (a.meteredCostUsd !== b.meteredCostUsd) {
|
|
739
|
+
return a.meteredCostUsd - b.meteredCostUsd;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 3. Tiebreakers
|
|
743
|
+
if (a.attempts !== b.attempts) {
|
|
744
|
+
return b.attempts - a.attempts;
|
|
745
|
+
}
|
|
746
|
+
const cmpH = a.harness.localeCompare(b.harness);
|
|
747
|
+
if (cmpH !== 0) return cmpH;
|
|
748
|
+
const cmpM = a.model.localeCompare(b.model);
|
|
749
|
+
if (cmpM !== 0) return cmpM;
|
|
750
|
+
return a.role.localeCompare(b.role);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
schema: ROUTING_REPORT_SCHEMA,
|
|
755
|
+
generatedAt,
|
|
756
|
+
totalAttempts: records.length,
|
|
757
|
+
rows,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export function formatRoutingReport(
|
|
762
|
+
report: RoutingReport,
|
|
763
|
+
options: { equivalentListCost?: boolean } = {},
|
|
764
|
+
): string {
|
|
765
|
+
if (report.rows.length === 0) {
|
|
766
|
+
return "no routing records to report";
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const showListCost = Boolean(options.equivalentListCost);
|
|
770
|
+
const headers = [
|
|
771
|
+
"Harness".padEnd(10),
|
|
772
|
+
"Model".padEnd(24),
|
|
773
|
+
"Effort".padEnd(8),
|
|
774
|
+
"Role".padEnd(14),
|
|
775
|
+
"Att".padStart(4),
|
|
776
|
+
"Pass%".padStart(7),
|
|
777
|
+
"Rwk%".padStart(6),
|
|
778
|
+
"p50(ms)".padStart(8),
|
|
779
|
+
"p95(ms)".padStart(8),
|
|
780
|
+
"CtxTok".padStart(8),
|
|
781
|
+
"Metered($)".padStart(11),
|
|
782
|
+
"$/Acc".padStart(9),
|
|
783
|
+
"Unm".padStart(4),
|
|
784
|
+
"Unk".padStart(5),
|
|
785
|
+
"Quota".padStart(6),
|
|
786
|
+
...(showListCost ? ["ListEquiv($)".padStart(13)] : []),
|
|
787
|
+
].join(" ");
|
|
788
|
+
|
|
789
|
+
const lines: string[] = [
|
|
790
|
+
`Routing Telemetry Report (${report.totalAttempts} attempt(s) across ${report.rows.length} route(s), quality-first ranking)`,
|
|
791
|
+
headers,
|
|
792
|
+
];
|
|
793
|
+
|
|
794
|
+
for (const row of report.rows) {
|
|
795
|
+
const passPct = `${(row.verifyPassRate * 100).toFixed(1)}%`;
|
|
796
|
+
const rwkPct = `${(row.reworkRate * 100).toFixed(1)}%`;
|
|
797
|
+
const p50 = `${row.latencyP50Ms}`;
|
|
798
|
+
const p95 = `${row.latencyP95Ms}`;
|
|
799
|
+
const ctx = row.medianContextTokens !== null ? `${row.medianContextTokens}` : "-";
|
|
800
|
+
const metered = `$${row.meteredCostUsd.toFixed(4)}`;
|
|
801
|
+
const perAcc = row.costPerAcceptedUsd !== null ? `$${row.costPerAcceptedUsd.toFixed(4)}` : "-";
|
|
802
|
+
const unkText = `${row.unknownCostAttempts}${row.flagged ? "*" : ""}`;
|
|
803
|
+
|
|
804
|
+
const cells = [
|
|
805
|
+
row.harness.padEnd(10),
|
|
806
|
+
(row.model.length > 24 ? `${row.model.slice(0, 21)}...` : row.model).padEnd(24),
|
|
807
|
+
row.thinking.padEnd(8),
|
|
808
|
+
(row.role.length > 14 ? `${row.role.slice(0, 11)}...` : row.role).padEnd(14),
|
|
809
|
+
String(row.attempts).padStart(4),
|
|
810
|
+
passPct.padStart(7),
|
|
811
|
+
rwkPct.padStart(6),
|
|
812
|
+
p50.padStart(8),
|
|
813
|
+
p95.padStart(8),
|
|
814
|
+
ctx.padStart(8),
|
|
815
|
+
metered.padStart(11),
|
|
816
|
+
perAcc.padStart(9),
|
|
817
|
+
String(row.unmeteredAttempts).padStart(4),
|
|
818
|
+
unkText.padStart(5),
|
|
819
|
+
String(row.quotaExhaustedAttempts).padStart(6),
|
|
820
|
+
...(showListCost ? [(row.equivalentListCostUsd !== undefined && row.equivalentListCostUsd !== null ? `$${row.equivalentListCostUsd.toFixed(4)}` : "-").padStart(13)] : []),
|
|
821
|
+
];
|
|
822
|
+
lines.push(cells.join(" "));
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (report.rows.some((r) => r.flagged)) {
|
|
826
|
+
lines.push("* = unknown-cost attempts present (never ranked cheapest)");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return lines.join("\n");
|
|
830
|
+
}
|