@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.
Files changed (34) hide show
  1. package/package.json +9 -8
  2. package/src/audit/audit-helpers.js +60 -6
  3. package/src/audit/audit-helpers.spec.js +42 -0
  4. package/src/audit/task-run-list.aggregate.js +3 -0
  5. package/src/audit/task-run-service.js +10 -2
  6. package/src/audit/task-run.aggregate.js +2 -0
  7. package/src/file.schema.js +0 -4
  8. package/src/metering/assert-llm-usage-budget.js +83 -0
  9. package/src/metering/assert-llm-usage-budget.spec.js +129 -0
  10. package/src/metering/get-workspace-llm-usage.action.js +5 -0
  11. package/src/metering/get-workspace-llm-usage.task.js +31 -0
  12. package/src/metering/get-workspace-llm-usage.task.spec.js +74 -0
  13. package/src/metering/get-workspace-task-metering.action.js +5 -0
  14. package/src/metering/get-workspace-task-metering.task.js +30 -0
  15. package/src/metering/get-workspace-task-metering.task.spec.js +43 -0
  16. package/src/metering/index.js +11 -0
  17. package/src/metering/llm-usage.aggregate.js +127 -0
  18. package/src/metering/llm-usage.aggregate.spec.js +95 -0
  19. package/src/metering/metering-service.js +13 -0
  20. package/src/metering/metering-service.spec.js +62 -0
  21. package/src/metering/task-metering.aggregate.js +108 -0
  22. package/src/metering/task-metering.aggregate.spec.js +71 -0
  23. package/src/metering/usage-month.js +7 -0
  24. package/src/runtime.js +2 -0
  25. package/src/server.js +2 -0
  26. package/src/ssr/detect-mobile-device.js +39 -0
  27. package/src/ssr/detect-mobile-device.spec.js +50 -0
  28. package/src/storage/storage-keys.js +24 -0
  29. package/src/storage/storage-keys.spec.js +12 -1
  30. package/src/tasks/in-process-sdk.js +1 -49
  31. package/src/tasks/in-process-sdk.spec.js +1 -39
  32. package/src/tasks/task-service.js +270 -41
  33. package/src/tasks/task-service.spec.js +108 -0
  34. 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.15.0",
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.15.0",
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.15.0",
56
- "@ossy/sdk": "^3.12.0",
56
+ "@ossy/schema": "^3.17.0",
57
+ "@ossy/sdk": "^3.17.0",
57
58
  "@ossy/tokens": "^3.11.1",
58
- "@ossy/users": "^3.15.0",
59
- "@ossy/workspaces": "^3.15.0",
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": "23517232ddc4f2c64ca0062faa3cad84ceaa34a7"
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
- if (typeof result !== 'object') {
23
- return { kind: typeof result, preview: String(result).slice(0, 200) }
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
- if (Array.isArray(result)) {
26
- return { kind: 'array', length: result.length }
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
- const keys = Object.keys(result)
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:
@@ -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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/platform/actions/get-workspace-llm-usage',
3
+ access: 'workspace',
4
+ audit: false,
5
+ }
@@ -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,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/platform/actions/get-workspace-task-metering',
3
+ access: 'workspace',
4
+ audit: false,
5
+ }
@@ -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
+ }