opencode-plugboard 0.19.0 → 0.21.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 +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +35 -23
- package/package.json +10 -4
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +17 -10
- package/src/cache.ts +26 -5
- package/src/completions/request.ts +15 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +155 -13
- package/src/contract.ts +9 -1
- package/src/discovery.ts +35 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +10 -4
- package/src/http.ts +65 -7
- package/src/keys.ts +172 -0
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +29 -7
- package/src/openai/request.ts +6 -4
- package/src/openai/response.ts +101 -21
- package/src/openai/route.ts +44 -17
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +3 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +7 -3
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +60 -7
- package/src/session/caller.ts +96 -11
- package/src/session/context.ts +18 -4
- package/src/session/events.ts +116 -12
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +40 -0
- package/src/session/link.ts +33 -0
- package/src/session/seeds.ts +10 -0
- package/src/session/state.ts +2 -0
- package/src/usage.ts +61 -2
- package/src/user.ts +55 -0
- package/tui.ts +5 -4
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,27 @@ 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
|
+
/** Deletes a host session. Used only for a session that was created after its request was aborted (#155). */
|
|
36
|
+
remove(sessionID: string): Promise<void>
|
|
37
|
+
/** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
|
|
38
|
+
skill(directory: string, id: string): Promise<string | undefined>
|
|
39
|
+
/** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
|
|
40
|
+
agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
|
|
41
|
+
/**
|
|
42
|
+
* The ids of the OpenCode tools this location offers directly to a model (`codemode: false`), the only
|
|
43
|
+
* ones a request session can call by name. CodeMode-wrapped tools are reached through `execute`, which a
|
|
44
|
+
* request session denies, so they are not listed. A tool whose permission name differs from its id is
|
|
45
|
+
* not listed either: the session's permission rules are written by id.
|
|
46
|
+
*/
|
|
47
|
+
directTools(): Promise<ReadonlyArray<string>>
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
type HostSession = HostContext["session"]
|
|
@@ -45,6 +60,10 @@ const hostSessionID = (id: string): HostSessionID => id as HostSessionID
|
|
|
45
60
|
type HostCreate = NonNullable<Parameters<HostSession["create"]>[0]>
|
|
46
61
|
type HostModelRef = NonNullable<HostCreate["model"]>
|
|
47
62
|
type HostDirectory = NonNullable<HostCreate["location"]>["directory"]
|
|
63
|
+
type HostAgent = NonNullable<HostCreate["agent"]>
|
|
64
|
+
|
|
65
|
+
/** How long a directory's skill list is reused: a skill edit is seen within this window (per plugin generation). */
|
|
66
|
+
const SKILL_CACHE_MS = 30_000
|
|
48
67
|
|
|
49
68
|
/**
|
|
50
69
|
* Branded model ref and directory. Same kind of cast as the session ID: the values are the
|
|
@@ -65,17 +84,33 @@ const hostDirectory = (directory: string): HostDirectory => directory as HostDir
|
|
|
65
84
|
*/
|
|
66
85
|
export function createSessionPort(ctx: HostContext): SessionPort {
|
|
67
86
|
const run = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => Effect.runPromise(effect)
|
|
87
|
+
const skillsByDirectory = new Map<string, { readonly at: number; readonly skills: ReadonlyMap<string, string> }>()
|
|
68
88
|
return {
|
|
69
89
|
create: (input) =>
|
|
70
90
|
run(
|
|
71
91
|
ctx.session.create({
|
|
72
92
|
model: hostModel(input.model),
|
|
93
|
+
...(input.agent === undefined ? {} : { agent: input.agent as HostAgent }),
|
|
73
94
|
title: input.title,
|
|
74
95
|
metadata: input.metadata,
|
|
75
96
|
permissions: input.permissions,
|
|
76
97
|
location: { directory: hostDirectory(input.directory) },
|
|
77
98
|
} satisfies HostCreate),
|
|
78
99
|
).then((created) => ({ id: String(created.id) })),
|
|
100
|
+
skill: async (directory, id) => {
|
|
101
|
+
const now = Date.now()
|
|
102
|
+
let held = skillsByDirectory.get(directory)
|
|
103
|
+
if (held === undefined || now - held.at >= SKILL_CACHE_MS) {
|
|
104
|
+
const listed = await run(ctx.skill.list({ location: { directory } }))
|
|
105
|
+
held = { at: now, skills: new Map(listed.data.map((skill): [string, string] => [skill.id, skill.content])) }
|
|
106
|
+
skillsByDirectory.set(directory, held)
|
|
107
|
+
}
|
|
108
|
+
return held.skills.get(id)
|
|
109
|
+
},
|
|
110
|
+
agent: async (directory, id) => {
|
|
111
|
+
const listed = await run(ctx.agent.list({ location: { directory } }))
|
|
112
|
+
return listed.data.find((agent) => agent.id === id)
|
|
113
|
+
},
|
|
79
114
|
prompt: (input) =>
|
|
80
115
|
run(
|
|
81
116
|
ctx.session.prompt({
|
|
@@ -86,6 +121,11 @@ export function createSessionPort(ctx: HostContext): SessionPort {
|
|
|
86
121
|
),
|
|
87
122
|
interrupt: (sessionID) =>
|
|
88
123
|
run(ctx.session.interrupt({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["interrupt"]>[0])).then(() => undefined),
|
|
124
|
+
remove: (sessionID) => run(ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0])).then(() => undefined),
|
|
125
|
+
directTools: () =>
|
|
126
|
+
run(ctx.tool.list()).then((tools) =>
|
|
127
|
+
tools.filter((tool) => tool.options?.codemode === false && (tool.options?.permission ?? tool.id) === tool.id).map((tool) => tool.id),
|
|
128
|
+
),
|
|
89
129
|
}
|
|
90
130
|
}
|
|
91
131
|
|
|
@@ -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/seeds.ts
CHANGED
|
@@ -39,6 +39,16 @@ export const REQUEST_PERMISSIONS: ReadonlyArray<{ action: string; resource: stri
|
|
|
39
39
|
{ action: `${SEED_PREFIX}*`, resource: "*", effect: "allow" },
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Session permissions of a request session of an app with option `serverTools`: the same deny-all, then
|
|
44
|
+
* each listed OpenCode tool id allowed by its exact name (no wildcard). The host takes the last matching
|
|
45
|
+
* rule (`Permission.evaluate`, findLast), so the later allows win over the deny. An app without
|
|
46
|
+
* `serverTools` gets exactly `REQUEST_PERMISSIONS`.
|
|
47
|
+
*/
|
|
48
|
+
export function requestPermissions(serverTools: ReadonlyArray<string>): ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }> {
|
|
49
|
+
return [...REQUEST_PERMISSIONS, ...serverTools.map((tool) => ({ action: tool, resource: "*", effect: "allow" as const }))]
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
/** The caller tools that get a seed in the model request (`tool_choice` none offers none, a named tool only that one). */
|
|
43
53
|
export function seedToolsFor(request: PlugRequest): ReadonlyArray<PlugTool> {
|
|
44
54
|
const choice = request.toolChoice
|
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
|
+
}
|
package/tui.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The `./tui` entrypoint
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// The `./tui` entrypoint for a plugin configured by local path. OpenCode resolves that as
|
|
2
|
+
// `<directory>/tui` with no `exports` lookup (packages/plugin/src/host.ts in OpenCode 2.0.26), so the
|
|
3
|
+
// entry lives at the package root. It re-exports the compiled file (`bun run build:tui`), not
|
|
4
|
+
// src/tui.tsx: a local path under node_modules is never Solid-compiled (see build-tui.ts).
|
|
5
|
+
export { default } from "./dist/tui.js"
|