opencode-plugboard 0.18.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 +32 -14
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +13 -6
- package/src/budgets.ts +200 -0
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +3 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +183 -10
- package/src/contract.ts +17 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +5 -1
- package/src/keys.ts +172 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +14 -2
- package/src/openai/request.ts +3 -0
- package/src/openai/response.ts +23 -5
- package/src/openai/route.ts +46 -7
- package/src/responses/request.ts +30 -14
- package/src/responses/route.ts +12 -5
- package/src/responses/store.ts +51 -0
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +20 -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/stop.ts +54 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +104 -5
- package/src/user.ts +55 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The output items of recent Responses answers, so a later request can refer to them with an
|
|
3
|
+
* `item_reference` (#147). The Vercel AI SDK keeps `store: true` by default and sends the earlier
|
|
4
|
+
* assistant turn as references to the items plugboard returned, so without this store its
|
|
5
|
+
* multi-turn chat is refused.
|
|
6
|
+
*
|
|
7
|
+
* Bounded and per app: an app's store holds up to 1000 answers for an hour, and an app reads only
|
|
8
|
+
* its own answers. Nothing is written to disk. The store lives on `globalThis` under a versioned
|
|
9
|
+
* key, as the other process-wide state does (docs/architecture.md, I6), so it outlives a plugin
|
|
10
|
+
* generation that is replaced. A reference to an item that was evicted, expired or never returned
|
|
11
|
+
* is a 400 that suggests `store: false`; it is never a guess.
|
|
12
|
+
*/
|
|
13
|
+
import { LRUCache } from "lru-cache"
|
|
14
|
+
|
|
15
|
+
type Item = Readonly<Record<string, unknown>>
|
|
16
|
+
|
|
17
|
+
const TTL_MS = 60 * 60 * 1000
|
|
18
|
+
const MAX_ANSWERS = 1000
|
|
19
|
+
const SLOT = Symbol.for("opencode-plugboard.responses-items/1")
|
|
20
|
+
|
|
21
|
+
const holder = globalThis as unknown as Record<symbol, Map<string, LRUCache<string, ReadonlyArray<Item>>> | undefined>
|
|
22
|
+
|
|
23
|
+
/** The store of one app: answer id -> its output items. */
|
|
24
|
+
function storeOf(app: string): LRUCache<string, ReadonlyArray<Item>> {
|
|
25
|
+
const apps = (holder[SLOT] ??= new Map())
|
|
26
|
+
let store = apps.get(app)
|
|
27
|
+
if (store === undefined) {
|
|
28
|
+
store = new LRUCache<string, ReadonlyArray<Item>>({ max: MAX_ANSWERS, ttl: TTL_MS })
|
|
29
|
+
apps.set(app, store)
|
|
30
|
+
}
|
|
31
|
+
return store
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isItem = (value: unknown): value is Item => typeof value === "object" && value !== null && typeof (value as Item).id === "string"
|
|
35
|
+
|
|
36
|
+
/** Keeps the output items of a completed or incomplete answer (a failed one has no items to replay). */
|
|
37
|
+
export function rememberAnswer(app: string, response: object): void {
|
|
38
|
+
const { id, output } = response as { id?: unknown; output?: unknown }
|
|
39
|
+
if (typeof id !== "string" || !Array.isArray(output)) return
|
|
40
|
+
const items = output.filter(isItem)
|
|
41
|
+
if (items.length > 0) storeOf(app).set(id, items)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The output item with this id that the app's recent answers returned, if still held. */
|
|
45
|
+
export function findItem(app: string, itemId: string): Item | undefined {
|
|
46
|
+
for (const items of storeOf(app).values()) {
|
|
47
|
+
const found = items.find((item) => item.id === itemId)
|
|
48
|
+
if (found !== undefined) return found
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
package/src/rpc.ts
CHANGED
|
@@ -26,6 +26,7 @@ export const UsageRpc = Rpc.define({
|
|
|
26
26
|
from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
|
|
27
27
|
to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
|
|
28
28
|
app: { type: "string", description: "One app name. Default: every app." },
|
|
29
|
+
user: { type: "string", description: "One end user id. Each app's `users` then lists only that user. Default: every user." },
|
|
29
30
|
},
|
|
30
31
|
additionalProperties: false,
|
|
31
32
|
},
|
|
@@ -37,7 +38,7 @@ export const UsageRpc = Rpc.define({
|
|
|
37
38
|
errors: {
|
|
38
39
|
invalid_query: {
|
|
39
40
|
type: "object",
|
|
40
|
-
properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or
|
|
41
|
+
properties: { param: { type: "string", description: "The query parameter that is wrong: from, to, app or user." } },
|
|
41
42
|
required: ["param"],
|
|
42
43
|
additionalProperties: false,
|
|
43
44
|
},
|
|
@@ -51,7 +52,7 @@ export const UsageRpc = Rpc.define({
|
|
|
51
52
|
function queryOf(input: unknown): UsageQuery {
|
|
52
53
|
const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
|
|
53
54
|
const text = (value: unknown) => (typeof value === "string" ? value : undefined)
|
|
54
|
-
return { from: text(record.from), to: text(record.to), app: text(record.app) }
|
|
55
|
+
return { from: text(record.from), to: text(record.to), app: text(record.app), user: text(record.user) }
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
/** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */
|
package/src/runtime.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { Cause, Effect, Stream } from "effect"
|
|
|
53
53
|
import stableStringify from "safe-stable-stringify"
|
|
54
54
|
import { raceAbort } from "./abort.js"
|
|
55
55
|
import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
|
|
56
|
+
import { personaOf } from "./models.js"
|
|
56
57
|
import type { PlugboardOptions } from "./config.js"
|
|
57
58
|
import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
|
|
58
59
|
import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
|
|
@@ -62,6 +63,8 @@ import { ensureAppDir } from "./session/history.js"
|
|
|
62
63
|
import type { HistoryPort } from "./session/host.js"
|
|
63
64
|
import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
|
|
64
65
|
import { createSessionStore, type SessionStore } from "./session/state.js"
|
|
66
|
+
import { createBudgetState, type BudgetState } from "./budgets.js"
|
|
67
|
+
import { createKeyState, type KeyState } from "./keys.js"
|
|
65
68
|
import { createUsageState, type UsageState } from "./usage.js"
|
|
66
69
|
|
|
67
70
|
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
@@ -115,8 +118,8 @@ const BACKEND_POLL_MS = 25
|
|
|
115
118
|
const BIND_RETRY_MS = 15_000
|
|
116
119
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
117
120
|
|
|
118
|
-
/** 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
119
|
-
const VERSION =
|
|
121
|
+
/** 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
|
|
122
|
+
const VERSION = 14
|
|
120
123
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
121
124
|
|
|
122
125
|
interface Listener {
|
|
@@ -180,6 +183,10 @@ interface Runtime {
|
|
|
180
183
|
cache?: { readonly fingerprint: string; readonly store: CacheStore }
|
|
181
184
|
/** The usage store's live days and retention clock (src/usage.ts). */
|
|
182
185
|
readonly usage: UsageState
|
|
186
|
+
/** The budget warnings already logged (src/budgets.ts). */
|
|
187
|
+
readonly budgets: BudgetState
|
|
188
|
+
/** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
|
|
189
|
+
readonly runtimeKeys: KeyState
|
|
183
190
|
listener?: Listener
|
|
184
191
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
185
192
|
pump?: Pump
|
|
@@ -193,6 +200,8 @@ const detachedMetrics = createMetrics()
|
|
|
193
200
|
const detachedStartedAt = Date.now()
|
|
194
201
|
const detachedLimits = createLimitState()
|
|
195
202
|
const detachedUsage = createUsageState()
|
|
203
|
+
const detachedBudgets = createBudgetState()
|
|
204
|
+
const detachedKeys = createKeyState()
|
|
196
205
|
let foreignVersionLogged = false
|
|
197
206
|
|
|
198
207
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -210,6 +219,8 @@ function runtime(): Runtime | undefined {
|
|
|
210
219
|
metrics: createMetrics(),
|
|
211
220
|
limits: createLimitState(),
|
|
212
221
|
usage: createUsageState(),
|
|
222
|
+
budgets: createBudgetState(),
|
|
223
|
+
runtimeKeys: createKeyState(),
|
|
213
224
|
startedAt: Date.now(),
|
|
214
225
|
}
|
|
215
226
|
slot[KEY] = created
|
|
@@ -291,6 +302,12 @@ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimi
|
|
|
291
302
|
/** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
|
|
292
303
|
export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
|
|
293
304
|
|
|
305
|
+
/** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
306
|
+
export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
|
|
307
|
+
|
|
308
|
+
/** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
|
|
309
|
+
export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
|
|
310
|
+
|
|
294
311
|
/**
|
|
295
312
|
* The options authentication reads: `keys` is the live key set, not the serving generation's own
|
|
296
313
|
* (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
|
|
@@ -328,7 +345,7 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
|
|
|
328
345
|
if (rt) rt.cache = held
|
|
329
346
|
}
|
|
330
347
|
const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
|
|
331
|
-
return cachedCaller(caller, config, held.store, count)
|
|
348
|
+
return cachedCaller(caller, config, held.store, count, (model) => personaOf(options, model) !== undefined)
|
|
332
349
|
}
|
|
333
350
|
return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
|
|
334
351
|
}
|
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/stop.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Caller stop sequences on the answer text (#145, #149). A provider ends the answer at a sequence
|
|
3
|
+
* but does not always return it, and Anthropic, OpenAI and Gemini all leave it out of the answer
|
|
4
|
+
* when the stop is the one that ended it. The host reports only `stop`, so the end of a plain
|
|
5
|
+
* stop's text is checked for a sequence, which is removed and reported. A streamed text keeps its
|
|
6
|
+
* last characters back (`StopHoldback`) until the finish decides whether they end in a sequence.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The longest of `stops` that `tail` ends with, if any. */
|
|
10
|
+
export const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
|
|
11
|
+
stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
|
|
12
|
+
|
|
13
|
+
/** Streamed text with its last `size` characters (the longest sequence) held back. Without sequences nothing is held. */
|
|
14
|
+
export class StopHoldback {
|
|
15
|
+
private held = ""
|
|
16
|
+
private readonly size: number
|
|
17
|
+
|
|
18
|
+
constructor(private readonly stops: ReadonlyArray<string>) {
|
|
19
|
+
this.size = Math.max(0, ...stops.map((stop) => stop.length))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Adds a chunk and returns the text that can be sent now: everything except the last `size` characters. */
|
|
23
|
+
push(chunk: string): string {
|
|
24
|
+
if (this.size === 0) return chunk
|
|
25
|
+
this.held += chunk
|
|
26
|
+
const out = this.held.slice(0, Math.max(0, this.held.length - this.size))
|
|
27
|
+
this.held = this.held.slice(out.length)
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Everything still held, unchanged. Used before a tool call or reasoning, which cannot be a stop. */
|
|
32
|
+
release(): string {
|
|
33
|
+
const out = this.held
|
|
34
|
+
this.held = ""
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The end of the text. `plainStop` is true when the model stopped for `stop` with no tool call:
|
|
40
|
+
* then a sequence the held text ends with is removed and returned as `matched`.
|
|
41
|
+
*/
|
|
42
|
+
finish(plainStop: boolean): { readonly text: string; readonly matched?: string } {
|
|
43
|
+
const matched = plainStop && this.size > 0 ? stopTail(this.held, this.stops) : undefined
|
|
44
|
+
const text = matched === undefined ? this.held : this.held.slice(0, this.held.length - matched.length)
|
|
45
|
+
this.held = ""
|
|
46
|
+
return matched === undefined ? { text } : { text, matched }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A complete text (no streaming): the text without the sequence it ends with, if any. */
|
|
51
|
+
export const stripStopTail = (text: string, stops: ReadonlyArray<string>): { readonly text: string; readonly matched?: string } => {
|
|
52
|
+
const matched = stopTail(text, stops)
|
|
53
|
+
return matched === undefined ? { text } : { text: text.slice(0, text.length - matched.length), matched }
|
|
54
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `x-plugboard-usage` (#52): the tokens and USD cost of the model calls a response answered, so a
|
|
3
|
+
* caller sees what one request cost without asking `GET /v1/usage`. It is set only while usage
|
|
4
|
+
* recording is on (`withUsage`, src/usage.ts), and only on a non-streaming response: a stream
|
|
5
|
+
* sends its headers with its first event, before the usage is known, so it has no such header.
|
|
6
|
+
*/
|
|
7
|
+
import type { ServerResponse } from "node:http"
|
|
8
|
+
import type { CallTotals } from "./contract.js"
|
|
9
|
+
|
|
10
|
+
export const USAGE_HEADER = "x-plugboard-usage"
|
|
11
|
+
|
|
12
|
+
/** The totals of one response, summed over its calls. `costUsd` is undefined when any call had no price. */
|
|
13
|
+
interface Totals {
|
|
14
|
+
readonly inputTokens: number
|
|
15
|
+
readonly outputTokens: number
|
|
16
|
+
readonly cacheReadTokens: number
|
|
17
|
+
readonly costUsd?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `input=<n>; output=<n>; cache_read=<n>; cost_usd=<x.xxxxxx>`; the cost part is left out for an unpriced model. */
|
|
21
|
+
export function formatUsage(totals: Totals): string {
|
|
22
|
+
const parts = [`input=${totals.inputTokens}`, `output=${totals.outputTokens}`, `cache_read=${totals.cacheReadTokens}`]
|
|
23
|
+
if (totals.costUsd !== undefined) parts.push(`cost_usd=${totals.costUsd.toFixed(6)}`)
|
|
24
|
+
return parts.join("; ")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `CallContext.onUsage` of one response: each answered call adds its totals and the header
|
|
29
|
+
* is rewritten while the headers are still open, so the fan-out's `n` choices sum into one value.
|
|
30
|
+
* `undefined` for a stream (see the module comment).
|
|
31
|
+
*/
|
|
32
|
+
export function reportUsage(response: ServerResponse, stream: boolean): ((call: CallTotals) => void) | undefined {
|
|
33
|
+
if (stream) return undefined
|
|
34
|
+
let totals: Totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }
|
|
35
|
+
return (call) => {
|
|
36
|
+
if (response.headersSent || response.destroyed) return
|
|
37
|
+
const costUsd = totals.costUsd === undefined || call.costUsd === undefined ? undefined : totals.costUsd + call.costUsd
|
|
38
|
+
totals = {
|
|
39
|
+
inputTokens: totals.inputTokens + call.inputTokens,
|
|
40
|
+
outputTokens: totals.outputTokens + call.outputTokens,
|
|
41
|
+
cacheReadTokens: totals.cacheReadTokens + call.cacheReadTokens,
|
|
42
|
+
...(costUsd === undefined ? {} : { costUsd }),
|
|
43
|
+
}
|
|
44
|
+
response.setHeader(USAGE_HEADER, formatUsage(totals))
|
|
45
|
+
}
|
|
46
|
+
}
|