@ossy/platform 3.15.0 → 3.17.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/package.json +9 -8
- package/src/audit/audit-helpers.js +60 -6
- package/src/audit/audit-helpers.spec.js +42 -0
- package/src/audit/task-run-list.aggregate.js +3 -0
- package/src/audit/task-run-service.js +10 -2
- package/src/audit/task-run.aggregate.js +2 -0
- package/src/file.schema.js +0 -4
- package/src/metering/assert-llm-usage-budget.js +83 -0
- package/src/metering/assert-llm-usage-budget.spec.js +129 -0
- package/src/metering/get-workspace-llm-usage.action.js +5 -0
- package/src/metering/get-workspace-llm-usage.task.js +31 -0
- package/src/metering/get-workspace-llm-usage.task.spec.js +74 -0
- package/src/metering/get-workspace-task-metering.action.js +5 -0
- package/src/metering/get-workspace-task-metering.task.js +30 -0
- package/src/metering/get-workspace-task-metering.task.spec.js +43 -0
- package/src/metering/index.js +11 -0
- package/src/metering/llm-usage.aggregate.js +127 -0
- package/src/metering/llm-usage.aggregate.spec.js +95 -0
- package/src/metering/metering-service.js +13 -0
- package/src/metering/metering-service.spec.js +62 -0
- package/src/metering/task-metering.aggregate.js +108 -0
- package/src/metering/task-metering.aggregate.spec.js +71 -0
- package/src/metering/usage-month.js +7 -0
- package/src/runtime.js +2 -0
- package/src/server.js +2 -0
- package/src/ssr/detect-mobile-device.js +39 -0
- package/src/ssr/detect-mobile-device.spec.js +50 -0
- package/src/storage/storage-keys.js +24 -0
- package/src/storage/storage-keys.spec.js +12 -1
- package/src/tasks/in-process-sdk.js +1 -49
- package/src/tasks/in-process-sdk.spec.js +1 -39
- package/src/tasks/task-service.js +270 -41
- package/src/tasks/task-service.spec.js +108 -0
- package/src/text-completion.schema.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ossy/platform",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.17.0",
|
|
4
4
|
"description": "Ossy application server runtime",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"./locale": "./src/locale.js",
|
|
30
30
|
"./verify-sign-in-session": "./src/verify-sign-in-session.js",
|
|
31
31
|
"./storage-keys": "./src/storage/storage-keys.js",
|
|
32
|
-
"./mcp": "./src/mcp/mount-platform-mcp.js"
|
|
32
|
+
"./mcp": "./src/mcp/mount-platform-mcp.js",
|
|
33
|
+
"./metering": "./src/metering/index.js"
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
35
36
|
"start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\"",
|
|
@@ -48,15 +49,15 @@
|
|
|
48
49
|
"@ossy/config": "^3.0.9",
|
|
49
50
|
"@ossy/event-store": "^3.12.0",
|
|
50
51
|
"@ossy/locale": "^3.4.0",
|
|
51
|
-
"@ossy/manifest": "^3.
|
|
52
|
+
"@ossy/manifest": "^3.17.0",
|
|
52
53
|
"@ossy/observability": "^3.0.9",
|
|
53
54
|
"@ossy/policies": "^3.0.9",
|
|
54
55
|
"@ossy/router": "^3.0.9",
|
|
55
|
-
"@ossy/schema": "^3.
|
|
56
|
-
"@ossy/sdk": "^3.
|
|
56
|
+
"@ossy/schema": "^3.17.0",
|
|
57
|
+
"@ossy/sdk": "^3.17.0",
|
|
57
58
|
"@ossy/tokens": "^3.11.1",
|
|
58
|
-
"@ossy/users": "^3.
|
|
59
|
-
"@ossy/workspaces": "^3.
|
|
59
|
+
"@ossy/users": "^3.17.0",
|
|
60
|
+
"@ossy/workspaces": "^3.17.0",
|
|
60
61
|
"cookie-parser": "^1.4.7",
|
|
61
62
|
"dotenv": ">=16.0.0 <18.0.0",
|
|
62
63
|
"express": ">=5.0.0 <6.0.0",
|
|
@@ -77,5 +78,5 @@
|
|
|
77
78
|
"Dockerfile",
|
|
78
79
|
"docker-healthcheck.js"
|
|
79
80
|
],
|
|
80
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "91ff7dffe9b0bddcb823daee67213d2008eb64e9"
|
|
81
82
|
}
|
|
@@ -19,14 +19,68 @@ export function hashPayload (payload) {
|
|
|
19
19
|
/** Keep event payloads small — full task results stay outside the audit stream. */
|
|
20
20
|
export function summarizeResult (result) {
|
|
21
21
|
if (result == null) return null
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
return previewValue(result, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generic usage extraction for any task result — ADR 0017. A task's `run()`
|
|
27
|
+
* returns `usage: { model, promptTokens, completionTokens, totalTokens? }`
|
|
28
|
+
* (e.g. OpenRouter-backed tasks) and `TaskRunService` forwards it into
|
|
29
|
+
* `MeteringService.record()` without needing to know which integration
|
|
30
|
+
* produced it.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} result
|
|
33
|
+
* @returns {{ model: string | null, promptTokens: number | null, completionTokens: number | null, totalTokens: number | null } | null}
|
|
34
|
+
*/
|
|
35
|
+
export function extractUsage (result) {
|
|
36
|
+
const usage = result && typeof result === 'object' ? result.usage : null
|
|
37
|
+
if (!usage || typeof usage !== 'object') return null
|
|
38
|
+
|
|
39
|
+
const model = typeof usage.model === 'string' ? usage.model : null
|
|
40
|
+
const promptTokens = typeof usage.promptTokens === 'number' ? usage.promptTokens : null
|
|
41
|
+
const completionTokens = typeof usage.completionTokens === 'number' ? usage.completionTokens : null
|
|
42
|
+
if (model == null && promptTokens == null && completionTokens == null) return null
|
|
43
|
+
|
|
44
|
+
const totalTokens = typeof usage.totalTokens === 'number'
|
|
45
|
+
? usage.totalTokens
|
|
46
|
+
: (promptTokens != null || completionTokens != null ? (promptTokens ?? 0) + (completionTokens ?? 0) : null)
|
|
47
|
+
|
|
48
|
+
return { model, promptTokens, completionTokens, totalTokens }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolved task inputs for the run detail. Empty maps are omitted.
|
|
53
|
+
*
|
|
54
|
+
* @param {unknown} inputs
|
|
55
|
+
* @returns {object | undefined}
|
|
56
|
+
*/
|
|
57
|
+
export function summarizeInputs (inputs) {
|
|
58
|
+
if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) return undefined
|
|
59
|
+
if (!Object.keys(inputs).length) return undefined
|
|
60
|
+
const preview = previewValue(inputs, 0)
|
|
61
|
+
if (!preview || typeof preview !== 'object' || Array.isArray(preview)) return undefined
|
|
62
|
+
if (!Object.keys(preview).length) return undefined
|
|
63
|
+
return preview
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function previewValue (value, depth) {
|
|
67
|
+
if (value == null || typeof value !== 'object') {
|
|
68
|
+
if (typeof value === 'string' && value.length > 240) return `${value.slice(0, 240)}…`
|
|
69
|
+
return value
|
|
70
|
+
}
|
|
71
|
+
if (depth >= 4) {
|
|
72
|
+
return Array.isArray(value)
|
|
73
|
+
? { kind: 'array', length: value.length }
|
|
74
|
+
: { kind: 'object', keyCount: Object.keys(value).length }
|
|
75
|
+
}
|
|
76
|
+
if (Array.isArray(value)) {
|
|
77
|
+
return value.slice(0, 12).map((item) => previewValue(item, depth + 1))
|
|
24
78
|
}
|
|
25
|
-
|
|
26
|
-
|
|
79
|
+
const preview = {}
|
|
80
|
+
for (const key of Object.keys(value).slice(0, 24)) {
|
|
81
|
+
preview[key] = previewValue(value[key], depth + 1)
|
|
27
82
|
}
|
|
28
|
-
|
|
29
|
-
return { kind: 'object', keys: keys.slice(0, 20), keyCount: keys.length }
|
|
83
|
+
return preview
|
|
30
84
|
}
|
|
31
85
|
|
|
32
86
|
/** @param {Error | { message?: string, code?: string }} error */
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from '@jest/globals'
|
|
2
|
+
import { extractUsage } from './audit-helpers.js'
|
|
3
|
+
|
|
4
|
+
describe('extractUsage', () => {
|
|
5
|
+
it('returns null when the result has no usage', () => {
|
|
6
|
+
expect(extractUsage(null)).toBeNull()
|
|
7
|
+
expect(extractUsage({})).toBeNull()
|
|
8
|
+
expect(extractUsage({ usage: null })).toBeNull()
|
|
9
|
+
expect(extractUsage({ usage: {} })).toBeNull()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('passes through model/promptTokens/completionTokens/totalTokens', () => {
|
|
13
|
+
expect(extractUsage({
|
|
14
|
+
usage: { model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20, totalTokens: 120 },
|
|
15
|
+
})).toEqual({
|
|
16
|
+
model: 'openai/gpt-4o-mini',
|
|
17
|
+
promptTokens: 100,
|
|
18
|
+
completionTokens: 20,
|
|
19
|
+
totalTokens: 120,
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('derives totalTokens when the task result omits it', () => {
|
|
24
|
+
expect(extractUsage({
|
|
25
|
+
usage: { model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20 },
|
|
26
|
+
})).toEqual({
|
|
27
|
+
model: 'openai/gpt-4o-mini',
|
|
28
|
+
promptTokens: 100,
|
|
29
|
+
completionTokens: 20,
|
|
30
|
+
totalTokens: 120,
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('is still meterable with just a model and no token counts', () => {
|
|
35
|
+
expect(extractUsage({ usage: { model: 'openai/gpt-4o-mini' } })).toEqual({
|
|
36
|
+
model: 'openai/gpt-4o-mini',
|
|
37
|
+
promptTokens: null,
|
|
38
|
+
completionTokens: null,
|
|
39
|
+
totalTokens: null,
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -42,6 +42,7 @@ export class TaskRunListProjection {
|
|
|
42
42
|
if (event.event === 'Started') {
|
|
43
43
|
entry.status = 'in progress'
|
|
44
44
|
entry.startedAt = event.created
|
|
45
|
+
entry.triggeredBy = event.payload.triggeredBy ?? null
|
|
45
46
|
runs.unshift(entry)
|
|
46
47
|
return { workspaceId, runs: runs.slice(0, 200) }
|
|
47
48
|
}
|
|
@@ -54,6 +55,7 @@ export class TaskRunListProjection {
|
|
|
54
55
|
startedAt: event.created,
|
|
55
56
|
completedAt: event.created,
|
|
56
57
|
durationMs: event.payload.durationMs,
|
|
58
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
57
59
|
})
|
|
58
60
|
return { workspaceId, runs: runs.slice(0, 200) }
|
|
59
61
|
}
|
|
@@ -66,6 +68,7 @@ export class TaskRunListProjection {
|
|
|
66
68
|
durationMs: event.payload.durationMs,
|
|
67
69
|
error: event.event === 'Failed' ? event.payload.error : undefined,
|
|
68
70
|
resultSummary: event.event === 'Completed' ? event.payload.resultSummary : undefined,
|
|
71
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
return { workspaceId, runs }
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
resolveActorId,
|
|
10
10
|
resolveWorkspaceId,
|
|
11
11
|
summarizeError,
|
|
12
|
+
summarizeInputs,
|
|
12
13
|
summarizeResult,
|
|
14
|
+
extractUsage,
|
|
13
15
|
} from './audit-helpers.js'
|
|
14
16
|
import { rejectOnAbort } from '../request-diagnostics.js'
|
|
15
17
|
|
|
@@ -105,8 +107,10 @@ export const TaskRunService = {
|
|
|
105
107
|
actorId,
|
|
106
108
|
durationMs,
|
|
107
109
|
resultSummary: summarizeResult(result),
|
|
110
|
+
inputs: summarizeInputs(context.inputs),
|
|
108
111
|
})
|
|
109
112
|
recordTaskMetrics(taskId, durationMs)
|
|
113
|
+
const usage = extractUsage(result)
|
|
110
114
|
await MeteringService.record({
|
|
111
115
|
kind: 'task',
|
|
112
116
|
actionId: context.actionId ?? null,
|
|
@@ -119,6 +123,7 @@ export const TaskRunService = {
|
|
|
119
123
|
actionInvocationId: context.actionInvocationId ?? null,
|
|
120
124
|
taskRunId: runId,
|
|
121
125
|
trigger,
|
|
126
|
+
...(usage ?? {}),
|
|
122
127
|
})
|
|
123
128
|
return result
|
|
124
129
|
} catch (error) {
|
|
@@ -130,6 +135,7 @@ export const TaskRunService = {
|
|
|
130
135
|
actorId,
|
|
131
136
|
durationMs,
|
|
132
137
|
error: summarizeError(error),
|
|
138
|
+
inputs: summarizeInputs(context.inputs),
|
|
133
139
|
})
|
|
134
140
|
recordTaskMetrics(taskId, durationMs)
|
|
135
141
|
await MeteringService.record({
|
|
@@ -193,7 +199,7 @@ export const TaskRunService = {
|
|
|
193
199
|
}
|
|
194
200
|
},
|
|
195
201
|
|
|
196
|
-
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary }) {
|
|
202
|
+
async _appendCompleted ({ runId, taskId, workspaceId, actorId, durationMs, resultSummary, inputs }) {
|
|
197
203
|
try {
|
|
198
204
|
await Aggregate.Of(TaskRun, runId)
|
|
199
205
|
.then(Aggregate.Add({
|
|
@@ -202,6 +208,7 @@ export const TaskRunService = {
|
|
|
202
208
|
payload: {
|
|
203
209
|
durationMs,
|
|
204
210
|
resultSummary,
|
|
211
|
+
...(inputs ? { inputs } : {}),
|
|
205
212
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
206
213
|
},
|
|
207
214
|
}))
|
|
@@ -210,7 +217,7 @@ export const TaskRunService = {
|
|
|
210
217
|
}
|
|
211
218
|
},
|
|
212
219
|
|
|
213
|
-
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error }) {
|
|
220
|
+
async _appendFailed ({ runId, taskId, workspaceId, actorId, durationMs, error, inputs }) {
|
|
214
221
|
try {
|
|
215
222
|
await Aggregate.Of(TaskRun, runId)
|
|
216
223
|
.then(Aggregate.Add({
|
|
@@ -219,6 +226,7 @@ export const TaskRunService = {
|
|
|
219
226
|
payload: {
|
|
220
227
|
durationMs,
|
|
221
228
|
error,
|
|
229
|
+
...(inputs ? { inputs } : {}),
|
|
222
230
|
...taskRunScopePayload({ taskId, workspaceId }),
|
|
223
231
|
},
|
|
224
232
|
}))
|
|
@@ -36,6 +36,7 @@ export class TaskRun {
|
|
|
36
36
|
completedAt: event.created,
|
|
37
37
|
durationMs: event.payload.durationMs,
|
|
38
38
|
resultSummary: event.payload.resultSummary ?? null,
|
|
39
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
case 'Failed':
|
|
@@ -45,6 +46,7 @@ export class TaskRun {
|
|
|
45
46
|
completedAt: event.created,
|
|
46
47
|
durationMs: event.payload.durationMs,
|
|
47
48
|
error: event.payload.error ?? null,
|
|
49
|
+
...(event.payload.inputs ? { inputs: event.payload.inputs } : {}),
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
default:
|
package/src/file.schema.js
CHANGED
|
@@ -7,9 +7,5 @@ export default {
|
|
|
7
7
|
{ name: 'Key', type: 'text' },
|
|
8
8
|
{ name: 'ContentLength', type: 'number' },
|
|
9
9
|
{ name: 'ContentType', type: 'text' },
|
|
10
|
-
// Populated async by @ossy/media-tasks/tasks/resize-common-web
|
|
11
|
-
{ name: 'blurhash', type: 'text' },
|
|
12
|
-
{ name: 'width', type: 'number' },
|
|
13
|
-
{ name: 'height', type: 'number' },
|
|
14
10
|
],
|
|
15
11
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Aggregate, getProjection } from '@ossy/event-store'
|
|
2
|
+
import { Workspace } from '@ossy/workspaces/server'
|
|
3
|
+
import { LlmUsageProjection, currentUsageMonth } from './llm-usage.aggregate.js'
|
|
4
|
+
|
|
5
|
+
/** Platform-wide default monthly token budget per workspace, used when a workspace has no override. */
|
|
6
|
+
export const DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP = 2_000_000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @returns {number}
|
|
10
|
+
*/
|
|
11
|
+
export function llmWorkspaceMonthlyTokenCap () {
|
|
12
|
+
const raw = process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
13
|
+
const parsed = raw != null ? Number(raw) : NaN
|
|
14
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Effective monthly token limit for a workspace — its own
|
|
19
|
+
* `llmMonthlyTokenLimit` override (set via
|
|
20
|
+
* `@ossy/workspaces/actions/set-llm-monthly-token-limit`) when
|
|
21
|
+
* present, else the platform-wide default.
|
|
22
|
+
*
|
|
23
|
+
* @param {string | null | undefined} workspaceId
|
|
24
|
+
* @returns {Promise<number>}
|
|
25
|
+
*/
|
|
26
|
+
export async function resolveLlmMonthlyTokenLimit (workspaceId) {
|
|
27
|
+
const platformDefault = llmWorkspaceMonthlyTokenCap()
|
|
28
|
+
if (!workspaceId) return platformDefault
|
|
29
|
+
|
|
30
|
+
const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
|
|
31
|
+
const override = workspace?.llmMonthlyTokenLimit
|
|
32
|
+
return typeof override === 'number' && override > 0 ? override : platformDefault
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Best-effort workspace id resolution from a task `run()` context — same
|
|
37
|
+
* precedence as `TaskRunService`'s `resolveWorkspaceId` (`payload`/`req`),
|
|
38
|
+
* plus the raw dispatch `event` for callers that invoke this before the
|
|
39
|
+
* task context's `payload.workspaceId` has been set.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} context
|
|
42
|
+
* @returns {string | null}
|
|
43
|
+
*/
|
|
44
|
+
export function resolveWorkspaceIdForBudget (context = {}) {
|
|
45
|
+
return context.workspaceId
|
|
46
|
+
?? context.payload?.workspaceId
|
|
47
|
+
?? context.req?.workspaceId
|
|
48
|
+
?? context.event?.payload?.belongsTo
|
|
49
|
+
?? context.event?.belongsTo
|
|
50
|
+
?? null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Fail-closed monthly token budget check for LLM-backed tasks (today: the
|
|
55
|
+
* OpenRouter integration; named generically so switching providers later
|
|
56
|
+
* needs no rename here).
|
|
57
|
+
* No-op when there is no workspace in context (e.g. a system/dev run) —
|
|
58
|
+
* nothing to attribute the cap to.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ workspaceId?: string | null, month?: string }} args
|
|
61
|
+
* @throws {Error & { status: 402, code: 'LLM_TOKEN_QUOTA_EXCEEDED', workspaceId: string, limit: number, used: number }}
|
|
62
|
+
*/
|
|
63
|
+
export async function assertLlmUsageBudget ({ workspaceId, month } = {}) {
|
|
64
|
+
if (!workspaceId) return
|
|
65
|
+
|
|
66
|
+
const limit = await resolveLlmMonthlyTokenLimit(workspaceId)
|
|
67
|
+
const usageMonth = currentUsageMonth(month)
|
|
68
|
+
const state = await getProjection(workspaceId, LlmUsageProjection.ProjectionId)
|
|
69
|
+
const used = state?.byMonth?.[usageMonth]?.totalTokens ?? 0
|
|
70
|
+
|
|
71
|
+
if (used >= limit) {
|
|
72
|
+
throw Object.assign(
|
|
73
|
+
new Error(`LLM monthly token budget exceeded for workspace ${workspaceId}`),
|
|
74
|
+
{
|
|
75
|
+
status: 402,
|
|
76
|
+
code: 'LLM_TOKEN_QUOTA_EXCEEDED',
|
|
77
|
+
workspaceId,
|
|
78
|
+
limit,
|
|
79
|
+
used,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const getProjectionMock = jest.fn()
|
|
4
|
+
const ofMock = jest.fn()
|
|
5
|
+
const viewMock = jest.fn(() => (state) => state)
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
8
|
+
getProjection: getProjectionMock,
|
|
9
|
+
Aggregate: { Of: ofMock, View: viewMock },
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
13
|
+
Workspace: {},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const {
|
|
17
|
+
DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP,
|
|
18
|
+
assertLlmUsageBudget,
|
|
19
|
+
llmWorkspaceMonthlyTokenCap,
|
|
20
|
+
resolveLlmMonthlyTokenLimit,
|
|
21
|
+
resolveWorkspaceIdForBudget,
|
|
22
|
+
} = await import('./assert-llm-usage-budget.js')
|
|
23
|
+
|
|
24
|
+
describe('resolveWorkspaceIdForBudget', () => {
|
|
25
|
+
it('prefers explicit workspaceId, then payload, then req, then the dispatch event', () => {
|
|
26
|
+
expect(resolveWorkspaceIdForBudget({ workspaceId: 'ws-a', payload: { workspaceId: 'ws-b' } })).toBe('ws-a')
|
|
27
|
+
expect(resolveWorkspaceIdForBudget({ payload: { workspaceId: 'ws-b' } })).toBe('ws-b')
|
|
28
|
+
expect(resolveWorkspaceIdForBudget({ req: { workspaceId: 'ws-c' } })).toBe('ws-c')
|
|
29
|
+
expect(resolveWorkspaceIdForBudget({ event: { payload: { belongsTo: 'ws-d' } } })).toBe('ws-d')
|
|
30
|
+
expect(resolveWorkspaceIdForBudget({ event: { belongsTo: 'ws-e' } })).toBe('ws-e')
|
|
31
|
+
expect(resolveWorkspaceIdForBudget({})).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('llmWorkspaceMonthlyTokenCap', () => {
|
|
36
|
+
const originalEnv = process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = originalEnv
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('falls back to the default when unset or invalid', () => {
|
|
43
|
+
delete process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
44
|
+
expect(llmWorkspaceMonthlyTokenCap()).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
45
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = 'not-a-number'
|
|
46
|
+
expect(llmWorkspaceMonthlyTokenCap()).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
47
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = '0'
|
|
48
|
+
expect(llmWorkspaceMonthlyTokenCap()).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('reads a positive override from the environment', () => {
|
|
52
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = '500000'
|
|
53
|
+
expect(llmWorkspaceMonthlyTokenCap()).toBe(500000)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('resolveLlmMonthlyTokenLimit', () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
ofMock.mockReset()
|
|
60
|
+
delete process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('uses the platform default without a workspaceId', async () => {
|
|
64
|
+
expect(await resolveLlmMonthlyTokenLimit(null)).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
65
|
+
expect(ofMock).not.toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('uses the platform default when the workspace has no override', async () => {
|
|
69
|
+
ofMock.mockResolvedValue({ id: 'ws-1' })
|
|
70
|
+
expect(await resolveLlmMonthlyTokenLimit('ws-1')).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('prefers the workspace-specific override', async () => {
|
|
74
|
+
ofMock.mockResolvedValue({ id: 'ws-1', llmMonthlyTokenLimit: 500000 })
|
|
75
|
+
expect(await resolveLlmMonthlyTokenLimit('ws-1')).toBe(500000)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('ignores an invalid (zero/negative) override', async () => {
|
|
79
|
+
ofMock.mockResolvedValue({ id: 'ws-1', llmMonthlyTokenLimit: 0 })
|
|
80
|
+
expect(await resolveLlmMonthlyTokenLimit('ws-1')).toBe(DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('assertLlmUsageBudget', () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
getProjectionMock.mockReset()
|
|
87
|
+
ofMock.mockReset()
|
|
88
|
+
ofMock.mockResolvedValue({})
|
|
89
|
+
delete process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('is a no-op without a workspace in context', async () => {
|
|
93
|
+
await expect(assertLlmUsageBudget({})).resolves.toBeUndefined()
|
|
94
|
+
expect(getProjectionMock).not.toHaveBeenCalled()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('allows the run when usage is under the cap', async () => {
|
|
98
|
+
getProjectionMock.mockResolvedValue({ byMonth: { '2026-09': { totalTokens: 100 } } })
|
|
99
|
+
await expect(assertLlmUsageBudget({ workspaceId: 'ws-1', month: '2026-09' })).resolves.toBeUndefined()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('allows the run when there is no usage recorded yet', async () => {
|
|
103
|
+
getProjectionMock.mockResolvedValue(null)
|
|
104
|
+
await expect(assertLlmUsageBudget({ workspaceId: 'ws-1' })).resolves.toBeUndefined()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('fails closed with a 402 once the monthly cap is reached', async () => {
|
|
108
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = '1000'
|
|
109
|
+
getProjectionMock.mockResolvedValue({ byMonth: { '2026-09': { totalTokens: 1000 } } })
|
|
110
|
+
|
|
111
|
+
await expect(assertLlmUsageBudget({ workspaceId: 'ws-1', month: '2026-09' }))
|
|
112
|
+
.rejects.toMatchObject({
|
|
113
|
+
status: 402,
|
|
114
|
+
code: 'LLM_TOKEN_QUOTA_EXCEEDED',
|
|
115
|
+
workspaceId: 'ws-1',
|
|
116
|
+
limit: 1000,
|
|
117
|
+
used: 1000,
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('fails closed against the workspace-specific override, not the platform default', async () => {
|
|
122
|
+
process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP = '2000000'
|
|
123
|
+
ofMock.mockResolvedValue({ llmMonthlyTokenLimit: 100 })
|
|
124
|
+
getProjectionMock.mockResolvedValue({ byMonth: { '2026-09': { totalTokens: 100 } } })
|
|
125
|
+
|
|
126
|
+
await expect(assertLlmUsageBudget({ workspaceId: 'ws-1', month: '2026-09' }))
|
|
127
|
+
.rejects.toMatchObject({ code: 'LLM_TOKEN_QUOTA_EXCEEDED', limit: 100, used: 100 })
|
|
128
|
+
})
|
|
129
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getProjection } from '@ossy/event-store'
|
|
2
|
+
import { LlmUsageProjection, currentUsageMonth } from './llm-usage.aggregate.js'
|
|
3
|
+
import { resolveLlmMonthlyTokenLimit } from './assert-llm-usage-budget.js'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: '@ossy/platform/tasks/get-workspace-llm-usage',
|
|
7
|
+
audit: false,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @returns {{ requests: number, promptTokens: number, completionTokens: number, totalTokens: number, byModel: object }} */
|
|
11
|
+
function emptyMonth () {
|
|
12
|
+
return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, byModel: {} }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function run ({ payload, req }) {
|
|
16
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
17
|
+
if (!workspaceId) {
|
|
18
|
+
throw Object.assign(new Error('workspaceId required'), { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const month = currentUsageMonth(payload?.month)
|
|
22
|
+
const state = await getProjection(workspaceId, LlmUsageProjection.ProjectionId)
|
|
23
|
+
const bucket = state?.byMonth?.[month] ?? emptyMonth()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
workspaceId,
|
|
27
|
+
month,
|
|
28
|
+
...bucket,
|
|
29
|
+
limit: await resolveLlmMonthlyTokenLimit(workspaceId),
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from '@jest/globals'
|
|
2
|
+
|
|
3
|
+
const getProjectionMock = jest.fn()
|
|
4
|
+
const ofMock = jest.fn(() => Promise.resolve({}))
|
|
5
|
+
const viewMock = jest.fn(() => (state) => state)
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('@ossy/event-store', () => ({
|
|
8
|
+
getProjection: getProjectionMock,
|
|
9
|
+
Aggregate: { Of: ofMock, View: viewMock },
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.unstable_mockModule('@ossy/workspaces/server', () => ({
|
|
13
|
+
Workspace: {},
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { run } = await import('./get-workspace-llm-usage.task.js')
|
|
17
|
+
|
|
18
|
+
describe('get-workspace-llm-usage task', () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
getProjectionMock.mockReset()
|
|
21
|
+
ofMock.mockReset()
|
|
22
|
+
ofMock.mockResolvedValue({})
|
|
23
|
+
delete process.env.LLM_WORKSPACE_MONTHLY_TOKEN_CAP
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('requires a workspaceId', async () => {
|
|
27
|
+
await expect(run({ payload: {} })).rejects.toMatchObject({ status: 400 })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns a zeroed bucket for a workspace with no usage yet', async () => {
|
|
31
|
+
getProjectionMock.mockResolvedValue(null)
|
|
32
|
+
const result = await run({ payload: { workspaceId: 'ws-1', month: '2026-09' } })
|
|
33
|
+
expect(result).toMatchObject({
|
|
34
|
+
workspaceId: 'ws-1',
|
|
35
|
+
month: '2026-09',
|
|
36
|
+
requests: 0,
|
|
37
|
+
promptTokens: 0,
|
|
38
|
+
completionTokens: 0,
|
|
39
|
+
totalTokens: 0,
|
|
40
|
+
byModel: {},
|
|
41
|
+
})
|
|
42
|
+
expect(result.limit).toBeGreaterThan(0)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns the current month bucket from the projection', async () => {
|
|
46
|
+
getProjectionMock.mockResolvedValue({
|
|
47
|
+
byMonth: {
|
|
48
|
+
'2026-09': {
|
|
49
|
+
requests: 3,
|
|
50
|
+
promptTokens: 300,
|
|
51
|
+
completionTokens: 60,
|
|
52
|
+
totalTokens: 360,
|
|
53
|
+
byModel: { 'openai/gpt-4o-mini': { requests: 3, promptTokens: 300, completionTokens: 60 } },
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const result = await run({ req: { workspaceId: 'ws-1' }, payload: { month: '2026-09' } })
|
|
59
|
+
expect(result).toMatchObject({
|
|
60
|
+
workspaceId: 'ws-1',
|
|
61
|
+
month: '2026-09',
|
|
62
|
+
requests: 3,
|
|
63
|
+
totalTokens: 360,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('reports the workspace-specific limit override when set', async () => {
|
|
68
|
+
getProjectionMock.mockResolvedValue(null)
|
|
69
|
+
ofMock.mockResolvedValue({ llmMonthlyTokenLimit: 750000 })
|
|
70
|
+
|
|
71
|
+
const result = await run({ payload: { workspaceId: 'ws-1' } })
|
|
72
|
+
expect(result.limit).toBe(750000)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getProjection } from '@ossy/event-store'
|
|
2
|
+
import { TaskMeteringProjection } from './task-metering.aggregate.js'
|
|
3
|
+
import { currentUsageMonth } from './usage-month.js'
|
|
4
|
+
|
|
5
|
+
export const metadata = {
|
|
6
|
+
id: '@ossy/platform/tasks/get-workspace-task-metering',
|
|
7
|
+
audit: false,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** @returns {{ requests: number, failures: number, durationMs: number, byModule: object }} */
|
|
11
|
+
function emptyMonth () {
|
|
12
|
+
return { requests: 0, failures: 0, durationMs: 0, byModule: {} }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function run ({ payload, req }) {
|
|
16
|
+
const workspaceId = payload?.workspaceId ?? req?.workspaceId
|
|
17
|
+
if (!workspaceId) {
|
|
18
|
+
throw Object.assign(new Error('workspaceId required'), { status: 400 })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const month = currentUsageMonth(payload?.month)
|
|
22
|
+
const state = await getProjection(workspaceId, TaskMeteringProjection.ProjectionId)
|
|
23
|
+
const bucket = state?.byMonth?.[month] ?? emptyMonth()
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
workspaceId,
|
|
27
|
+
month,
|
|
28
|
+
...bucket,
|
|
29
|
+
}
|
|
30
|
+
}
|