opencode-plugboard 0.19.0 → 0.20.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/README.md +2 -2
- package/index.ts +6 -1
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/route.ts +11 -6
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +2 -0
- package/src/config.ts +86 -10
- package/src/contract.ts +3 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/route.ts +3 -1
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +10 -2
- package/src/openai/request.ts +2 -0
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +23 -6
- package/src/responses/request.ts +2 -0
- package/src/responses/route.ts +3 -1
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +12 -3
- package/src/session/caller.ts +27 -4
- package/src/session/context.ts +3 -1
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +26 -0
- package/src/session/link.ts +33 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
package/src/session/caller.ts
CHANGED
|
@@ -21,7 +21,7 @@ import { raceAbort } from "../abort.js"
|
|
|
21
21
|
import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
|
|
22
22
|
import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
|
|
23
23
|
import { log, logError } from "../log.js"
|
|
24
|
-
import { checkAppModel } from "../models.js"
|
|
24
|
+
import { checkAppModel, type Persona } from "../models.js"
|
|
25
25
|
import { RequestCollector } from "./events.js"
|
|
26
26
|
import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
|
|
27
27
|
import { describeError, type SessionPort } from "./host.js"
|
|
@@ -44,6 +44,11 @@ export interface SessionCallerDeps {
|
|
|
44
44
|
* called right before `session.create`. An error ends the request without creating anything.
|
|
45
45
|
*/
|
|
46
46
|
readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
|
|
47
|
+
/**
|
|
48
|
+
* What the requested name stands for beyond a plain model (`personaOf` in src/models.ts): a skill alias adds
|
|
49
|
+
* the skill's content to the system prompt, an agent runs its own system prompt and OpenCode agent.
|
|
50
|
+
*/
|
|
51
|
+
readonly persona?: (model: string) => Persona | undefined
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
/** How long the request guard may outlive the response while the host has not reported the end of the execution. */
|
|
@@ -54,6 +59,8 @@ const failure = (status: number, message: string, code: string): PlugEvent => ({
|
|
|
54
59
|
error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
|
|
55
60
|
})
|
|
56
61
|
|
|
62
|
+
const notFound = (message: string): PlugError => ({ status: 404, kind: "not_found", message, code: "model_not_found", param: "model" })
|
|
63
|
+
|
|
57
64
|
const countMedia = (request: PlugRequest): number =>
|
|
58
65
|
request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
|
|
59
66
|
|
|
@@ -123,7 +130,7 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
123
130
|
const policy = appPolicy({ apps }, app)
|
|
124
131
|
const outOfScope = checkAppModel(policy, called.model, called.ref)
|
|
125
132
|
if (outOfScope) return yield note({ type: "error", error: outOfScope })
|
|
126
|
-
|
|
133
|
+
let { request, stripped } = applyToolPolicy(called, policy)
|
|
127
134
|
const tooMany = checkAppTools(request, app, policy)
|
|
128
135
|
if (tooMany) return yield note({ type: "error", error: tooMany })
|
|
129
136
|
if (stripped) {
|
|
@@ -166,23 +173,39 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
166
173
|
if (unavailable) return yield note({ type: "error", error: unavailable })
|
|
167
174
|
if (context.signal.aborted) return
|
|
168
175
|
|
|
176
|
+
// 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
|
|
177
|
+
// caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
|
|
178
|
+
const persona = deps.persona?.(called.model)
|
|
179
|
+
let prelude: ReadonlyArray<string> = []
|
|
180
|
+
if (persona?.kind === "skill") {
|
|
181
|
+
const content = await port.skill(directory, persona.skill)
|
|
182
|
+
if (content === undefined) return yield note({ type: "error", error: notFound(`Skill ${persona.skill} is not available`) })
|
|
183
|
+
prelude = [content]
|
|
184
|
+
} else if (persona?.kind === "agent") {
|
|
185
|
+
const agent = await port.agent(directory, persona.agent)
|
|
186
|
+
if (agent === undefined) return yield note({ type: "error", error: notFound(`Agent ${persona.agent} is not available`) })
|
|
187
|
+
request = { ...request, system: agent.system ? [agent.system] : [] }
|
|
188
|
+
}
|
|
189
|
+
if (context.signal.aborted) return
|
|
190
|
+
|
|
169
191
|
collector = new RequestCollector({ stopTurn: interrupt, requestID })
|
|
170
192
|
const created = await raceAbort(port.create({
|
|
171
193
|
directory,
|
|
172
194
|
model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
|
|
195
|
+
...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
|
|
173
196
|
title: makeTitle(request),
|
|
174
197
|
metadata: { plugboard: { app, model: request.model, requestID } },
|
|
175
198
|
permissions: REQUEST_PERMISSIONS,
|
|
176
199
|
}), context.signal)
|
|
177
200
|
sessionID = String(created.id)
|
|
178
|
-
sessions.register({ sessionID, app, requestID, request, collector })
|
|
201
|
+
sessions.register({ sessionID, app, requestID, request, collector, prelude })
|
|
179
202
|
|
|
180
203
|
context.signal.addEventListener("abort", onAbort, { once: true })
|
|
181
204
|
if (context.signal.aborted) return onAbort()
|
|
182
205
|
|
|
183
206
|
// The history id is known from here on: tell the front-end before the first event.
|
|
184
207
|
try {
|
|
185
|
-
context.onRecord?.(sessionID)
|
|
208
|
+
context.onRecord?.(sessionID, directory)
|
|
186
209
|
} catch (error) {
|
|
187
210
|
logError("request.on_record_failed", { requestID, app, error: describeError(error) })
|
|
188
211
|
}
|
package/src/session/context.ts
CHANGED
|
@@ -123,7 +123,9 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
|
|
|
123
123
|
return text("[image unavailable]")
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
// Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
|
|
127
|
+
// then the tool-choice steering below.
|
|
128
|
+
const system = [...(state.prelude ?? []), ...systemFor(request.system, appPolicy({ apps }, state.app))].map(text)
|
|
127
129
|
// The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
|
|
128
130
|
const choice = request.toolChoice
|
|
129
131
|
if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
|
package/src/session/history.ts
CHANGED
|
@@ -61,10 +61,16 @@ const CONTROLS_KEEP_LAYOUT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u20
|
|
|
61
61
|
/** One display line: whitespace (including line breaks) collapsed to single spaces, then control and bidi characters removed. */
|
|
62
62
|
export const oneLine = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROLS, "").trim()
|
|
63
63
|
|
|
64
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Session title: the end user as `[user] ` when the caller named one (#57), then the first user text, one
|
|
66
|
+
* line, control and bidi characters removed. The whole title is cut to 60 characters.
|
|
67
|
+
*/
|
|
65
68
|
export const makeTitle = (request: PlugRequest): string => {
|
|
69
|
+
const prefix = request.user === undefined ? "" : `[${request.user}] `
|
|
66
70
|
const flat = oneLine(firstUserText(request))
|
|
67
|
-
|
|
71
|
+
const room = TITLE_LENGTH - prefix.length
|
|
72
|
+
const text = flat.length > room ? flat.slice(0, room - 1) + "…" : flat
|
|
73
|
+
return (prefix + text).trimEnd()
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/** Readable transcript stored as the session's prompt; images are listed by attachment number. */
|
package/src/session/host.ts
CHANGED
|
@@ -24,12 +24,18 @@ export interface SessionPort {
|
|
|
24
24
|
create(input: {
|
|
25
25
|
readonly directory: string
|
|
26
26
|
readonly model: { readonly providerID: string; readonly id: string; readonly variant?: string }
|
|
27
|
+
/** The OpenCode agent the session runs as (option `agents`); absent: the host's default agent. */
|
|
28
|
+
readonly agent?: string
|
|
27
29
|
readonly title: string
|
|
28
30
|
readonly metadata: { readonly [key: string]: Json }
|
|
29
31
|
readonly permissions: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }>
|
|
30
32
|
}): Promise<{ id: string }>
|
|
31
33
|
prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
|
|
32
34
|
interrupt(sessionID: string): Promise<void>
|
|
35
|
+
/** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
|
|
36
|
+
skill(directory: string, id: string): Promise<string | undefined>
|
|
37
|
+
/** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
|
|
38
|
+
agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
type HostSession = HostContext["session"]
|
|
@@ -45,6 +51,10 @@ const hostSessionID = (id: string): HostSessionID => id as HostSessionID
|
|
|
45
51
|
type HostCreate = NonNullable<Parameters<HostSession["create"]>[0]>
|
|
46
52
|
type HostModelRef = NonNullable<HostCreate["model"]>
|
|
47
53
|
type HostDirectory = NonNullable<HostCreate["location"]>["directory"]
|
|
54
|
+
type HostAgent = NonNullable<HostCreate["agent"]>
|
|
55
|
+
|
|
56
|
+
/** How long a directory's skill list is reused: a skill edit is seen within this window (per plugin generation). */
|
|
57
|
+
const SKILL_CACHE_MS = 30_000
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
60
|
* Branded model ref and directory. Same kind of cast as the session ID: the values are the
|
|
@@ -65,17 +75,33 @@ const hostDirectory = (directory: string): HostDirectory => directory as HostDir
|
|
|
65
75
|
*/
|
|
66
76
|
export function createSessionPort(ctx: HostContext): SessionPort {
|
|
67
77
|
const run = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => Effect.runPromise(effect)
|
|
78
|
+
const skillsByDirectory = new Map<string, { readonly at: number; readonly skills: ReadonlyMap<string, string> }>()
|
|
68
79
|
return {
|
|
69
80
|
create: (input) =>
|
|
70
81
|
run(
|
|
71
82
|
ctx.session.create({
|
|
72
83
|
model: hostModel(input.model),
|
|
84
|
+
...(input.agent === undefined ? {} : { agent: input.agent as HostAgent }),
|
|
73
85
|
title: input.title,
|
|
74
86
|
metadata: input.metadata,
|
|
75
87
|
permissions: input.permissions,
|
|
76
88
|
location: { directory: hostDirectory(input.directory) },
|
|
77
89
|
} satisfies HostCreate),
|
|
78
90
|
).then((created) => ({ id: String(created.id) })),
|
|
91
|
+
skill: async (directory, id) => {
|
|
92
|
+
const now = Date.now()
|
|
93
|
+
let held = skillsByDirectory.get(directory)
|
|
94
|
+
if (held === undefined || now - held.at >= SKILL_CACHE_MS) {
|
|
95
|
+
const listed = await run(ctx.skill.list({ location: { directory } }))
|
|
96
|
+
held = { at: now, skills: new Map(listed.data.map((skill): [string, string] => [skill.id, skill.content])) }
|
|
97
|
+
skillsByDirectory.set(directory, held)
|
|
98
|
+
}
|
|
99
|
+
return held.skills.get(id)
|
|
100
|
+
},
|
|
101
|
+
agent: async (directory, id) => {
|
|
102
|
+
const listed = await run(ctx.agent.list({ location: { directory } }))
|
|
103
|
+
return listed.data.find((agent) => agent.id === id)
|
|
104
|
+
},
|
|
79
105
|
prompt: (input) =>
|
|
80
106
|
run(
|
|
81
107
|
ctx.session.prompt({
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links from a response to the OpenCode session page of its request (#77). The host exposes no UI URL
|
|
3
|
+
* to a plugin, so option `historyUrl` is a template the operator fills in for how they reach OpenCode:
|
|
4
|
+
* `{id}` is the session id, `{directory}` the app directory (URL-encoded). With the option set, a
|
|
5
|
+
* response that carries `x-plugboard-session` also carries `x-plugboard-session-url` with the filled
|
|
6
|
+
* link, and the request log line gains `session_url=`.
|
|
7
|
+
*
|
|
8
|
+
* Pure: no node imports. The routes call `setSessionLink` from their `onRecord` callback, after they
|
|
9
|
+
* set the session header, so a link is only ever sent for a session that was named.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const SESSION_URL_HEADER = "x-plugboard-session-url"
|
|
13
|
+
|
|
14
|
+
/** The template with `{id}` and `{directory}` filled (both URL-encoded). */
|
|
15
|
+
export function sessionUrl(template: string, recordID: string, directory = ""): string {
|
|
16
|
+
return template.replaceAll("{id}", encodeURIComponent(recordID)).replaceAll("{directory}", encodeURIComponent(directory))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Sets `x-plugboard-session-url` for one history record and the log field, when a template is set.
|
|
21
|
+
* Nothing happens without a template, so a route calls this unconditionally.
|
|
22
|
+
*/
|
|
23
|
+
export function setSessionLink(
|
|
24
|
+
response: { setHeader(name: string, value: string): unknown },
|
|
25
|
+
info: { sessionUrl?: string },
|
|
26
|
+
template: string | undefined,
|
|
27
|
+
recordID: string,
|
|
28
|
+
directory?: string,
|
|
29
|
+
): void {
|
|
30
|
+
if (template === undefined) return
|
|
31
|
+
info.sessionUrl = sessionUrl(template, recordID, directory)
|
|
32
|
+
response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
|
|
33
|
+
}
|
package/src/session/state.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface RequestState {
|
|
|
32
32
|
readonly requestID: string
|
|
33
33
|
readonly request: PlugRequest
|
|
34
34
|
readonly collector: EventSink
|
|
35
|
+
/** System parts placed before the app's and the caller's (a skill's content, option `skill:` alias). Empty or absent for a plain model. */
|
|
36
|
+
readonly prelude?: ReadonlyArray<string>
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface Retirement {
|
package/src/usage.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { errorMessage, log, logError } from "./log.js"
|
|
|
29
29
|
import type { Outcome } from "./metrics.js"
|
|
30
30
|
import { parseModelRef } from "./models.js"
|
|
31
31
|
import type { StoragePort } from "./session/host.js"
|
|
32
|
+
import { sanitizeEndUser } from "./user.js"
|
|
32
33
|
|
|
33
34
|
/** Upper bounds of the latency buckets in ms; one more bucket holds everything above the last. */
|
|
34
35
|
export const LATENCY_BOUNDS_MS: ReadonlyArray<number> = [100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000, 60_000, 120_000, 300_000, 600_000]
|
|
@@ -123,9 +124,19 @@ export interface UsageCounts<Latency = LatencyHistogram> {
|
|
|
123
124
|
lastRequestAt?: string
|
|
124
125
|
}
|
|
125
126
|
|
|
127
|
+
/** One end user's counts (src/user.ts, #57): HTTP requests on the model routes, tokens and cost of the answered calls. */
|
|
128
|
+
export interface UserUsage {
|
|
129
|
+
requests: number
|
|
130
|
+
/** Total tokens of those calls (the five kinds summed). */
|
|
131
|
+
tokens: number
|
|
132
|
+
costUsd: number
|
|
133
|
+
}
|
|
134
|
+
|
|
126
135
|
/** One stored day, at `usage/<app>/<date>`. */
|
|
127
136
|
export interface DayRecord extends UsageCounts {
|
|
128
137
|
perModel: Record<string, ModelUsage>
|
|
138
|
+
/** Per end user (see `USERS_PER_DAY`). Older stored days have none. */
|
|
139
|
+
users: Record<string, UserUsage>
|
|
129
140
|
}
|
|
130
141
|
|
|
131
142
|
/** Counters with latency percentiles, as a report carries them. */
|
|
@@ -153,6 +164,8 @@ export interface UsageAppReport {
|
|
|
153
164
|
totals: UsageTotals
|
|
154
165
|
/** Per model, summed over the range. The key is `provider/model`; the variant is not part of it. */
|
|
155
166
|
models: Record<string, ModelUsage>
|
|
167
|
+
/** Per end user, summed over the range (src/user.ts). Past `USERS_PER_DAY` a day's extra users are under `_other`. With `user`, only that user. */
|
|
168
|
+
users?: Record<string, UserUsage>
|
|
156
169
|
/** Days with any record in the range, oldest first. */
|
|
157
170
|
days: UsageDay[]
|
|
158
171
|
/** The app's budget state, when option `budgets` sets one for it. Read from the current window, not from the range. */
|
|
@@ -179,6 +192,8 @@ export interface UsageQuery {
|
|
|
179
192
|
readonly to?: string
|
|
180
193
|
/** One app. Absent: every app. */
|
|
181
194
|
readonly app?: string
|
|
195
|
+
/** One end user (src/user.ts): `users` of each app then lists only that user. Absent: every user. */
|
|
196
|
+
readonly user?: string
|
|
182
197
|
}
|
|
183
198
|
|
|
184
199
|
// ---------------------------------------------------------------- pure helpers
|
|
@@ -213,6 +228,7 @@ export function emptyDay(): DayRecord {
|
|
|
213
228
|
limited: 0,
|
|
214
229
|
unpricedRequests: 0,
|
|
215
230
|
perModel: {},
|
|
231
|
+
users: {},
|
|
216
232
|
}
|
|
217
233
|
}
|
|
218
234
|
|
|
@@ -296,9 +312,22 @@ export function sumDays(parts: ReadonlyArray<DayRecord>): DayRecord {
|
|
|
296
312
|
total.perModel[model] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
|
|
297
313
|
}
|
|
298
314
|
}
|
|
315
|
+
total.users = sumUsers(parts)
|
|
299
316
|
return total
|
|
300
317
|
}
|
|
301
318
|
|
|
319
|
+
/** Per end user, summed over the day records (`only`: that user alone). */
|
|
320
|
+
function sumUsers(parts: ReadonlyArray<DayRecord>, only?: string): Record<string, UserUsage> {
|
|
321
|
+
const out: Record<string, UserUsage> = {}
|
|
322
|
+
for (const part of parts)
|
|
323
|
+
for (const [user, usage] of Object.entries(part.users ?? {})) {
|
|
324
|
+
if (only !== undefined && user !== only) continue
|
|
325
|
+
const sum = out[user] ?? { requests: 0, tokens: 0, costUsd: 0 }
|
|
326
|
+
out[user] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
|
|
327
|
+
}
|
|
328
|
+
return out
|
|
329
|
+
}
|
|
330
|
+
|
|
302
331
|
/** A day's counters with latency percentiles, as a report carries them. */
|
|
303
332
|
export function summaryOf(day: DayRecord): UsageTotals {
|
|
304
333
|
return {
|
|
@@ -329,6 +358,11 @@ export function fromJson(value: unknown): DayRecord {
|
|
|
329
358
|
const usage = object(item)
|
|
330
359
|
perModel[model] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
|
|
331
360
|
}
|
|
361
|
+
const users: Record<string, UserUsage> = {}
|
|
362
|
+
for (const [user, item] of Object.entries(object(raw.users))) {
|
|
363
|
+
const usage = object(item)
|
|
364
|
+
users[user] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
|
|
365
|
+
}
|
|
332
366
|
return {
|
|
333
367
|
requests: nonNegative(raw.requests),
|
|
334
368
|
errors: { client: nonNegative(errors.client), server: nonNegative(errors.server), timeout: nonNegative(errors.timeout), disconnected: nonNegative(errors.disconnected) },
|
|
@@ -348,6 +382,7 @@ export function fromJson(value: unknown): DayRecord {
|
|
|
348
382
|
unpricedRequests: nonNegative(raw.unpricedRequests),
|
|
349
383
|
...(typeof raw.lastRequestAt === "string" ? { lastRequestAt: raw.lastRequestAt } : {}),
|
|
350
384
|
perModel,
|
|
385
|
+
users,
|
|
351
386
|
}
|
|
352
387
|
}
|
|
353
388
|
|
|
@@ -395,6 +430,8 @@ export interface RequestUsage {
|
|
|
395
430
|
readonly status: number
|
|
396
431
|
readonly outcome: Outcome
|
|
397
432
|
readonly ms: number
|
|
433
|
+
/** The end user of the request, when kept (src/user.ts). */
|
|
434
|
+
readonly user?: string
|
|
398
435
|
}
|
|
399
436
|
|
|
400
437
|
/** One model call, when it ended. `answered` when its stream finished with an answer; `cache` when the response cache applied. */
|
|
@@ -405,6 +442,8 @@ export interface CallUsage {
|
|
|
405
442
|
readonly answered: boolean
|
|
406
443
|
readonly cache?: "hit" | "miss"
|
|
407
444
|
readonly usage?: PlugUsage
|
|
445
|
+
/** The end user of the call, when kept (src/user.ts). */
|
|
446
|
+
readonly user?: string
|
|
408
447
|
}
|
|
409
448
|
|
|
410
449
|
export interface UsageRecorder {
|
|
@@ -444,6 +483,18 @@ async function readDay(storage: StoragePort, key: string): Promise<DayRecord | u
|
|
|
444
483
|
}
|
|
445
484
|
}
|
|
446
485
|
|
|
486
|
+
/** Distinct end users a day keeps per app. Later users count under `_other` (the key cannot be an end user: no `_`). */
|
|
487
|
+
export const USERS_PER_DAY = 200
|
|
488
|
+
const OTHER_USER = "_other"
|
|
489
|
+
|
|
490
|
+
/** The counts of one end user in a day, created on first use; past the cap the request counts under `_other`. */
|
|
491
|
+
function userSlot(day: DayRecord, user: string): UserUsage {
|
|
492
|
+
if (day.users[user]) return day.users[user]
|
|
493
|
+
const distinct = Object.keys(day.users).length - (day.users[OTHER_USER] ? 1 : 0)
|
|
494
|
+
const key = distinct < USERS_PER_DAY ? user : OTHER_USER
|
|
495
|
+
return (day.users[key] ??= { requests: 0, tokens: 0, costUsd: 0 })
|
|
496
|
+
}
|
|
497
|
+
|
|
447
498
|
const addTokens = (into: UsageTokens, from: UsageTokens): UsageTokens => ({
|
|
448
499
|
input: into.input + from.input,
|
|
449
500
|
cacheRead: into.cacheRead + from.cacheRead,
|
|
@@ -558,6 +609,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
|
|
|
558
609
|
day.latency.sumMs += ms
|
|
559
610
|
day.latency.buckets[bucketIndex(ms)] = (day.latency.buckets[bucketIndex(ms)] ?? 0) + 1
|
|
560
611
|
day.lastRequestAt = new Date(at).toISOString()
|
|
612
|
+
if (input.user !== undefined) userSlot(day, input.user).requests++
|
|
561
613
|
})
|
|
562
614
|
},
|
|
563
615
|
|
|
@@ -595,6 +647,11 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
|
|
|
595
647
|
day.tokens = addTokens(day.tokens, tokens)
|
|
596
648
|
day.costUsd += price
|
|
597
649
|
if (unpriced) day.unpricedRequests++
|
|
650
|
+
if (input.user !== undefined) {
|
|
651
|
+
const slot = userSlot(day, input.user)
|
|
652
|
+
slot.tokens += totalTokens(tokens)
|
|
653
|
+
slot.costUsd += price
|
|
654
|
+
}
|
|
598
655
|
})
|
|
599
656
|
})().catch((error) => logError("usage.record_failed", { app: input.app, error: errorMessage(error) }))
|
|
600
657
|
},
|
|
@@ -614,6 +671,8 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
|
|
|
614
671
|
const at = now()
|
|
615
672
|
if (input.app !== undefined && !APP_NAME_PATTERN.test(input.app))
|
|
616
673
|
return { status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: "`app` must be an app name from the keys configuration" }
|
|
674
|
+
if (input.user !== undefined && sanitizeEndUser(input.user) === undefined)
|
|
675
|
+
return { status: 400, kind: "invalid_request", code: "invalid_user", param: "user", message: "`user` must be 1 to 64 characters of A-Z, a-z, 0-9, `.`, `_`, `@`, `:` or `-`" }
|
|
617
676
|
const range = resolveRange(input, at)
|
|
618
677
|
if (isPlugError(range)) return range
|
|
619
678
|
const prefix = input.app === undefined ? KEY_PREFIX : keyOf(input.app, "")
|
|
@@ -646,7 +705,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
|
|
|
646
705
|
const total = sumDays(rows.map((row) => row.day))
|
|
647
706
|
appTotals.push(total)
|
|
648
707
|
const days: UsageDay[] = rows.map((row) => ({ date: row.date, ...summaryOf(row.day), perModel: { ...row.day.perModel } }))
|
|
649
|
-
apps[app] = { totals: summaryOf(total), models: { ...total.perModel }, days }
|
|
708
|
+
apps[app] = { totals: summaryOf(total), models: { ...total.perModel }, users: sumUsers(rows.map((row) => row.day), input.user), days }
|
|
650
709
|
}
|
|
651
710
|
return { from: range.from, to: range.to, generatedAt: new Date(at).toISOString(), totals: summaryOf(sumDays(appTotals)), apps }
|
|
652
711
|
},
|
|
@@ -704,7 +763,7 @@ export function withUsage(inner: ModelCaller, recorder: UsageRecorder | undefine
|
|
|
704
763
|
}
|
|
705
764
|
} finally {
|
|
706
765
|
const ref = served ? parseModelRef(served.model) : request.ref
|
|
707
|
-
recorder.call({ app: context.app, ...(ref ? { ref } : {}), answered, ...(cache ? { cache } : {}), ...(usage ? { usage } : {}) })
|
|
766
|
+
recorder.call({ app: context.app, ...(ref ? { ref } : {}), answered, ...(cache ? { cache } : {}), ...(usage ? { usage } : {}), ...(request.user === undefined ? {} : { user: request.user }) })
|
|
708
767
|
}
|
|
709
768
|
})()
|
|
710
769
|
},
|
package/src/user.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The end user of a request (#57): the caller's own id for one of its users, read from the body
|
|
3
|
+
* (OpenAI `user`, Anthropic `metadata.user_id`; Gemini has none). It labels a session title, the
|
|
4
|
+
* request log line and the per-user usage counts. It never names a path: a value is only ever a
|
|
5
|
+
* string key in a record.
|
|
6
|
+
*
|
|
7
|
+
* A value is kept only when it is trimmed and at most 64 characters of `A-Z a-z 0-9 . _ @ : -`.
|
|
8
|
+
* Anything else is dropped, and the response says so with `x-plugboard-user-dropped: 1`. A blank
|
|
9
|
+
* value is no user at all (neither kept nor reported).
|
|
10
|
+
*
|
|
11
|
+
* Pure: no node imports, so the request decoders (`request.ts` files) can use it (I20).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const USER_DROPPED_HEADER = "x-plugboard-user-dropped"
|
|
15
|
+
|
|
16
|
+
const END_USER = /^[A-Za-z0-9._@:-]{1,64}$/
|
|
17
|
+
|
|
18
|
+
/** The user id to keep, or undefined when the value is absent, blank or not allowed. */
|
|
19
|
+
export function sanitizeEndUser(raw: unknown): string | undefined {
|
|
20
|
+
if (typeof raw !== "string") return undefined
|
|
21
|
+
const trimmed = raw.trim()
|
|
22
|
+
return END_USER.test(trimmed) ? trimmed : undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the caller sent a value that is not kept. Absent and blank values are not dropped. */
|
|
26
|
+
export function endUserDropped(raw: unknown): boolean {
|
|
27
|
+
if (raw === undefined || raw === null) return false
|
|
28
|
+
if (typeof raw === "string" && raw.trim() === "") return false
|
|
29
|
+
return sanitizeEndUser(raw) === undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `body.user`, the OpenAI-shaped field. */
|
|
33
|
+
export const bodyUser = (body: unknown): unknown => (typeof body === "object" && body !== null ? (body as { user?: unknown }).user : undefined)
|
|
34
|
+
|
|
35
|
+
/** `body.metadata.user_id`, the Anthropic-shaped field. */
|
|
36
|
+
export function bodyUserId(body: unknown): unknown {
|
|
37
|
+
const metadata = typeof body === "object" && body !== null ? (body as { metadata?: unknown }).metadata : undefined
|
|
38
|
+
return typeof metadata === "object" && metadata !== null ? (metadata as { user_id?: unknown }).user_id : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The `user` member of a `PlugRequest` (spread into it), empty when no user is kept. */
|
|
42
|
+
export const userField = (raw: unknown): { user?: string } => {
|
|
43
|
+
const user = sanitizeEndUser(raw)
|
|
44
|
+
return user === undefined ? {} : { user }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* For a route, after the body is read: sets `x-plugboard-user-dropped` when the value is not kept (the
|
|
49
|
+
* headers are still open then), and names the kept user in the request log fields.
|
|
50
|
+
*/
|
|
51
|
+
export function noteEndUser(response: { setHeader(name: string, value: string): unknown }, info: { user?: string }, raw: unknown): void {
|
|
52
|
+
if (endUserDropped(raw)) response.setHeader(USER_DROPPED_HEADER, "1")
|
|
53
|
+
const user = sanitizeEndUser(raw)
|
|
54
|
+
if (user !== undefined) info.user = user
|
|
55
|
+
}
|