@ossy/platform 3.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "3.16.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.16.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.16.0",
56
- "@ossy/sdk": "^3.16.0",
56
+ "@ossy/schema": "^3.17.0",
57
+ "@ossy/sdk": "^3.17.0",
57
58
  "@ossy/tokens": "^3.11.1",
58
- "@ossy/users": "^3.16.0",
59
- "@ossy/workspaces": "^3.16.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": "1872fb1467bf656284aae35881c34960c3860807"
81
+ "gitHead": "91ff7dffe9b0bddcb823daee67213d2008eb64e9"
81
82
  }
@@ -22,6 +22,32 @@ export function summarizeResult (result) {
22
22
  return previewValue(result, 0)
23
23
  }
24
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
+
25
51
  /**
26
52
  * Resolved task inputs for the run detail. Empty maps are omitted.
27
53
  *
@@ -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
+ })
@@ -11,6 +11,7 @@ import {
11
11
  summarizeError,
12
12
  summarizeInputs,
13
13
  summarizeResult,
14
+ extractUsage,
14
15
  } from './audit-helpers.js'
15
16
  import { rejectOnAbort } from '../request-diagnostics.js'
16
17
 
@@ -109,6 +110,7 @@ export const TaskRunService = {
109
110
  inputs: summarizeInputs(context.inputs),
110
111
  })
111
112
  recordTaskMetrics(taskId, durationMs)
113
+ const usage = extractUsage(result)
112
114
  await MeteringService.record({
113
115
  kind: 'task',
114
116
  actionId: context.actionId ?? null,
@@ -121,6 +123,7 @@ export const TaskRunService = {
121
123
  actionInvocationId: context.actionInvocationId ?? null,
122
124
  taskRunId: runId,
123
125
  trigger,
126
+ ...(usage ?? {}),
124
127
  })
125
128
  return result
126
129
  } catch (error) {
@@ -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
+ }
@@ -0,0 +1,43 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const getProjectionMock = jest.fn()
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ getProjection: getProjectionMock,
7
+ }))
8
+
9
+ const { run } = await import('./get-workspace-task-metering.task.js')
10
+
11
+ describe('get-workspace-task-metering task', () => {
12
+ beforeEach(() => {
13
+ getProjectionMock.mockReset()
14
+ })
15
+
16
+ it('requires a workspaceId', async () => {
17
+ await expect(run({ payload: {} })).rejects.toMatchObject({ status: 400 })
18
+ })
19
+
20
+ it('returns a zeroed bucket for a workspace with no task runs yet', async () => {
21
+ getProjectionMock.mockResolvedValue(null)
22
+ const result = await run({ payload: { workspaceId: 'ws-1', month: '2026-09' } })
23
+ expect(result).toEqual({
24
+ workspaceId: 'ws-1',
25
+ month: '2026-09',
26
+ requests: 0,
27
+ failures: 0,
28
+ durationMs: 0,
29
+ byModule: {},
30
+ })
31
+ })
32
+
33
+ it('returns the current month bucket from the projection', async () => {
34
+ getProjectionMock.mockResolvedValue({
35
+ byMonth: {
36
+ '2026-09': { requests: 12, failures: 1, durationMs: 4500, byModule: { '@ossy/media-tasks': { requests: 12, durationMs: 4500 } } },
37
+ },
38
+ })
39
+
40
+ const result = await run({ req: { workspaceId: 'ws-1' }, payload: { month: '2026-09' } })
41
+ expect(result).toMatchObject({ workspaceId: 'ws-1', month: '2026-09', requests: 12, durationMs: 4500 })
42
+ })
43
+ })
@@ -0,0 +1,11 @@
1
+ export { MeteringService } from './metering-service.js'
2
+ export { currentUsageMonth } from './usage-month.js'
3
+ export { LlmUsageProjection } from './llm-usage.aggregate.js'
4
+ export { TaskMeteringProjection } from './task-metering.aggregate.js'
5
+ export {
6
+ DEFAULT_LLM_WORKSPACE_MONTHLY_TOKEN_CAP,
7
+ assertLlmUsageBudget,
8
+ llmWorkspaceMonthlyTokenCap,
9
+ resolveLlmMonthlyTokenLimit,
10
+ resolveWorkspaceIdForBudget,
11
+ } from './assert-llm-usage-budget.js'
@@ -0,0 +1,127 @@
1
+ import { currentUsageMonth } from './usage-month.js'
2
+
3
+ export { currentUsageMonth }
4
+
5
+ /**
6
+ * ADR 0017 — per-workspace LLM token usage, folded at write.
7
+ *
8
+ * Bounded, time-bucketed projection over `@ossy/platform/schema/meter-event`
9
+ * `Recorded` events. Only events carrying LLM usage (`promptTokens` /
10
+ * `completionTokens`, set via `TaskRunService` → `MeteringService.record()`
11
+ * when a task result includes `usage`) affect state — everything else is a
12
+ * no-op `Apply` (durationMs/success-only meter events for non-LLM tasks).
13
+ *
14
+ * Named generically (not after a specific provider) — today's LLM tasks
15
+ * (`run-prompt`, `visual-content-descriptors`) call OpenRouter, but nothing
16
+ * in this metering layer assumes that; swapping the provider integration
17
+ * later needs no rename here.
18
+ *
19
+ * Scope: one document per workspace. Snapshot size is bounded by
20
+ * months × distinct models, not one row per request — mirrors
21
+ * `@ossy/analytics`'s `PageViewLocationProjection` day-bucket shape.
22
+ *
23
+ * Read via `getProjection(workspaceId, LlmUsageProjection.ProjectionId)`
24
+ * (`@ossy/event-store`). This is the same data backing both the workspace
25
+ * settings dashboard stat and `assertLlmUsageBudget`'s hard cap check.
26
+ *
27
+ * See `TaskMeteringProjection` (`./task-metering.aggregate.js`) for the
28
+ * general, all-tasks count/duration counterpart to this LLM-specific one.
29
+ */
30
+ export class LlmUsageProjection {
31
+
32
+ static kind = 'projection'
33
+ static ProjectionId = '@ossy/platform/data/llm-usage'
34
+
35
+ static sources = [
36
+ { type: '@ossy/platform/schema/meter-event', event: 'Recorded' },
37
+ ]
38
+
39
+ /**
40
+ * @param {object} event
41
+ * @returns {string | null}
42
+ */
43
+ static scopeFromEvent (event) {
44
+ const workspaceId = event.payload?.belongsTo ?? event.payload?.workspaceId
45
+ return typeof workspaceId === 'string' && workspaceId ? workspaceId : null
46
+ }
47
+
48
+ static cacheKeys (scopeId) {
49
+ return [
50
+ `projection:${LlmUsageProjection.ProjectionId}:${scopeId}`,
51
+ 'action:@ossy/platform/actions/get-workspace-llm-usage',
52
+ ]
53
+ }
54
+
55
+ /**
56
+ * @param {string} scopeId workspaceId
57
+ */
58
+ static initialState (scopeId) {
59
+ return { workspaceId: scopeId, byMonth: {} }
60
+ }
61
+
62
+ /**
63
+ * @param {object} event
64
+ * @param {{ workspaceId?: string, byMonth?: Record<string, MonthBucket> }} state
65
+ * @returns {{ workspaceId: string, byMonth: Record<string, MonthBucket> }}
66
+ */
67
+ static Apply (event, state) {
68
+ const payload = event.payload ?? {}
69
+ const promptTokens = typeof payload.promptTokens === 'number' ? payload.promptTokens : null
70
+ const completionTokens = typeof payload.completionTokens === 'number' ? payload.completionTokens : null
71
+ if (promptTokens == null && completionTokens == null) {
72
+ // Not an LLM-usage meter event (e.g. a plain task/action record) — no-op.
73
+ return { workspaceId: state.workspaceId, byMonth: state.byMonth ?? {} }
74
+ }
75
+
76
+ const totalTokens = typeof payload.totalTokens === 'number'
77
+ ? payload.totalTokens
78
+ : (promptTokens ?? 0) + (completionTokens ?? 0)
79
+
80
+ const at = Number(event.created)
81
+ const month = new Date(Number.isFinite(at) && at > 0 ? at : Date.now()).toISOString().slice(0, 7)
82
+
83
+ const byMonth = { ...(state.byMonth ?? {}) }
84
+ const bucket = byMonth[month] ?? emptyBucket()
85
+ const modelKey = typeof payload.model === 'string' && payload.model ? payload.model : 'unknown'
86
+ const modelBucket = bucket.byModel[modelKey] ?? emptyModelBucket()
87
+
88
+ byMonth[month] = {
89
+ requests: bucket.requests + 1,
90
+ promptTokens: bucket.promptTokens + (promptTokens ?? 0),
91
+ completionTokens: bucket.completionTokens + (completionTokens ?? 0),
92
+ totalTokens: bucket.totalTokens + totalTokens,
93
+ byModel: {
94
+ ...bucket.byModel,
95
+ [modelKey]: {
96
+ requests: modelBucket.requests + 1,
97
+ promptTokens: modelBucket.promptTokens + (promptTokens ?? 0),
98
+ completionTokens: modelBucket.completionTokens + (completionTokens ?? 0),
99
+ },
100
+ },
101
+ }
102
+
103
+ return { workspaceId: state.workspaceId, byMonth }
104
+ }
105
+ }
106
+
107
+ /** @returns {MonthBucket} */
108
+ function emptyBucket () {
109
+ return { requests: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0, byModel: {} }
110
+ }
111
+
112
+ function emptyModelBucket () {
113
+ return { requests: 0, promptTokens: 0, completionTokens: 0 }
114
+ }
115
+
116
+ /**
117
+ * @typedef {{
118
+ * requests: number,
119
+ * promptTokens: number,
120
+ * completionTokens: number,
121
+ * totalTokens: number,
122
+ * byModel: Record<string, { requests: number, promptTokens: number, completionTokens: number }>,
123
+ * }} MonthBucket
124
+ */
125
+
126
+ export { LlmUsageProjection as Aggregate }
127
+ export const id = 'platform/data/llm-usage'
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { LlmUsageProjection, currentUsageMonth } from './llm-usage.aggregate.js'
3
+
4
+ describe('LlmUsageProjection', () => {
5
+ it('scopes by workspaceId (belongsTo)', () => {
6
+ expect(LlmUsageProjection.scopeFromEvent({ payload: { belongsTo: 'ws-1' } })).toBe('ws-1')
7
+ expect(LlmUsageProjection.scopeFromEvent({ payload: {} })).toBeNull()
8
+ })
9
+
10
+ it('is a no-op for meter events without token usage (non-LLM tasks)', () => {
11
+ const state = LlmUsageProjection.initialState('ws-1')
12
+ const next = LlmUsageProjection.Apply({
13
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
14
+ payload: { kind: 'task', taskId: '@ossy/booking/tasks/confirm', durationMs: 12, success: true },
15
+ }, state)
16
+ expect(next).toEqual({ workspaceId: 'ws-1', byMonth: {} })
17
+ })
18
+
19
+ it('folds prompt/completion tokens into the event month, keyed by model', () => {
20
+ const state = LlmUsageProjection.initialState('ws-1')
21
+ const next = LlmUsageProjection.Apply({
22
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
23
+ payload: {
24
+ belongsTo: 'ws-1',
25
+ model: 'openai/gpt-4o-mini',
26
+ promptTokens: 100,
27
+ completionTokens: 20,
28
+ totalTokens: 120,
29
+ },
30
+ }, state)
31
+
32
+ expect(next.byMonth['2026-09']).toEqual({
33
+ requests: 1,
34
+ promptTokens: 100,
35
+ completionTokens: 20,
36
+ totalTokens: 120,
37
+ byModel: {
38
+ 'openai/gpt-4o-mini': { requests: 1, promptTokens: 100, completionTokens: 20 },
39
+ },
40
+ })
41
+ })
42
+
43
+ it('accumulates across requests and models within the same month', () => {
44
+ const first = LlmUsageProjection.Apply({
45
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
46
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20 },
47
+ }, LlmUsageProjection.initialState('ws-1'))
48
+
49
+ const second = LlmUsageProjection.Apply({
50
+ created: Date.parse('2026-09-20T08:00:00.000Z'),
51
+ payload: { belongsTo: 'ws-1', model: 'google/gemini-2.5-flash-lite', promptTokens: 50, completionTokens: 10 },
52
+ }, first)
53
+
54
+ expect(second.byMonth['2026-09']).toEqual({
55
+ requests: 2,
56
+ promptTokens: 150,
57
+ completionTokens: 30,
58
+ totalTokens: 180,
59
+ byModel: {
60
+ 'openai/gpt-4o-mini': { requests: 1, promptTokens: 100, completionTokens: 20 },
61
+ 'google/gemini-2.5-flash-lite': { requests: 1, promptTokens: 50, completionTokens: 10 },
62
+ },
63
+ })
64
+ })
65
+
66
+ it('buckets separately across months', () => {
67
+ const first = LlmUsageProjection.Apply({
68
+ created: Date.parse('2026-08-31T23:00:00.000Z'),
69
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 100, completionTokens: 20 },
70
+ }, LlmUsageProjection.initialState('ws-1'))
71
+
72
+ const second = LlmUsageProjection.Apply({
73
+ created: Date.parse('2026-09-01T01:00:00.000Z'),
74
+ payload: { belongsTo: 'ws-1', model: 'openai/gpt-4o-mini', promptTokens: 5, completionTokens: 1 },
75
+ }, first)
76
+
77
+ expect(Object.keys(second.byMonth).sort()).toEqual(['2026-08', '2026-09'])
78
+ expect(second.byMonth['2026-08'].totalTokens).toBe(120)
79
+ expect(second.byMonth['2026-09'].totalTokens).toBe(6)
80
+ })
81
+
82
+ it('invalidates the workspace usage action cache', () => {
83
+ expect(LlmUsageProjection.cacheKeys('ws-1')).toEqual([
84
+ 'projection:@ossy/platform/data/llm-usage:ws-1',
85
+ 'action:@ossy/platform/actions/get-workspace-llm-usage',
86
+ ])
87
+ })
88
+ })
89
+
90
+ describe('currentUsageMonth', () => {
91
+ it('passes through an explicit month, else defaults to the current UTC month', () => {
92
+ expect(currentUsageMonth('2026-01')).toBe('2026-01')
93
+ expect(currentUsageMonth()).toBe(new Date().toISOString().slice(0, 7))
94
+ })
95
+ })
@@ -23,6 +23,10 @@ export const MeteringService = {
23
23
  * actionInvocationId?: string | null,
24
24
  * taskRunId?: string | null,
25
25
  * trigger?: string | null,
26
+ * model?: string | null,
27
+ * promptTokens?: number | null,
28
+ * completionTokens?: number | null,
29
+ * totalTokens?: number | null,
26
30
  * }} record
27
31
  */
28
32
  async record (record) {
@@ -38,6 +42,10 @@ export const MeteringService = {
38
42
  actionInvocationId = null,
39
43
  taskRunId = null,
40
44
  trigger = null,
45
+ model = null,
46
+ promptTokens = null,
47
+ completionTokens = null,
48
+ totalTokens = null,
41
49
  } = record
42
50
 
43
51
  const moduleId = taskId
@@ -59,6 +67,11 @@ export const MeteringService = {
59
67
  actionInvocationId,
60
68
  taskRunId,
61
69
  trigger,
70
+ // ADR 0017 — optional LLM/usage-metering fields; null for non-metered runs.
71
+ model,
72
+ promptTokens,
73
+ completionTokens,
74
+ totalTokens,
62
75
  recordedAt: Date.now(),
63
76
  }
64
77
 
@@ -0,0 +1,62 @@
1
+ import { beforeEach, describe, expect, it, jest } from '@jest/globals'
2
+
3
+ const appendResourceEventMock = jest.fn().mockResolvedValue(undefined)
4
+
5
+ jest.unstable_mockModule('@ossy/event-store', () => ({
6
+ EventStore: { AppendResourceEvent: appendResourceEventMock },
7
+ }))
8
+
9
+ jest.unstable_mockModule('@ossy/observability', () => ({
10
+ createLogger: () => ({ debug () {}, info () {}, warn () {}, error () {} }),
11
+ metrics: { increment () {}, timing () {} },
12
+ }))
13
+
14
+ const { MeteringService } = await import('./metering-service.js')
15
+
16
+ describe('MeteringService.record', () => {
17
+ beforeEach(() => {
18
+ appendResourceEventMock.mockClear()
19
+ })
20
+
21
+ it('defaults usage fields to null for a plain task/action record', async () => {
22
+ await MeteringService.record({
23
+ kind: 'task',
24
+ taskId: '@ossy/booking/tasks/confirm',
25
+ workspaceId: 'ws-1',
26
+ durationMs: 12,
27
+ success: true,
28
+ })
29
+
30
+ expect(appendResourceEventMock).toHaveBeenCalledTimes(1)
31
+ const [{ payload }] = appendResourceEventMock.mock.calls[0]
32
+ expect(payload).toMatchObject({
33
+ model: null,
34
+ promptTokens: null,
35
+ completionTokens: null,
36
+ totalTokens: null,
37
+ })
38
+ })
39
+
40
+ it('carries model/token usage onto the meter-event payload', async () => {
41
+ await MeteringService.record({
42
+ kind: 'task',
43
+ taskId: '@ossy/media-tasks/tasks/run-prompt',
44
+ workspaceId: 'ws-1',
45
+ durationMs: 800,
46
+ success: true,
47
+ model: 'openai/gpt-4o-mini',
48
+ promptTokens: 100,
49
+ completionTokens: 20,
50
+ totalTokens: 120,
51
+ })
52
+
53
+ const [{ payload }] = appendResourceEventMock.mock.calls[0]
54
+ expect(payload).toMatchObject({
55
+ belongsTo: 'ws-1',
56
+ model: 'openai/gpt-4o-mini',
57
+ promptTokens: 100,
58
+ completionTokens: 20,
59
+ totalTokens: 120,
60
+ })
61
+ })
62
+ })
@@ -0,0 +1,108 @@
1
+ import { currentUsageMonth } from './usage-month.js'
2
+
3
+ export { currentUsageMonth }
4
+
5
+ /**
6
+ * ADR 0017 (amendment) — general, all-tasks execution volume per workspace,
7
+ * folded at write. Where `LlmUsageProjection` only folds meter events
8
+ * carrying LLM token usage, this folds **every** `kind: 'task'` meter event
9
+ * `MeteringService.record()` writes — regardless of whether the task used
10
+ * an LLM — into a bounded, monthly-bucketed count + total execution
11
+ * time. Intended as a second, non-token billing dimension ("how much
12
+ * automation did this workspace run") independent of AI usage.
13
+ *
14
+ * Snapshot size is bounded by months × distinct modules, mirroring
15
+ * `LlmUsageProjection` / `@ossy/analytics`'s day-bucket shape.
16
+ */
17
+ export class TaskMeteringProjection {
18
+
19
+ static kind = 'projection'
20
+ static ProjectionId = '@ossy/platform/data/task-metering'
21
+
22
+ static sources = [
23
+ { type: '@ossy/platform/schema/meter-event', event: 'Recorded' },
24
+ ]
25
+
26
+ /**
27
+ * @param {object} event
28
+ * @returns {string | null}
29
+ */
30
+ static scopeFromEvent (event) {
31
+ const workspaceId = event.payload?.belongsTo ?? event.payload?.workspaceId
32
+ return typeof workspaceId === 'string' && workspaceId ? workspaceId : null
33
+ }
34
+
35
+ static cacheKeys (scopeId) {
36
+ return [
37
+ `projection:${TaskMeteringProjection.ProjectionId}:${scopeId}`,
38
+ 'action:@ossy/platform/actions/get-workspace-task-metering',
39
+ ]
40
+ }
41
+
42
+ /**
43
+ * @param {string} scopeId workspaceId
44
+ */
45
+ static initialState (scopeId) {
46
+ return { workspaceId: scopeId, byMonth: {} }
47
+ }
48
+
49
+ /**
50
+ * @param {object} event
51
+ * @param {{ workspaceId?: string, byMonth?: Record<string, TaskMonthBucket> }} state
52
+ * @returns {{ workspaceId: string, byMonth: Record<string, TaskMonthBucket> }}
53
+ */
54
+ static Apply (event, state) {
55
+ const payload = event.payload ?? {}
56
+ if (payload.kind !== 'task') {
57
+ // Action invocations are metered too, but this dimension is task
58
+ // execution volume only.
59
+ return { workspaceId: state.workspaceId, byMonth: state.byMonth ?? {} }
60
+ }
61
+
62
+ const at = Number(event.created)
63
+ const month = new Date(Number.isFinite(at) && at > 0 ? at : Date.now()).toISOString().slice(0, 7)
64
+
65
+ const byMonth = { ...(state.byMonth ?? {}) }
66
+ const bucket = byMonth[month] ?? emptyBucket()
67
+ const moduleKey = typeof payload.moduleId === 'string' && payload.moduleId ? payload.moduleId : 'unknown'
68
+ const moduleBucket = bucket.byModule[moduleKey] ?? emptyModuleBucket()
69
+ const durationMs = typeof payload.durationMs === 'number' ? payload.durationMs : 0
70
+ const failed = payload.success === false
71
+
72
+ byMonth[month] = {
73
+ requests: bucket.requests + 1,
74
+ failures: bucket.failures + (failed ? 1 : 0),
75
+ durationMs: bucket.durationMs + durationMs,
76
+ byModule: {
77
+ ...bucket.byModule,
78
+ [moduleKey]: {
79
+ requests: moduleBucket.requests + 1,
80
+ durationMs: moduleBucket.durationMs + durationMs,
81
+ },
82
+ },
83
+ }
84
+
85
+ return { workspaceId: state.workspaceId, byMonth }
86
+ }
87
+ }
88
+
89
+ /** @returns {TaskMonthBucket} */
90
+ function emptyBucket () {
91
+ return { requests: 0, failures: 0, durationMs: 0, byModule: {} }
92
+ }
93
+
94
+ function emptyModuleBucket () {
95
+ return { requests: 0, durationMs: 0 }
96
+ }
97
+
98
+ /**
99
+ * @typedef {{
100
+ * requests: number,
101
+ * failures: number,
102
+ * durationMs: number,
103
+ * byModule: Record<string, { requests: number, durationMs: number }>,
104
+ * }} TaskMonthBucket
105
+ */
106
+
107
+ export { TaskMeteringProjection as Aggregate }
108
+ export const id = 'platform/data/task-metering'
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { TaskMeteringProjection } from './task-metering.aggregate.js'
3
+ import { currentUsageMonth } from './usage-month.js'
4
+
5
+ describe('TaskMeteringProjection', () => {
6
+ it('scopes by workspaceId (belongsTo)', () => {
7
+ expect(TaskMeteringProjection.scopeFromEvent({ payload: { belongsTo: 'ws-1' } })).toBe('ws-1')
8
+ expect(TaskMeteringProjection.scopeFromEvent({ payload: {} })).toBeNull()
9
+ })
10
+
11
+ it('is a no-op for action-kind meter events (task execution volume only)', () => {
12
+ const state = TaskMeteringProjection.initialState('ws-1')
13
+ const next = TaskMeteringProjection.Apply({
14
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
15
+ payload: { kind: 'action', actionId: '@ossy/booking/actions/create', durationMs: 12 },
16
+ }, state)
17
+ expect(next).toEqual({ workspaceId: 'ws-1', byMonth: {} })
18
+ })
19
+
20
+ it('counts every task run and sums duration, regardless of success', () => {
21
+ const first = TaskMeteringProjection.Apply({
22
+ created: Date.parse('2026-09-05T12:00:00.000Z'),
23
+ payload: { kind: 'task', taskId: '@ossy/media-tasks/tasks/run-prompt', moduleId: '@ossy/media-tasks', durationMs: 800, success: true },
24
+ }, TaskMeteringProjection.initialState('ws-1'))
25
+
26
+ const second = TaskMeteringProjection.Apply({
27
+ created: Date.parse('2026-09-20T08:00:00.000Z'),
28
+ payload: { kind: 'task', taskId: '@ossy/booking/tasks/confirm', moduleId: '@ossy/booking', durationMs: 50, success: false },
29
+ }, first)
30
+
31
+ expect(second.byMonth['2026-09']).toEqual({
32
+ requests: 2,
33
+ failures: 1,
34
+ durationMs: 850,
35
+ byModule: {
36
+ '@ossy/media-tasks': { requests: 1, durationMs: 800 },
37
+ '@ossy/booking': { requests: 1, durationMs: 50 },
38
+ },
39
+ })
40
+ })
41
+
42
+ it('buckets separately across months', () => {
43
+ const first = TaskMeteringProjection.Apply({
44
+ created: Date.parse('2026-08-31T23:00:00.000Z'),
45
+ payload: { kind: 'task', moduleId: '@ossy/media-tasks', durationMs: 100, success: true },
46
+ }, TaskMeteringProjection.initialState('ws-1'))
47
+
48
+ const second = TaskMeteringProjection.Apply({
49
+ created: Date.parse('2026-09-01T01:00:00.000Z'),
50
+ payload: { kind: 'task', moduleId: '@ossy/media-tasks', durationMs: 20, success: true },
51
+ }, first)
52
+
53
+ expect(Object.keys(second.byMonth).sort()).toEqual(['2026-08', '2026-09'])
54
+ expect(second.byMonth['2026-08'].durationMs).toBe(100)
55
+ expect(second.byMonth['2026-09'].durationMs).toBe(20)
56
+ })
57
+
58
+ it('invalidates the workspace task-metering action cache', () => {
59
+ expect(TaskMeteringProjection.cacheKeys('ws-1')).toEqual([
60
+ 'projection:@ossy/platform/data/task-metering:ws-1',
61
+ 'action:@ossy/platform/actions/get-workspace-task-metering',
62
+ ])
63
+ })
64
+ })
65
+
66
+ describe('currentUsageMonth (re-exported)', () => {
67
+ it('passes through an explicit month, else defaults to the current UTC month', () => {
68
+ expect(currentUsageMonth('2026-01')).toBe('2026-01')
69
+ expect(currentUsageMonth()).toBe(new Date().toISOString().slice(0, 7))
70
+ })
71
+ })
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @param {string} [month] `YYYY-MM`; defaults to the current UTC month.
3
+ * @returns {string}
4
+ */
5
+ export function currentUsageMonth (month) {
6
+ return typeof month === 'string' && month ? month : new Date().toISOString().slice(0, 7)
7
+ }
package/src/runtime.js CHANGED
@@ -12,6 +12,7 @@ import { loadSite, invalidateSite } from './site-loader.js'
12
12
  import { createLogger } from '@ossy/observability'
13
13
  import { mountHealthEndpoint } from './health.js'
14
14
  import { readWorkspaceIdFromCookies } from './user-app-settings.js'
15
+ import { detectMobileDevice } from './ssr/detect-mobile-device.js'
15
16
 
16
17
  const log = createLogger('platform')
17
18
 
@@ -214,6 +215,7 @@ export async function startRuntime ({ port } = {}) {
214
215
  taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
215
216
  url: requestUrl,
216
217
  isAuthenticated: !!req.isAuthenticated,
218
+ isMobileDevice: detectMobileDevice(req),
217
219
  language,
218
220
  messages: cloneSerializable(messages),
219
221
  ...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
package/src/server.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  withTimeout,
38
38
  } from './request-diagnostics.js'
39
39
  import { mergeWorkspaceSchemas } from '@ossy/workspaces/merge-workspace-schemas'
40
+ import { detectMobileDevice } from './ssr/detect-mobile-device.js'
40
41
  import {
41
42
  buildEnableablePackageSet,
42
43
  setEnableablePackages,
@@ -578,6 +579,7 @@ export async function startServer (options = {}) {
578
579
  taskGraphEdges: cloneSerializable(manifest.taskGraphEdges || []),
579
580
  url: requestUrl,
580
581
  isAuthenticated: !!req.isAuthenticated,
582
+ isMobileDevice: detectMobileDevice(req),
581
583
  language,
582
584
  messages: cloneSerializable(messages),
583
585
  ...(fallbackMessages ? { fallbackMessages: cloneSerializable(fallbackMessages) } : {}),
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SSR device detection for the app shell (fixes #871 — desktop sidenav
3
+ * flashing on mobile because SSR always assumed a desktop viewport).
4
+ *
5
+ * Primary signal: the `Sec-CH-UA-Mobile` client hint (Chromium browsers
6
+ * send it on every request once `Accept-CH` opts in, no round trip needed).
7
+ * It is a structured boolean: `?1` (mobile) or `?0` (desktop).
8
+ *
9
+ * Fallback: a `User-Agent` regex for browsers that don't send the client
10
+ * hint (Safari, Firefox). This is a best-effort guess only — the client
11
+ * still re-resolves the real value via `matchMedia` after mount.
12
+ */
13
+
14
+ const MOBILE_USER_AGENT_PATTERN = /Mobi|Android|iPhone|iPad|iPod/i
15
+
16
+ /**
17
+ * @param {string | undefined | null} headerValue raw `Sec-CH-UA-Mobile` header value
18
+ * @returns {boolean | null} `true`/`false` when the hint is present, `null` when absent/malformed
19
+ */
20
+ export function parseSecChUaMobile (headerValue) {
21
+ if (typeof headerValue !== 'string') return null
22
+ const trimmed = headerValue.trim()
23
+ if (trimmed === '?1') return true
24
+ if (trimmed === '?0') return false
25
+ return null
26
+ }
27
+
28
+ /**
29
+ * @param {import('express').Request | undefined} req
30
+ * @returns {boolean} best-effort guess of whether the request came from a mobile device
31
+ */
32
+ export function detectMobileDevice (req) {
33
+ const secChUaMobile = req?.headers?.['sec-ch-ua-mobile'] ?? req?.get?.('Sec-CH-UA-Mobile')
34
+ const hinted = parseSecChUaMobile(secChUaMobile)
35
+ if (hinted !== null) return hinted
36
+
37
+ const userAgent = req?.headers?.['user-agent'] ?? req?.get?.('User-Agent') ?? ''
38
+ return MOBILE_USER_AGENT_PATTERN.test(userAgent)
39
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import { detectMobileDevice, parseSecChUaMobile } from './detect-mobile-device.js'
3
+
4
+ describe('parseSecChUaMobile', () => {
5
+ it('returns true for the mobile client hint', () => {
6
+ expect(parseSecChUaMobile('?1')).toBe(true)
7
+ })
8
+
9
+ it('returns false for the desktop client hint', () => {
10
+ expect(parseSecChUaMobile('?0')).toBe(false)
11
+ })
12
+
13
+ it('returns null when the header is missing or malformed', () => {
14
+ expect(parseSecChUaMobile(undefined)).toBeNull()
15
+ expect(parseSecChUaMobile(null)).toBeNull()
16
+ expect(parseSecChUaMobile('')).toBeNull()
17
+ expect(parseSecChUaMobile('mobile')).toBeNull()
18
+ })
19
+
20
+ it('tolerates surrounding whitespace', () => {
21
+ expect(parseSecChUaMobile(' ?1 ')).toBe(true)
22
+ })
23
+ })
24
+
25
+ describe('detectMobileDevice', () => {
26
+ it('prefers the Sec-CH-UA-Mobile client hint when present', () => {
27
+ const req = { headers: { 'sec-ch-ua-mobile': '?1', 'user-agent': 'Mozilla/5.0 (Windows NT 10.0)' } }
28
+ expect(detectMobileDevice(req)).toBe(true)
29
+ })
30
+
31
+ it('falls back to sniffing User-Agent when the client hint is absent (e.g. Safari/Firefox)', () => {
32
+ const req = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit' } }
33
+ expect(detectMobileDevice(req)).toBe(true)
34
+ })
35
+
36
+ it('treats desktop User-Agents as non-mobile when the client hint is absent', () => {
37
+ const req = { headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit' } }
38
+ expect(detectMobileDevice(req)).toBe(false)
39
+ })
40
+
41
+ it('supports Express-style req.get(...) as a fallback lookup', () => {
42
+ const req = { headers: {}, get: (name) => (name === 'Sec-CH-UA-Mobile' ? '?1' : undefined) }
43
+ expect(detectMobileDevice(req)).toBe(true)
44
+ })
45
+
46
+ it('defaults to false when no request or headers are available', () => {
47
+ expect(detectMobileDevice(undefined)).toBe(false)
48
+ expect(detectMobileDevice({})).toBe(false)
49
+ })
50
+ })
@@ -0,0 +1,37 @@
1
+ import { TASK_OUTPUT_PROVENANCE_FIELDS } from '@ossy/schema'
2
+
3
+ /**
4
+ * Platform schema for freeform text/Markdown LLM completion artifacts.
5
+ *
6
+ * Any task that produces unstructured prose (a chat completion, a
7
+ * generated summary, …) placed as a document should declare this schema
8
+ * as its output `schemaId` instead of inventing a feature-specific one.
9
+ * Structured JSON results (e.g. `{ title, tags, alt }`) still get their
10
+ * own feature schema — this one is only for the "freeform text/md" shape.
11
+ *
12
+ * See `docs/concepts/TaskOutput.md`.
13
+ */
14
+ export default {
15
+ id: '@ossy/platform/schema/text-completion',
16
+ name: 'Text completion',
17
+ categoryName: 'Platform',
18
+ icon: 'comment',
19
+ fields: [
20
+ {
21
+ name: 'text',
22
+ type: 'textarea',
23
+ description: 'Model completion (freeform text/Markdown)',
24
+ },
25
+ {
26
+ name: 'model',
27
+ type: 'text',
28
+ description: 'Model slug that produced the completion',
29
+ },
30
+ {
31
+ name: 'prompt',
32
+ type: 'textarea',
33
+ description: 'Prompt that was sent, before resource context',
34
+ },
35
+ ...TASK_OUTPUT_PROVENANCE_FIELDS,
36
+ ],
37
+ }