@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 +9 -8
- package/src/audit/audit-helpers.js +26 -0
- package/src/audit/audit-helpers.spec.js +42 -0
- package/src/audit/task-run-service.js +3 -0
- 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/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
|
}
|
|
@@ -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,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
|
+
}
|
|
@@ -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
|
+
})
|
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
|
+
}
|