snow-flow-test 10.0.1-test.113 → 10.0.1-test.114
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/package.json +1 -1
- package/src/session/prompt.ts +4 -0
- package/src/usage/index.ts +1 -0
- package/src/usage/reporter.ts +237 -0
package/package.json
CHANGED
package/src/session/prompt.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
|
|
|
45
45
|
import { iife } from "@/util/iife"
|
|
46
46
|
import { Shell } from "@/shell/shell"
|
|
47
47
|
import { Truncate } from "@/tool/truncation"
|
|
48
|
+
import { UsageReporter } from "@/usage"
|
|
48
49
|
|
|
49
50
|
// @ts-ignore
|
|
50
51
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
|
@@ -152,6 +153,9 @@ export namespace SessionPrompt {
|
|
|
152
153
|
const session = await Session.get(input.sessionID)
|
|
153
154
|
await SessionRevert.cleanup(session)
|
|
154
155
|
|
|
156
|
+
// Initialize TUI usage reporting (lazy, no-op if no enterprise auth)
|
|
157
|
+
UsageReporter.init()
|
|
158
|
+
|
|
155
159
|
const message = await createUserMessage(input)
|
|
156
160
|
await Session.touch(input.sessionID)
|
|
157
161
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UsageReporter } from "./reporter"
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Instance } from "@/project/instance"
|
|
2
|
+
import { Bus } from "@/bus"
|
|
3
|
+
import { MessageV2 } from "@/session/message-v2"
|
|
4
|
+
import { Auth } from "@/auth"
|
|
5
|
+
import { Log } from "@/util/log"
|
|
6
|
+
|
|
7
|
+
const log = Log.create({ service: "usage.reporter" })
|
|
8
|
+
|
|
9
|
+
const BUILTIN_TOOLS = new Set([
|
|
10
|
+
"Read",
|
|
11
|
+
"Write",
|
|
12
|
+
"Edit",
|
|
13
|
+
"Bash",
|
|
14
|
+
"Glob",
|
|
15
|
+
"Grep",
|
|
16
|
+
"Task",
|
|
17
|
+
"WebFetch",
|
|
18
|
+
"WebSearch",
|
|
19
|
+
"Skill",
|
|
20
|
+
"TodoRead",
|
|
21
|
+
"TodoWrite",
|
|
22
|
+
"NotebookEdit",
|
|
23
|
+
"AskUserQuestion",
|
|
24
|
+
"EnterPlanMode",
|
|
25
|
+
"ExitPlanMode",
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
interface UsageEvent {
|
|
29
|
+
eventType: "llm" | "tool"
|
|
30
|
+
sessionId: string
|
|
31
|
+
machineId?: string
|
|
32
|
+
model?: string
|
|
33
|
+
provider?: string
|
|
34
|
+
agent?: string
|
|
35
|
+
tokensInput?: number
|
|
36
|
+
tokensOutput?: number
|
|
37
|
+
tokensReasoning?: number
|
|
38
|
+
tokensCacheRead?: number
|
|
39
|
+
tokensCacheWrite?: number
|
|
40
|
+
costUsd?: number
|
|
41
|
+
toolName?: string
|
|
42
|
+
toolCategory?: string
|
|
43
|
+
durationMs?: number
|
|
44
|
+
success?: boolean
|
|
45
|
+
errorMessage?: string
|
|
46
|
+
timestamp: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AuthCache {
|
|
50
|
+
licenseKey: string
|
|
51
|
+
portalUrl: string
|
|
52
|
+
cachedAt: number
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const AUTH_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
|
56
|
+
const FLUSH_INTERVAL = 30_000 // 30 seconds
|
|
57
|
+
const MAX_BATCH = 500
|
|
58
|
+
|
|
59
|
+
export namespace UsageReporter {
|
|
60
|
+
// Message metadata cache: maps messageID -> { modelID, providerID, agent }
|
|
61
|
+
// Populated from Message.Updated events so we can correlate step-finish parts
|
|
62
|
+
const messageMetadata = new Map<string, { modelID: string; providerID: string; agent: string }>()
|
|
63
|
+
|
|
64
|
+
const state = Instance.state(
|
|
65
|
+
() => {
|
|
66
|
+
const queue: UsageEvent[] = []
|
|
67
|
+
let authCache: AuthCache | undefined
|
|
68
|
+
let flushTimer: ReturnType<typeof setInterval> | undefined
|
|
69
|
+
|
|
70
|
+
const unsubs = [
|
|
71
|
+
// Track assistant message metadata for correlating step-finish parts
|
|
72
|
+
Bus.subscribe(MessageV2.Event.Updated, (event) => {
|
|
73
|
+
const info = event.properties.info
|
|
74
|
+
if (info.role !== "assistant") return
|
|
75
|
+
messageMetadata.set(info.id, {
|
|
76
|
+
modelID: info.modelID,
|
|
77
|
+
providerID: info.providerID,
|
|
78
|
+
agent: info.agent,
|
|
79
|
+
})
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
// Track part updates for usage events
|
|
83
|
+
Bus.subscribe(MessageV2.Event.PartUpdated, (event) => {
|
|
84
|
+
const part = event.properties.part
|
|
85
|
+
|
|
86
|
+
if (part.type === "step-finish") {
|
|
87
|
+
const meta = messageMetadata.get(part.messageID)
|
|
88
|
+
queue.push({
|
|
89
|
+
eventType: "llm",
|
|
90
|
+
sessionId: part.sessionID,
|
|
91
|
+
model: meta?.modelID,
|
|
92
|
+
provider: meta?.providerID,
|
|
93
|
+
agent: meta?.agent,
|
|
94
|
+
tokensInput: part.tokens.input,
|
|
95
|
+
tokensOutput: part.tokens.output,
|
|
96
|
+
tokensReasoning: part.tokens.reasoning,
|
|
97
|
+
tokensCacheRead: part.tokens.cache.read,
|
|
98
|
+
tokensCacheWrite: part.tokens.cache.write,
|
|
99
|
+
costUsd: part.cost,
|
|
100
|
+
timestamp: Date.now(),
|
|
101
|
+
})
|
|
102
|
+
if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (part.type === "tool") {
|
|
106
|
+
if (part.state.status === "completed") {
|
|
107
|
+
queue.push({
|
|
108
|
+
eventType: "tool",
|
|
109
|
+
sessionId: part.sessionID,
|
|
110
|
+
toolName: part.tool,
|
|
111
|
+
toolCategory: BUILTIN_TOOLS.has(part.tool) ? "builtin" : "mcp",
|
|
112
|
+
durationMs: part.state.time.end - part.state.time.start,
|
|
113
|
+
success: true,
|
|
114
|
+
timestamp: Date.now(),
|
|
115
|
+
})
|
|
116
|
+
if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (part.state.status === "error") {
|
|
120
|
+
queue.push({
|
|
121
|
+
eventType: "tool",
|
|
122
|
+
sessionId: part.sessionID,
|
|
123
|
+
toolName: part.tool,
|
|
124
|
+
toolCategory: BUILTIN_TOOLS.has(part.tool) ? "builtin" : "mcp",
|
|
125
|
+
durationMs: part.state.time.end - part.state.time.start,
|
|
126
|
+
success: false,
|
|
127
|
+
errorMessage: part.state.error,
|
|
128
|
+
timestamp: Date.now(),
|
|
129
|
+
})
|
|
130
|
+
if (queue.length >= MAX_BATCH) flush(queue, authCache).catch(() => {})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}),
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
// Start periodic flush
|
|
137
|
+
resolveAuth().then((auth) => {
|
|
138
|
+
if (!auth) {
|
|
139
|
+
log.info("no enterprise/portal auth found, TUI usage reporting disabled")
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
authCache = auth
|
|
143
|
+
flushTimer = setInterval(() => {
|
|
144
|
+
flush(queue, authCache).catch(() => {})
|
|
145
|
+
}, FLUSH_INTERVAL)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return { queue, unsubs, flushTimer, authCache }
|
|
149
|
+
},
|
|
150
|
+
async (current) => {
|
|
151
|
+
// Dispose: final flush + cleanup
|
|
152
|
+
if (current.flushTimer) clearInterval(current.flushTimer)
|
|
153
|
+
for (const unsub of current.unsubs) unsub()
|
|
154
|
+
if (current.queue.length > 0 && current.authCache) {
|
|
155
|
+
await flush(current.queue, current.authCache).catch(() => {})
|
|
156
|
+
}
|
|
157
|
+
messageMetadata.clear()
|
|
158
|
+
},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
/** Initialize the reporter. Call once per Instance lifecycle. */
|
|
162
|
+
export function init() {
|
|
163
|
+
state() // triggers lazy init
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function resolveAuth(): Promise<AuthCache | undefined> {
|
|
168
|
+
const all = await Auth.all()
|
|
169
|
+
|
|
170
|
+
// Check enterprise auth first
|
|
171
|
+
for (const entry of Object.values(all)) {
|
|
172
|
+
if (entry.type === "enterprise" && entry.licenseKey) {
|
|
173
|
+
return {
|
|
174
|
+
licenseKey: entry.licenseKey,
|
|
175
|
+
portalUrl: entry.enterpriseUrl || process.env.SNOW_FLOW_PORTAL_URL || "https://portal.snow-flow.dev",
|
|
176
|
+
cachedAt: Date.now(),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check portal auth (Teams users have organization license key)
|
|
182
|
+
// Portal auth doesn't carry a license key directly, but we can use the token
|
|
183
|
+
// The portal backend also accepts organization license keys
|
|
184
|
+
for (const entry of Object.values(all)) {
|
|
185
|
+
if (entry.type === "portal") {
|
|
186
|
+
// Portal users need their org license key; we don't have it directly
|
|
187
|
+
// They authenticate via token, not license key
|
|
188
|
+
// For now, skip portal-only users (they use the portal chat which already tracks usage)
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function flush(queue: UsageEvent[], authCache: AuthCache | undefined): Promise<void> {
|
|
197
|
+
if (queue.length === 0 || !authCache) return
|
|
198
|
+
|
|
199
|
+
// Drain the queue
|
|
200
|
+
const batch = queue.splice(0, MAX_BATCH)
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const response = await fetch(`${authCache.portalUrl}/api/tui/usage/ingest`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
"X-License-Key": authCache.licenseKey,
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({ events: batch }),
|
|
210
|
+
signal: AbortSignal.timeout(10_000),
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
if (response.status === 401) {
|
|
215
|
+
log.warn("TUI usage flush got 401, clearing auth cache")
|
|
216
|
+
// Re-resolve auth on next flush
|
|
217
|
+
const fresh = await resolveAuth()
|
|
218
|
+
if (fresh) {
|
|
219
|
+
authCache.licenseKey = fresh.licenseKey
|
|
220
|
+
authCache.portalUrl = fresh.portalUrl
|
|
221
|
+
authCache.cachedAt = fresh.cachedAt
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Put events back if flush failed (with limit to avoid unbounded growth)
|
|
225
|
+
if (queue.length + batch.length <= MAX_BATCH * 2) {
|
|
226
|
+
queue.unshift(...batch)
|
|
227
|
+
}
|
|
228
|
+
log.warn("TUI usage flush failed", { status: response.status })
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// Fire-and-forget: network errors are expected when offline
|
|
232
|
+
if (queue.length + batch.length <= MAX_BATCH * 2) {
|
|
233
|
+
queue.unshift(...batch)
|
|
234
|
+
}
|
|
235
|
+
log.warn("TUI usage flush error", { error: String(error) })
|
|
236
|
+
}
|
|
237
|
+
}
|