@llm4ts/flow 2.4.2 → 2.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/dist/Classified.d.ts +2 -0
- package/dist/Classified.d.ts.map +1 -1
- package/dist/Classified.js +4 -0
- package/dist/Classified.js.map +1 -1
- package/dist/CostReport.d.ts +110 -0
- package/dist/CostReport.d.ts.map +1 -0
- package/dist/CostReport.js +313 -0
- package/dist/CostReport.js.map +1 -0
- package/dist/EstimatedUsage.d.ts.map +1 -1
- package/dist/EstimatedUsage.js +4 -0
- package/dist/EstimatedUsage.js.map +1 -1
- package/dist/Flow.d.ts +14 -0
- package/dist/Flow.d.ts.map +1 -1
- package/dist/Flow.js +45 -2
- package/dist/Flow.js.map +1 -1
- package/dist/FlowContext.d.ts +7 -0
- package/dist/FlowContext.d.ts.map +1 -1
- package/dist/FlowContext.js.map +1 -1
- package/dist/FlowEvents.d.ts +44 -1
- package/dist/FlowEvents.d.ts.map +1 -1
- package/dist/FlowEvents.js +35 -0
- package/dist/FlowEvents.js.map +1 -1
- package/dist/GitTool.d.ts +7 -0
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +15 -1
- package/dist/GitTool.js.map +1 -1
- package/dist/Judgment.d.ts +88 -0
- package/dist/Judgment.d.ts.map +1 -0
- package/dist/Judgment.js +281 -0
- package/dist/Judgment.js.map +1 -0
- package/dist/JudgmentDataset.d.ts +79 -0
- package/dist/JudgmentDataset.d.ts.map +1 -0
- package/dist/JudgmentDataset.js +164 -0
- package/dist/JudgmentDataset.js.map +1 -0
- package/dist/JudgmentEval.d.ts +143 -0
- package/dist/JudgmentEval.d.ts.map +1 -0
- package/dist/JudgmentEval.js +242 -0
- package/dist/JudgmentEval.js.map +1 -0
- package/dist/JudgmentLog.d.ts +46 -0
- package/dist/JudgmentLog.d.ts.map +1 -0
- package/dist/JudgmentLog.js +62 -0
- package/dist/JudgmentLog.js.map +1 -0
- package/dist/JudgmentTypes.d.ts +13 -0
- package/dist/JudgmentTypes.d.ts.map +1 -0
- package/dist/JudgmentTypes.js +11 -0
- package/dist/JudgmentTypes.js.map +1 -0
- package/dist/ProgramJudge.d.ts +15 -2
- package/dist/ProgramJudge.d.ts.map +1 -1
- package/dist/ProgramJudge.js +58 -3
- package/dist/ProgramJudge.js.map +1 -1
- package/dist/Replay.d.ts.map +1 -1
- package/dist/Replay.js +9 -4
- package/dist/Replay.js.map +1 -1
- package/dist/Review.d.ts +35 -0
- package/dist/Review.d.ts.map +1 -1
- package/dist/Review.js +92 -13
- package/dist/Review.js.map +1 -1
- package/dist/Reviewer.d.ts +8 -0
- package/dist/Reviewer.d.ts.map +1 -1
- package/dist/Reviewer.js +12 -1
- package/dist/Reviewer.js.map +1 -1
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +1 -0
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +8 -3
- package/src/Classified.ts +5 -0
- package/src/CostReport.ts +402 -0
- package/src/EstimatedUsage.ts +15 -0
- package/src/Flow.ts +77 -2
- package/src/FlowContext.ts +7 -0
- package/src/FlowEvents.ts +42 -0
- package/src/GitTool.ts +19 -1
- package/src/Judgment.ts +418 -0
- package/src/JudgmentDataset.ts +254 -0
- package/src/JudgmentEval.ts +306 -0
- package/src/JudgmentLog.ts +96 -0
- package/src/JudgmentTypes.ts +15 -0
- package/src/ProgramJudge.ts +89 -4
- package/src/Replay.ts +23 -17
- package/src/Review.ts +163 -13
- package/src/Reviewer.ts +15 -1
- package/src/TransientRetry.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llm4ts/flow",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.6.0",
|
|
4
4
|
"description": "Effect-native LLM workflow, persistence, review, and repository automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"./Classified": "./dist/Classified.js",
|
|
28
28
|
"./Context": "./dist/Context.js",
|
|
29
29
|
"./CostLedger": "./dist/CostLedger.js",
|
|
30
|
+
"./CostReport": "./dist/CostReport.js",
|
|
30
31
|
"./CostTracker": "./dist/CostTracker.js",
|
|
31
32
|
"./Decisions": "./dist/Decisions.js",
|
|
32
33
|
"./Domains": "./dist/Domains.js",
|
|
@@ -47,6 +48,8 @@
|
|
|
47
48
|
"./Package": "./dist/Package.js",
|
|
48
49
|
"./PageSpec": "./dist/PageSpec.js",
|
|
49
50
|
"./Patterns": "./dist/Patterns.js",
|
|
51
|
+
"./Judgment": "./dist/Judgment.js",
|
|
52
|
+
"./JudgmentLog": "./dist/JudgmentLog.js",
|
|
50
53
|
"./Perimeter": "./dist/Perimeter.js",
|
|
51
54
|
"./Persistence": "./dist/Persistence.js",
|
|
52
55
|
"./Plan": "./dist/Plan.js",
|
|
@@ -69,7 +72,9 @@
|
|
|
69
72
|
"./Wall": "./dist/Wall.js",
|
|
70
73
|
"./Workspace": "./dist/Workspace.js",
|
|
71
74
|
"./WorkspaceLayout": "./dist/WorkspaceLayout.js",
|
|
72
|
-
"./WorkspaceTools": "./dist/WorkspaceTools.js"
|
|
75
|
+
"./WorkspaceTools": "./dist/WorkspaceTools.js",
|
|
76
|
+
"./JudgmentDataset": "./dist/JudgmentDataset.js",
|
|
77
|
+
"./JudgmentEval": "./dist/JudgmentEval.js"
|
|
73
78
|
},
|
|
74
79
|
"publishConfig": {
|
|
75
80
|
"access": "public",
|
|
@@ -93,7 +98,7 @@
|
|
|
93
98
|
"typescript"
|
|
94
99
|
],
|
|
95
100
|
"dependencies": {
|
|
96
|
-
"@llm4ts/core": "2.
|
|
101
|
+
"@llm4ts/core": "2.6.0"
|
|
97
102
|
},
|
|
98
103
|
"peerDependencies": {
|
|
99
104
|
"effect": "4.0.0-rc.115"
|
package/src/Classified.ts
CHANGED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import * as DateTime from "effect/DateTime"
|
|
2
|
+
import * as Option from "effect/Option"
|
|
3
|
+
import * as Schema from "effect/Schema"
|
|
4
|
+
import type { TokenUsage } from "@llm4ts/core/Models"
|
|
5
|
+
import { isEstimatedModel } from "./EstimatedUsage.ts"
|
|
6
|
+
import { TokensUsed } from "./FlowEvents.ts"
|
|
7
|
+
import type { TraceLine } from "./FlowRecorder.ts"
|
|
8
|
+
import { estimateCostUsd, PricesAsOf } from "./PriceList.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cross-run usage report for budgeting.
|
|
12
|
+
*
|
|
13
|
+
* A run's cost summary (`CostTracker`) answers "what did this run cost"; this
|
|
14
|
+
* module answers "what do my runs cost per day, per hour, and per run" from
|
|
15
|
+
* the `TokensUsed` lines every trace already records. Measured usage (a
|
|
16
|
+
* backend's own token counts) and estimated usage (`EstimatedUsage`'s
|
|
17
|
+
* character-count fallback, labelled `estimated:<model>`) are kept in separate
|
|
18
|
+
* columns so a budget never mistakes one for the other.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const UnknownModel = "(unknown)"
|
|
22
|
+
|
|
23
|
+
export class UsageSample extends Schema.Class<UsageSample>("UsageSample")({
|
|
24
|
+
/** Epoch milliseconds of the token report. */
|
|
25
|
+
at: Schema.Number,
|
|
26
|
+
runId: Schema.String,
|
|
27
|
+
agent: Schema.String,
|
|
28
|
+
model: Schema.String,
|
|
29
|
+
prompt: Schema.Int,
|
|
30
|
+
completion: Schema.Int,
|
|
31
|
+
total: Schema.Int,
|
|
32
|
+
cached: Schema.optionalKey(Schema.Int),
|
|
33
|
+
costUsd: Schema.optionalKey(Schema.Number),
|
|
34
|
+
estimated: Schema.Boolean
|
|
35
|
+
}) {}
|
|
36
|
+
|
|
37
|
+
export class UsageTotals extends Schema.Class<UsageTotals>("UsageTotals")({
|
|
38
|
+
requests: Schema.Int,
|
|
39
|
+
prompt: Schema.Int,
|
|
40
|
+
completion: Schema.Int,
|
|
41
|
+
total: Schema.Int,
|
|
42
|
+
cached: Schema.optionalKey(Schema.Int),
|
|
43
|
+
costUsd: Schema.optionalKey(Schema.Number)
|
|
44
|
+
}) {}
|
|
45
|
+
|
|
46
|
+
export class UsageBucket extends Schema.Class<UsageBucket>("UsageBucket")({
|
|
47
|
+
/** `YYYY-MM-DD` for a day, `YYYY-MM-DD HH:00` for an hour, in the report's zone. */
|
|
48
|
+
key: Schema.String,
|
|
49
|
+
runs: Schema.Int,
|
|
50
|
+
measured: UsageTotals,
|
|
51
|
+
estimated: UsageTotals
|
|
52
|
+
}) {}
|
|
53
|
+
|
|
54
|
+
export class ModelUsage extends Schema.Class<ModelUsage>("ModelUsage")({
|
|
55
|
+
model: Schema.String,
|
|
56
|
+
estimated: Schema.Boolean,
|
|
57
|
+
totals: UsageTotals
|
|
58
|
+
}) {}
|
|
59
|
+
|
|
60
|
+
export class UsageAverages extends Schema.Class<UsageAverages>("UsageAverages")({
|
|
61
|
+
activeDays: Schema.Int,
|
|
62
|
+
calendarDays: Schema.Int,
|
|
63
|
+
activeHours: Schema.Int,
|
|
64
|
+
tokensPerActiveDay: Schema.Int,
|
|
65
|
+
tokensPerCalendarDay: Schema.Int,
|
|
66
|
+
tokensPerActiveHour: Schema.Int,
|
|
67
|
+
tokensPerRun: Schema.Int,
|
|
68
|
+
costUsdPerActiveDay: Schema.optionalKey(Schema.Number),
|
|
69
|
+
costUsdPerCalendarDay: Schema.optionalKey(Schema.Number),
|
|
70
|
+
costUsdPerActiveHour: Schema.optionalKey(Schema.Number),
|
|
71
|
+
costUsdPerRun: Schema.optionalKey(Schema.Number),
|
|
72
|
+
peakDay: Schema.optionalKey(Schema.String),
|
|
73
|
+
peakHour: Schema.optionalKey(Schema.String)
|
|
74
|
+
}) {}
|
|
75
|
+
|
|
76
|
+
export class UsageProjection extends Schema.Class<UsageProjection>("UsageProjection")({
|
|
77
|
+
runsPerDay: Schema.Number,
|
|
78
|
+
tokensPerDay: Schema.Int,
|
|
79
|
+
costUsdPerDay: Schema.optionalKey(Schema.Number)
|
|
80
|
+
}) {}
|
|
81
|
+
|
|
82
|
+
export class CostReport extends Schema.Class<CostReport>("CostReport")({
|
|
83
|
+
schemaVersion: Schema.Int,
|
|
84
|
+
timeZone: Schema.String,
|
|
85
|
+
pricesAsOf: Schema.String,
|
|
86
|
+
from: Schema.optionalKey(Schema.String),
|
|
87
|
+
to: Schema.optionalKey(Schema.String),
|
|
88
|
+
runs: Schema.Int,
|
|
89
|
+
requests: Schema.Int,
|
|
90
|
+
measured: UsageTotals,
|
|
91
|
+
estimated: UsageTotals,
|
|
92
|
+
averages: UsageAverages,
|
|
93
|
+
projection: Schema.optionalKey(UsageProjection),
|
|
94
|
+
byDay: Schema.Array(UsageBucket),
|
|
95
|
+
byHour: Schema.Array(UsageBucket),
|
|
96
|
+
byModel: Schema.Array(ModelUsage)
|
|
97
|
+
}) {}
|
|
98
|
+
|
|
99
|
+
export const CurrentCostReportSchema = 1
|
|
100
|
+
|
|
101
|
+
const tokensUsedCodec = Schema.fromJsonString(TokensUsed)
|
|
102
|
+
const decodeTokensUsed = Schema.decodeUnknownOption(tokensUsedCodec)
|
|
103
|
+
|
|
104
|
+
// Backend-reported cost is authoritative; the pricing table only fills in
|
|
105
|
+
// when the backend said nothing (the same rule as `CostTracker`).
|
|
106
|
+
const costOf = (model: string, usage: TokenUsage): number | undefined =>
|
|
107
|
+
usage.costUsd ?? (model === UnknownModel ? undefined : estimateCostUsd(model, usage))
|
|
108
|
+
|
|
109
|
+
/** The token reports of one trace, in trace order; malformed lines are skipped. */
|
|
110
|
+
export const usageSamplesFromTrace = (
|
|
111
|
+
lines: ReadonlyArray<TraceLine>
|
|
112
|
+
): ReadonlyArray<UsageSample> =>
|
|
113
|
+
lines.flatMap((line) => {
|
|
114
|
+
if (line.kind !== "TokensUsed") {
|
|
115
|
+
return []
|
|
116
|
+
}
|
|
117
|
+
const raw = line.fields.event
|
|
118
|
+
if (typeof raw !== "string") {
|
|
119
|
+
return []
|
|
120
|
+
}
|
|
121
|
+
const decoded = decodeTokensUsed(raw)
|
|
122
|
+
if (Option.isNone(decoded)) {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
const event = decoded.value
|
|
126
|
+
const model = event.model ?? UnknownModel
|
|
127
|
+
const costUsd = costOf(model, event.usage)
|
|
128
|
+
return [
|
|
129
|
+
UsageSample.make({
|
|
130
|
+
at: line.timestamp,
|
|
131
|
+
runId: line.runId,
|
|
132
|
+
agent: event.agent,
|
|
133
|
+
model,
|
|
134
|
+
prompt: event.usage.prompt,
|
|
135
|
+
completion: event.usage.completion,
|
|
136
|
+
total: event.usage.total,
|
|
137
|
+
...(event.usage.cached === undefined ? {} : { cached: event.usage.cached }),
|
|
138
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
139
|
+
estimated: isEstimatedModel(model)
|
|
140
|
+
})
|
|
141
|
+
]
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
const sumOptional = (values: ReadonlyArray<number | undefined>): number | undefined => {
|
|
145
|
+
const present = values.flatMap((value) => (value === undefined ? [] : [value]))
|
|
146
|
+
return present.length === 0 ? undefined : present.reduce((sum, value) => sum + value, 0)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const totalsOf = (samples: ReadonlyArray<UsageSample>): UsageTotals => {
|
|
150
|
+
const cached = sumOptional(samples.map((sample) => sample.cached))
|
|
151
|
+
const costUsd = sumOptional(samples.map((sample) => sample.costUsd))
|
|
152
|
+
return UsageTotals.make({
|
|
153
|
+
requests: samples.length,
|
|
154
|
+
prompt: samples.reduce((sum, sample) => sum + sample.prompt, 0),
|
|
155
|
+
completion: samples.reduce((sum, sample) => sum + sample.completion, 0),
|
|
156
|
+
total: samples.reduce((sum, sample) => sum + sample.total, 0),
|
|
157
|
+
...(cached === undefined ? {} : { cached }),
|
|
158
|
+
...(costUsd === undefined ? {} : { costUsd })
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const distinctRuns = (samples: ReadonlyArray<UsageSample>): number =>
|
|
163
|
+
new Set(samples.map((sample) => sample.runId)).size
|
|
164
|
+
|
|
165
|
+
const pad = (value: number): string => value.toString().padStart(2, "0")
|
|
166
|
+
|
|
167
|
+
interface ZonedParts {
|
|
168
|
+
readonly year: number
|
|
169
|
+
readonly month: number
|
|
170
|
+
readonly day: number
|
|
171
|
+
readonly hour: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const partsIn = (timeZone: DateTime.TimeZone, at: number): ZonedParts => {
|
|
175
|
+
const parts = DateTime.toParts(DateTime.makeZonedUnsafe(at, { timeZone }))
|
|
176
|
+
return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const dayKey = (parts: ZonedParts): string => `${parts.year}-${pad(parts.month)}-${pad(parts.day)}`
|
|
180
|
+
|
|
181
|
+
const hourKey = (parts: ZonedParts): string => `${dayKey(parts)} ${pad(parts.hour)}:00`
|
|
182
|
+
|
|
183
|
+
const zoneLabel = (timeZone: DateTime.TimeZone): string =>
|
|
184
|
+
timeZone._tag === "Named"
|
|
185
|
+
? timeZone.id
|
|
186
|
+
: timeZone.offset === 0
|
|
187
|
+
? "UTC"
|
|
188
|
+
: DateTime.zoneToString(timeZone)
|
|
189
|
+
|
|
190
|
+
const bucketsBy = (
|
|
191
|
+
samples: ReadonlyArray<UsageSample>,
|
|
192
|
+
keyOf: (sample: UsageSample) => string
|
|
193
|
+
): ReadonlyArray<UsageBucket> => {
|
|
194
|
+
const groups = new Map<string, Array<UsageSample>>()
|
|
195
|
+
for (const sample of samples) {
|
|
196
|
+
const key = keyOf(sample)
|
|
197
|
+
groups.set(key, [...(groups.get(key) ?? []), sample])
|
|
198
|
+
}
|
|
199
|
+
return [...groups.entries()]
|
|
200
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
201
|
+
.map(([key, members]) =>
|
|
202
|
+
UsageBucket.make({
|
|
203
|
+
key,
|
|
204
|
+
runs: distinctRuns(members),
|
|
205
|
+
measured: totalsOf(members.filter((sample) => !sample.estimated)),
|
|
206
|
+
estimated: totalsOf(members.filter((sample) => sample.estimated))
|
|
207
|
+
})
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const bucketTokens = (bucket: UsageBucket): number => bucket.measured.total + bucket.estimated.total
|
|
212
|
+
|
|
213
|
+
const bucketCost = (bucket: UsageBucket): number | undefined =>
|
|
214
|
+
sumOptional([bucket.measured.costUsd, bucket.estimated.costUsd])
|
|
215
|
+
|
|
216
|
+
/** The first bucket carrying the most tokens, in chronological order. */
|
|
217
|
+
const peakOf = (buckets: ReadonlyArray<UsageBucket>): UsageBucket | undefined =>
|
|
218
|
+
buckets.reduce<UsageBucket | undefined>(
|
|
219
|
+
(best, bucket) =>
|
|
220
|
+
best === undefined || bucketTokens(bucket) > bucketTokens(best) ? bucket : best,
|
|
221
|
+
undefined
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const calendarDaysBetween = (first: ZonedParts, last: ZonedParts): number =>
|
|
225
|
+
Math.round(
|
|
226
|
+
(Date.UTC(last.year, last.month - 1, last.day) -
|
|
227
|
+
Date.UTC(first.year, first.month - 1, first.day)) /
|
|
228
|
+
86_400_000
|
|
229
|
+
) + 1
|
|
230
|
+
|
|
231
|
+
const ratio = (value: number, count: number): number => (count === 0 ? 0 : value / count)
|
|
232
|
+
|
|
233
|
+
const costRatio = (cost: number | undefined, count: number): number | undefined =>
|
|
234
|
+
cost === undefined || count === 0 ? undefined : cost / count
|
|
235
|
+
|
|
236
|
+
export interface CostReportOptions {
|
|
237
|
+
/** Zone the day and hour buckets are cut in. Default UTC. */
|
|
238
|
+
readonly timeZone?: DateTime.TimeZone
|
|
239
|
+
/** Assumed daily run count for the projection line; none when absent. */
|
|
240
|
+
readonly runsPerDay?: number
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const buildCostReport = (
|
|
244
|
+
samples: ReadonlyArray<UsageSample>,
|
|
245
|
+
options: CostReportOptions = {}
|
|
246
|
+
): CostReport => {
|
|
247
|
+
const timeZone = options.timeZone ?? DateTime.zoneMakeOffset(0)
|
|
248
|
+
const ordered = [...samples].sort((left, right) => left.at - right.at)
|
|
249
|
+
const byDay = bucketsBy(ordered, (sample) => dayKey(partsIn(timeZone, sample.at)))
|
|
250
|
+
const byHour = bucketsBy(ordered, (sample) => hourKey(partsIn(timeZone, sample.at)))
|
|
251
|
+
const measured = totalsOf(ordered.filter((sample) => !sample.estimated))
|
|
252
|
+
const estimated = totalsOf(ordered.filter((sample) => sample.estimated))
|
|
253
|
+
const tokens = measured.total + estimated.total
|
|
254
|
+
const cost = sumOptional([measured.costUsd, estimated.costUsd])
|
|
255
|
+
const runs = distinctRuns(ordered)
|
|
256
|
+
const first = ordered[0]
|
|
257
|
+
const last = ordered[ordered.length - 1]
|
|
258
|
+
const calendarDays =
|
|
259
|
+
first === undefined || last === undefined
|
|
260
|
+
? 0
|
|
261
|
+
: calendarDaysBetween(partsIn(timeZone, first.at), partsIn(timeZone, last.at))
|
|
262
|
+
const tokensPerRun = ratio(tokens, runs)
|
|
263
|
+
const costUsdPerRun = costRatio(cost, runs)
|
|
264
|
+
const costUsdPerActiveDay = costRatio(cost, byDay.length)
|
|
265
|
+
const costUsdPerCalendarDay = costRatio(cost, calendarDays)
|
|
266
|
+
const costUsdPerActiveHour = costRatio(cost, byHour.length)
|
|
267
|
+
const peakDay = peakOf(byDay)
|
|
268
|
+
const peakHour = peakOf(byHour)
|
|
269
|
+
const projection =
|
|
270
|
+
options.runsPerDay === undefined
|
|
271
|
+
? undefined
|
|
272
|
+
: UsageProjection.make({
|
|
273
|
+
runsPerDay: options.runsPerDay,
|
|
274
|
+
tokensPerDay: Math.round(tokensPerRun * options.runsPerDay),
|
|
275
|
+
...(costUsdPerRun === undefined
|
|
276
|
+
? {}
|
|
277
|
+
: { costUsdPerDay: costUsdPerRun * options.runsPerDay })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const byModelGroups = new Map<string, Array<UsageSample>>()
|
|
281
|
+
for (const sample of ordered) {
|
|
282
|
+
byModelGroups.set(sample.model, [...(byModelGroups.get(sample.model) ?? []), sample])
|
|
283
|
+
}
|
|
284
|
+
const byModel = [...byModelGroups.entries()]
|
|
285
|
+
.map(([model, members]) =>
|
|
286
|
+
ModelUsage.make({ model, estimated: isEstimatedModel(model), totals: totalsOf(members) })
|
|
287
|
+
)
|
|
288
|
+
.sort((left, right) =>
|
|
289
|
+
left.estimated === right.estimated
|
|
290
|
+
? left.model.localeCompare(right.model)
|
|
291
|
+
: left.estimated
|
|
292
|
+
? 1
|
|
293
|
+
: -1
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
return CostReport.make({
|
|
297
|
+
schemaVersion: CurrentCostReportSchema,
|
|
298
|
+
timeZone: zoneLabel(timeZone),
|
|
299
|
+
pricesAsOf: PricesAsOf,
|
|
300
|
+
...(first === undefined ? {} : { from: new Date(first.at).toISOString() }),
|
|
301
|
+
...(last === undefined ? {} : { to: new Date(last.at).toISOString() }),
|
|
302
|
+
runs,
|
|
303
|
+
requests: ordered.length,
|
|
304
|
+
measured,
|
|
305
|
+
estimated,
|
|
306
|
+
averages: UsageAverages.make({
|
|
307
|
+
activeDays: byDay.length,
|
|
308
|
+
calendarDays,
|
|
309
|
+
activeHours: byHour.length,
|
|
310
|
+
tokensPerActiveDay: Math.round(ratio(tokens, byDay.length)),
|
|
311
|
+
tokensPerCalendarDay: Math.round(ratio(tokens, calendarDays)),
|
|
312
|
+
tokensPerActiveHour: Math.round(ratio(tokens, byHour.length)),
|
|
313
|
+
tokensPerRun: Math.round(tokensPerRun),
|
|
314
|
+
...(costUsdPerActiveDay === undefined ? {} : { costUsdPerActiveDay }),
|
|
315
|
+
...(costUsdPerCalendarDay === undefined ? {} : { costUsdPerCalendarDay }),
|
|
316
|
+
...(costUsdPerActiveHour === undefined ? {} : { costUsdPerActiveHour }),
|
|
317
|
+
...(costUsdPerRun === undefined ? {} : { costUsdPerRun }),
|
|
318
|
+
...(peakDay === undefined ? {} : { peakDay: peakDay.key }),
|
|
319
|
+
...(peakHour === undefined ? {} : { peakHour: peakHour.key })
|
|
320
|
+
}),
|
|
321
|
+
...(projection === undefined ? {} : { projection }),
|
|
322
|
+
byDay,
|
|
323
|
+
byHour,
|
|
324
|
+
byModel
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const thousands = (value: number): string =>
|
|
329
|
+
Math.round(value)
|
|
330
|
+
.toString()
|
|
331
|
+
.replace(/\B(?=(\d{3})+(?!\d))/gu, ",")
|
|
332
|
+
|
|
333
|
+
const money = (cost: number | undefined): string =>
|
|
334
|
+
cost === undefined ? "" : ` ($${cost.toFixed(4)})`
|
|
335
|
+
|
|
336
|
+
const totalsLine = (label: string, totals: UsageTotals): string => {
|
|
337
|
+
const cached =
|
|
338
|
+
totals.cached === undefined || totals.cached <= 0 ? "" : ` (${thousands(totals.cached)} cached)`
|
|
339
|
+
return `${label}: ${thousands(totals.prompt)} in${cached}, ${thousands(totals.completion)} out, ${thousands(totals.total)} total${money(totals.costUsd)}`
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const runsLabel = (runs: number): string => `${runs} run${runs === 1 ? "" : "s"}`
|
|
343
|
+
|
|
344
|
+
const bucketLine = (bucket: UsageBucket): string =>
|
|
345
|
+
` ${bucket.key} ${runsLabel(bucket.runs)} ${thousands(bucket.measured.total)} measured ${thousands(bucket.estimated.total)} estimated${money(bucketCost(bucket))}`
|
|
346
|
+
|
|
347
|
+
export const renderCostReport = (report: CostReport): string => {
|
|
348
|
+
if (report.from === undefined || report.to === undefined) {
|
|
349
|
+
return "llm4ts costs\n\nno usage recorded — run a flow whose backend reports token counts, then try again"
|
|
350
|
+
}
|
|
351
|
+
const averages = report.averages
|
|
352
|
+
const peakDay = peakOf(report.byDay)
|
|
353
|
+
const peakHour = peakOf(report.byHour)
|
|
354
|
+
const peaks = [
|
|
355
|
+
...(peakDay === undefined
|
|
356
|
+
? []
|
|
357
|
+
: [`peak day ${peakDay.key} (${thousands(bucketTokens(peakDay))})`]),
|
|
358
|
+
...(peakHour === undefined
|
|
359
|
+
? []
|
|
360
|
+
: [`peak hour ${peakHour.key} (${thousands(bucketTokens(peakHour))})`])
|
|
361
|
+
]
|
|
362
|
+
const sections: Array<string> = [
|
|
363
|
+
"llm4ts costs",
|
|
364
|
+
`window: ${report.from} → ${report.to} (${report.timeZone})`,
|
|
365
|
+
`runs: ${report.runs} · requests: ${report.requests}`,
|
|
366
|
+
totalsLine("measured", report.measured),
|
|
367
|
+
totalsLine("estimated", report.estimated),
|
|
368
|
+
"",
|
|
369
|
+
"averages (measured + estimated):",
|
|
370
|
+
` per active day: ${thousands(averages.tokensPerActiveDay)} tokens${money(averages.costUsdPerActiveDay)} over ${averages.activeDays} day${averages.activeDays === 1 ? "" : "s"}`,
|
|
371
|
+
` per calendar day: ${thousands(averages.tokensPerCalendarDay)} tokens${money(averages.costUsdPerCalendarDay)} over ${averages.calendarDays} day${averages.calendarDays === 1 ? "" : "s"}`,
|
|
372
|
+
` per active hour: ${thousands(averages.tokensPerActiveHour)} tokens${money(averages.costUsdPerActiveHour)} over ${averages.activeHours} hour${averages.activeHours === 1 ? "" : "s"}`,
|
|
373
|
+
` per run: ${thousands(averages.tokensPerRun)} tokens${money(averages.costUsdPerRun)}`,
|
|
374
|
+
...(peaks.length === 0 ? [] : [` ${peaks.join(", ")}`])
|
|
375
|
+
]
|
|
376
|
+
if (report.projection !== undefined) {
|
|
377
|
+
sections.push(
|
|
378
|
+
"projection:",
|
|
379
|
+
` at ${report.projection.runsPerDay} runs/day: ${thousands(report.projection.tokensPerDay)} tokens${money(report.projection.costUsdPerDay)} per day`
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
sections.push("", "by day:", ...report.byDay.map(bucketLine))
|
|
383
|
+
sections.push("", "by hour:", ...report.byHour.map(bucketLine))
|
|
384
|
+
sections.push(
|
|
385
|
+
"",
|
|
386
|
+
"by model:",
|
|
387
|
+
...report.byModel.map(
|
|
388
|
+
(entry) =>
|
|
389
|
+
` ${entry.model} ${thousands(entry.totals.total)} tokens${money(entry.totals.costUsd)}`
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
const notes = [
|
|
393
|
+
`costs are backend-reported where available, otherwise estimated from the pricing table (rates as of ${report.pricesAsOf} — may be stale)`,
|
|
394
|
+
...(report.estimated.requests === 0
|
|
395
|
+
? []
|
|
396
|
+
: [
|
|
397
|
+
"estimated:<model> rows come from character counts (LLM4TS_ESTIMATE_MODEL), not backend token counts"
|
|
398
|
+
])
|
|
399
|
+
]
|
|
400
|
+
sections.push("", ...notes.map((note) => `* ${note}`))
|
|
401
|
+
return sections.join("\n")
|
|
402
|
+
}
|
package/src/EstimatedUsage.ts
CHANGED
|
@@ -174,6 +174,21 @@ export const makeEstimatedUsageMeter = Effect.fn("@llm4ts/flow/EstimatedUsage.ma
|
|
|
174
174
|
)
|
|
175
175
|
})
|
|
176
176
|
),
|
|
177
|
+
scoreLabels: (prompt, labels) =>
|
|
178
|
+
service
|
|
179
|
+
.scoreLabels(prompt, labels)
|
|
180
|
+
.pipe(
|
|
181
|
+
Effect.tap((distribution) =>
|
|
182
|
+
record(
|
|
183
|
+
distribution.usage ??
|
|
184
|
+
estimateUsage(
|
|
185
|
+
prompt.length,
|
|
186
|
+
JSON.stringify(distribution.probabilities).length,
|
|
187
|
+
options
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
),
|
|
177
192
|
isAvailable: service.isAvailable
|
|
178
193
|
}
|
|
179
194
|
return { service: decorated, totals: Ref.get(totals) }
|
package/src/Flow.ts
CHANGED
|
@@ -5,10 +5,19 @@ import { withToolActivity } from "./Activity.ts"
|
|
|
5
5
|
import { makeChat, type Chat } from "./Chat.ts"
|
|
6
6
|
import type { FlowContextShape } from "./FlowContext.ts"
|
|
7
7
|
import { FlowAborted, FlowLlmError, type FlowError } from "./FlowError.ts"
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
AssistantMessage,
|
|
10
|
+
Info,
|
|
11
|
+
JudgmentObserved,
|
|
12
|
+
publishJudgmentObserved,
|
|
13
|
+
TokensUsed,
|
|
14
|
+
type FlowEventsShape
|
|
15
|
+
} from "./FlowEvents.ts"
|
|
9
16
|
import type { Plan, Task } from "./Plan.ts"
|
|
10
17
|
import type { PlanStoreShape } from "./Persistence.ts"
|
|
11
18
|
import { implementTaskLoop, stage } from "./PlanExecution.ts"
|
|
19
|
+
import { truth } from "@llm4ts/core/judgment/Schemas"
|
|
20
|
+
import { certaintyOf, decide, judgmentOf, type JudgmentMode } from "./Judgment.ts"
|
|
12
21
|
import { minimalReviewers, reviewAndFixLoop, type ReviewResult } from "./Review.ts"
|
|
13
22
|
import type { Reviewer } from "./Reviewer.ts"
|
|
14
23
|
|
|
@@ -65,8 +74,65 @@ export interface ImplementPlanOptions {
|
|
|
65
74
|
* where one unconfirmed no-op should not sink otherwise-finished work.
|
|
66
75
|
*/
|
|
67
76
|
readonly noopTaskPolicy?: "fail" | "complete"
|
|
77
|
+
/**
|
|
78
|
+
* How the "already satisfied" confirmation is read (ADR 0017): "literal"
|
|
79
|
+
* (default) looks for TASK_ALREADY_SATISFIED without any judgment call.
|
|
80
|
+
* { mode } asks the run's judgment service, defaulting to observe: observe
|
|
81
|
+
* and advise publish the literal outcome and keep using it. Explicit act
|
|
82
|
+
* uses a confident judgment, falling back to the literal reading on doubt
|
|
83
|
+
* or failure. The legacy "judgment" string remains an alias for { mode: "act" }.
|
|
84
|
+
*/
|
|
85
|
+
readonly satisfiedProbe?: "literal" | "judgment" | { readonly mode?: JudgmentMode }
|
|
68
86
|
}
|
|
69
87
|
|
|
88
|
+
/** The judgment form of the empty-diff probe: one Truth question over the coder's reply. */
|
|
89
|
+
export const satisfiedByJudgment = Effect.fn("@llm4ts/flow/Flow.satisfiedByJudgment")(function* (
|
|
90
|
+
context: FlowContextShape,
|
|
91
|
+
taskTitle: string,
|
|
92
|
+
reply: string,
|
|
93
|
+
mode: JudgmentMode = "observe"
|
|
94
|
+
): Effect.fn.Return<boolean | undefined> {
|
|
95
|
+
const literalMatch = reply.includes("TASK_ALREADY_SATISFIED")
|
|
96
|
+
const judgment = judgmentOf(context)
|
|
97
|
+
const state = { task: taskTitle, reply }
|
|
98
|
+
const question = truth(
|
|
99
|
+
"The reply states that the task is already fully satisfied by the current repository and that no change was made."
|
|
100
|
+
)
|
|
101
|
+
const result = yield* judgment
|
|
102
|
+
.judge({ state, questions: { satisfied: question } })
|
|
103
|
+
.pipe(Effect.option)
|
|
104
|
+
if (result._tag === "None") {
|
|
105
|
+
return mode === "act" ? undefined : literalMatch
|
|
106
|
+
}
|
|
107
|
+
const answer = result.value.answers["satisfied"]
|
|
108
|
+
if (answer === undefined || answer.type !== "truth") {
|
|
109
|
+
return mode === "act" ? undefined : literalMatch
|
|
110
|
+
}
|
|
111
|
+
const decision = decide(answer)
|
|
112
|
+
if (mode !== "act") {
|
|
113
|
+
yield* publishJudgmentObserved(
|
|
114
|
+
context.events,
|
|
115
|
+
JudgmentObserved.make({
|
|
116
|
+
consumer: "satisfied-probe",
|
|
117
|
+
key: "satisfied",
|
|
118
|
+
state,
|
|
119
|
+
question,
|
|
120
|
+
answer,
|
|
121
|
+
judgmentIdentity: judgment.identity,
|
|
122
|
+
decision,
|
|
123
|
+
certainty: certaintyOf(answer),
|
|
124
|
+
support: answer.support,
|
|
125
|
+
origin: answer.origin,
|
|
126
|
+
outcome: { _tag: "SatisfiedProbe", literalMatch },
|
|
127
|
+
mode
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
return literalMatch
|
|
131
|
+
}
|
|
132
|
+
if (decision !== "act") return undefined
|
|
133
|
+
return answer.truth >= 0.5
|
|
134
|
+
})
|
|
135
|
+
|
|
70
136
|
const defaultCommitMessage = (plan: Plan, task: Task): string => `${plan.epicId}: ${task.title}`
|
|
71
137
|
|
|
72
138
|
const composeSystem = (base: string | undefined, note: string): string =>
|
|
@@ -130,7 +196,16 @@ export const implementPlanFlow = Effect.fn("@llm4ts/flow/Flow.implementPlan")(fu
|
|
|
130
196
|
)
|
|
131
197
|
const afterConfirmation = yield* context.git.diffAll
|
|
132
198
|
if (afterConfirmation.trim().length === 0) {
|
|
133
|
-
|
|
199
|
+
const judged =
|
|
200
|
+
options.satisfiedProbe !== undefined && options.satisfiedProbe !== "literal"
|
|
201
|
+
? yield* satisfiedByJudgment(
|
|
202
|
+
context,
|
|
203
|
+
task.title,
|
|
204
|
+
confirmation,
|
|
205
|
+
options.satisfiedProbe === "judgment" ? "act" : options.satisfiedProbe.mode
|
|
206
|
+
)
|
|
207
|
+
: undefined
|
|
208
|
+
if (judged ?? confirmation.includes("TASK_ALREADY_SATISFIED")) {
|
|
134
209
|
yield* context.events.publish(
|
|
135
210
|
Info.make({
|
|
136
211
|
message: `task "${task.title}" confirmed already satisfied; skipping review and commit`
|
package/src/FlowContext.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as Context from "effect/Context"
|
|
2
2
|
import type * as Effect from "effect/Effect"
|
|
3
3
|
import type * as Scope from "effect/Scope"
|
|
4
|
+
import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
|
|
4
5
|
import type { ConnectorCapabilities } from "@llm4ts/core/Models"
|
|
5
6
|
import type { LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
6
7
|
import type { FlowError } from "./FlowError.ts"
|
|
@@ -19,6 +20,12 @@ export interface FlowContextShape {
|
|
|
19
20
|
readonly userPrompt: string
|
|
20
21
|
readonly workDir: string
|
|
21
22
|
readonly workspace: string
|
|
23
|
+
/**
|
|
24
|
+
* Typed judgments (ADR 0017), over the run's `judgment` seat or the hosted
|
|
25
|
+
* TypeSafe backend. Absent on a context built by hand; `judgmentOf` in
|
|
26
|
+
* `Judgment.ts` then derives one from the reasoning seat.
|
|
27
|
+
*/
|
|
28
|
+
readonly judgment?: JudgmentShape
|
|
22
29
|
/**
|
|
23
30
|
* The same seats rebound to another directory (a story worktree, ADR
|
|
24
31
|
* 0013): every CLI seat launched there, git rooted there, the run's
|
package/src/FlowEvents.ts
CHANGED
|
@@ -7,6 +7,33 @@ import * as Schema from "effect/Schema"
|
|
|
7
7
|
import type * as Scope from "effect/Scope"
|
|
8
8
|
import * as Stream from "effect/Stream"
|
|
9
9
|
import { TokenUsage } from "@llm4ts/core/Models"
|
|
10
|
+
import { Answer, AnswerOrigin, Question, State } from "@llm4ts/core/judgment/Schemas"
|
|
11
|
+
import { Decision, JudgmentMode } from "./JudgmentTypes.ts"
|
|
12
|
+
|
|
13
|
+
export const JudgmentOutcome = Schema.Union([
|
|
14
|
+
Schema.TaggedStruct("ReviewPrescreen", {
|
|
15
|
+
lens: Schema.String,
|
|
16
|
+
issues: Schema.Struct({ Critical: Schema.Int, Warning: Schema.Int, Info: Schema.Int })
|
|
17
|
+
}),
|
|
18
|
+
Schema.TaggedStruct("SatisfiedProbe", { literalMatch: Schema.Boolean }),
|
|
19
|
+
Schema.TaggedStruct("ProgramJudge", { score: Schema.Number })
|
|
20
|
+
])
|
|
21
|
+
export type JudgmentOutcome = typeof JudgmentOutcome.Type
|
|
22
|
+
|
|
23
|
+
export class JudgmentObserved extends Schema.TaggedClass<JudgmentObserved>()("JudgmentObserved", {
|
|
24
|
+
consumer: Schema.Literals(["review-prescreen", "satisfied-probe", "program-judge"]),
|
|
25
|
+
key: Schema.String,
|
|
26
|
+
state: State,
|
|
27
|
+
question: Question,
|
|
28
|
+
answer: Answer,
|
|
29
|
+
judgmentIdentity: Schema.String,
|
|
30
|
+
decision: Decision,
|
|
31
|
+
certainty: Schema.Number,
|
|
32
|
+
support: Schema.Number,
|
|
33
|
+
origin: AnswerOrigin,
|
|
34
|
+
outcome: JudgmentOutcome,
|
|
35
|
+
mode: JudgmentMode
|
|
36
|
+
}) {}
|
|
10
37
|
|
|
11
38
|
export class StageStarted extends Schema.TaggedClass<StageStarted>()("StageStarted", {
|
|
12
39
|
stage: Schema.String
|
|
@@ -72,6 +99,7 @@ export class Declassified extends Schema.TaggedClass<Declassified>()("Declassifi
|
|
|
72
99
|
}) {}
|
|
73
100
|
|
|
74
101
|
export const FlowEvent = Schema.Union([
|
|
102
|
+
JudgmentObserved,
|
|
75
103
|
StageStarted,
|
|
76
104
|
StageCompleted,
|
|
77
105
|
StageFailed,
|
|
@@ -101,6 +129,20 @@ export interface FlowEventsShape {
|
|
|
101
129
|
readonly publish: (event: FlowEvent) => Effect.Effect<void>
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
/** Publish an observation, rendering the same decision and outcome in advise mode. */
|
|
133
|
+
export const publishJudgmentObserved = Effect.fn("@llm4ts/flow/FlowEvents.publishJudgmentObserved")(
|
|
134
|
+
function* (events: FlowEventsShape, observation: JudgmentObserved): Effect.fn.Return<void> {
|
|
135
|
+
yield* events.publish(observation)
|
|
136
|
+
if (observation.mode === "advise") {
|
|
137
|
+
yield* events.publish(
|
|
138
|
+
Info.make({
|
|
139
|
+
message: `judgment ${observation.consumer} '${observation.key}': ${observation.decision}; outcome ${JSON.stringify(observation.outcome)}`
|
|
140
|
+
})
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
104
146
|
export class FlowEvents extends Context.Service<FlowEvents, FlowEventsShape>()(
|
|
105
147
|
"@llm4ts/flow/FlowEvents"
|
|
106
148
|
) {}
|