snow-flow 10.0.1-dev.394 → 10.0.1-dev.396
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
CHANGED
|
@@ -407,17 +407,32 @@ async function probeFlowFactoryNamespace(
|
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
-
// 5. Probe each candidate — GET
|
|
410
|
+
// 5. Probe each candidate — GET the /discover endpoint (a real GET handler)
|
|
411
|
+
// If /discover doesn't exist yet, fall back to /create (expects 405)
|
|
411
412
|
for (var j = 0; j < unique.length; j++) {
|
|
413
|
+
// Try /discover first (GET endpoint, returns 200 when namespace is correct)
|
|
414
|
+
try {
|
|
415
|
+
var discoverResp = await client.get('/api/' + unique[j] + '/' + FLOW_FACTORY_API_ID + '/discover');
|
|
416
|
+
if (discoverResp.status === 200 || discoverResp.data) {
|
|
417
|
+
return unique[j]; // Namespace confirmed via /discover
|
|
418
|
+
}
|
|
419
|
+
} catch (discoverErr: any) {
|
|
420
|
+
var ds = discoverErr.response?.status;
|
|
421
|
+
if (ds === 401 || ds === 403) {
|
|
422
|
+
return unique[j]; // Namespace correct but auth issue
|
|
423
|
+
}
|
|
424
|
+
// 404 = wrong namespace OR /discover not deployed yet, try /create
|
|
425
|
+
}
|
|
426
|
+
// Fallback: try /create (POST-only, expect 405 for correct namespace)
|
|
412
427
|
try {
|
|
413
428
|
await client.get('/api/' + unique[j] + '/' + FLOW_FACTORY_API_ID + '/create');
|
|
414
|
-
return unique[j]; // 200 =
|
|
415
|
-
} catch (
|
|
416
|
-
var
|
|
417
|
-
if (
|
|
429
|
+
return unique[j]; // 200 = unexpected but valid
|
|
430
|
+
} catch (createErr: any) {
|
|
431
|
+
var cs = createErr.response?.status;
|
|
432
|
+
if (cs === 405 || cs === 401 || cs === 403) {
|
|
418
433
|
return unique[j]; // Namespace correct — method or auth rejected
|
|
419
434
|
}
|
|
420
|
-
// 404 = wrong namespace, try next
|
|
435
|
+
// 404 = wrong namespace, try next candidate
|
|
421
436
|
}
|
|
422
437
|
}
|
|
423
438
|
|
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
|
+
}
|