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/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"
|
|
@@ -63,6 +64,7 @@ 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"
|
|
65
66
|
import { createBudgetState, type BudgetState } from "./budgets.js"
|
|
67
|
+
import { createKeyState, type KeyState } from "./keys.js"
|
|
66
68
|
import { createUsageState, type UsageState } from "./usage.js"
|
|
67
69
|
|
|
68
70
|
export type Handler = (request: IncomingMessage, response: ServerResponse) => void
|
|
@@ -116,8 +118,8 @@ const BACKEND_POLL_MS = 25
|
|
|
116
118
|
const BIND_RETRY_MS = 15_000
|
|
117
119
|
const BIND_REMINDER_MS = 5 * 60_000
|
|
118
120
|
|
|
119
|
-
/** 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. */
|
|
120
|
-
const VERSION =
|
|
121
|
+
/** 15: the last options error (`configError`, #159) and `catalogLoaded` for `/readyz` (#160). 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 = 15
|
|
121
123
|
const KEY = Symbol.for("opencode-plugboard.runtime")
|
|
122
124
|
|
|
123
125
|
interface Listener {
|
|
@@ -154,6 +156,12 @@ interface Pump {
|
|
|
154
156
|
stop(): void
|
|
155
157
|
}
|
|
156
158
|
|
|
159
|
+
/** A plugin options error and when it was recorded (epoch ms). */
|
|
160
|
+
export interface ConfigError {
|
|
161
|
+
readonly message: string
|
|
162
|
+
readonly at: number
|
|
163
|
+
}
|
|
164
|
+
|
|
157
165
|
interface Runtime {
|
|
158
166
|
readonly version: number
|
|
159
167
|
readonly sessions: SessionStore
|
|
@@ -183,6 +191,12 @@ interface Runtime {
|
|
|
183
191
|
readonly usage: UsageState
|
|
184
192
|
/** The budget warnings already logged (src/budgets.ts). */
|
|
185
193
|
readonly budgets: BudgetState
|
|
194
|
+
/** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
|
|
195
|
+
readonly runtimeKeys: KeyState
|
|
196
|
+
/** The last plugin options error (`index.ts`), cleared by the next Location whose options parse (#159). */
|
|
197
|
+
configError?: ConfigError
|
|
198
|
+
/** Set once the serving catalog has loaded successfully (`loadServingCatalog`); `/readyz` reads it (#160). */
|
|
199
|
+
catalogLoaded?: boolean
|
|
186
200
|
listener?: Listener
|
|
187
201
|
/** Present while a fiber is subscribed to the server event bus. */
|
|
188
202
|
pump?: Pump
|
|
@@ -197,6 +211,7 @@ const detachedStartedAt = Date.now()
|
|
|
197
211
|
const detachedLimits = createLimitState()
|
|
198
212
|
const detachedUsage = createUsageState()
|
|
199
213
|
const detachedBudgets = createBudgetState()
|
|
214
|
+
const detachedKeys = createKeyState()
|
|
200
215
|
let foreignVersionLogged = false
|
|
201
216
|
|
|
202
217
|
/** The process runtime, created on first use; undefined when a different version already owns the process. */
|
|
@@ -215,6 +230,7 @@ function runtime(): Runtime | undefined {
|
|
|
215
230
|
limits: createLimitState(),
|
|
216
231
|
usage: createUsageState(),
|
|
217
232
|
budgets: createBudgetState(),
|
|
233
|
+
runtimeKeys: createKeyState(),
|
|
218
234
|
startedAt: Date.now(),
|
|
219
235
|
}
|
|
220
236
|
slot[KEY] = created
|
|
@@ -299,6 +315,9 @@ export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
|
|
|
299
315
|
/** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
|
|
300
316
|
export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
|
|
301
317
|
|
|
318
|
+
/** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
|
|
319
|
+
export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
|
|
320
|
+
|
|
302
321
|
/**
|
|
303
322
|
* The options authentication reads: `keys` is the live key set, not the serving generation's own
|
|
304
323
|
* (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
|
|
@@ -336,7 +355,9 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
|
|
|
336
355
|
if (rt) rt.cache = held
|
|
337
356
|
}
|
|
338
357
|
const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
|
|
339
|
-
|
|
358
|
+
// One bypass predicate: a persona model, or an app whose answers depend on live OpenCode tool output (#82).
|
|
359
|
+
const serverToolApps = new Set(Object.entries(options.apps).filter(([, policy]) => policy.serverTools !== undefined).map(([app]) => app))
|
|
360
|
+
return cachedCaller(caller, config, held.store, count, (model, app) => personaOf(options, model) !== undefined || serverToolApps.has(app))
|
|
340
361
|
}
|
|
341
362
|
return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
|
|
342
363
|
}
|
|
@@ -348,12 +369,16 @@ export interface RuntimeStatus extends RuntimeSnapshot {
|
|
|
348
369
|
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
349
370
|
/** The serving generation's Location is a confirmed app directory (else the newest generation serves). */
|
|
350
371
|
readonly servingConfirmed: boolean
|
|
372
|
+
/** The last plugin options error, when its Location was the latest to load the plugin (#159). */
|
|
373
|
+
readonly configError?: ConfigError
|
|
374
|
+
/** The serving catalog has loaded at least once in this process (`/readyz`, #160). */
|
|
375
|
+
readonly catalogLoaded: boolean
|
|
351
376
|
}
|
|
352
377
|
|
|
353
378
|
/** A synchronous snapshot of the runtime: no host call, no filesystem. */
|
|
354
379
|
export function processStatus(): RuntimeStatus {
|
|
355
380
|
const rt = runtime()
|
|
356
|
-
if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false }
|
|
381
|
+
if (!rt) return { runtimeVersion: VERSION, generations: 0, pumpRunning: false, guards: detachedSessions.size(), startedAt: detachedStartedAt, servingConfirmed: false, catalogLoaded: false }
|
|
357
382
|
const serving = preferred(rt)
|
|
358
383
|
return {
|
|
359
384
|
runtimeVersion: rt.version,
|
|
@@ -362,10 +387,23 @@ export function processStatus(): RuntimeStatus {
|
|
|
362
387
|
guards: rt.sessions.size(),
|
|
363
388
|
startedAt: rt.startedAt,
|
|
364
389
|
servingConfirmed: serving !== undefined && rt.confirmed.has(serving.directory),
|
|
390
|
+
catalogLoaded: rt.catalogLoaded === true,
|
|
365
391
|
...(rt.listener ? { listener: { host: rt.listener.listen.host, port: rt.listener.listen.port, bound: rt.listener.bound } } : {}),
|
|
392
|
+
...(rt.configError ? { configError: rt.configError } : {}),
|
|
366
393
|
}
|
|
367
394
|
}
|
|
368
395
|
|
|
396
|
+
/**
|
|
397
|
+
* Records the plugin options error of the Location that just failed to parse its options, or clears
|
|
398
|
+
* it (`undefined`) once options parse. Synchronous, so plugin setup may call it. The status and
|
|
399
|
+
* `/readyz` show it while a listener from an earlier good generation still serves (#159).
|
|
400
|
+
*/
|
|
401
|
+
export function setConfigError(message: string | undefined): void {
|
|
402
|
+
const rt = runtime()
|
|
403
|
+
if (!rt) return
|
|
404
|
+
rt.configError = message === undefined ? undefined : { message, at: Date.now() }
|
|
405
|
+
}
|
|
406
|
+
|
|
369
407
|
/** The generation that serves requests and supplies the catalog: the newest confirmed app-directory one, else the newest. */
|
|
370
408
|
function preferred(rt: Runtime): Generation | undefined {
|
|
371
409
|
for (let index = rt.generations.length - 1; index >= 0; index--) if (rt.confirmed.has(rt.generations[index]!.directory)) return rt.generations[index]
|
|
@@ -376,9 +414,11 @@ function preferred(rt: Runtime): Generation | undefined {
|
|
|
376
414
|
* The catalog of the preferred accepted generation. `fallback` (the caller's own loader) is only
|
|
377
415
|
* used when no generation is registered, which a live request does not normally see.
|
|
378
416
|
*/
|
|
379
|
-
export function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
|
|
417
|
+
export async function loadServingCatalog(fallback: () => Promise<ReadonlyArray<CatalogModel>>): Promise<ReadonlyArray<CatalogModel>> {
|
|
380
418
|
const rt = runtime()
|
|
381
|
-
|
|
419
|
+
const models = await (rt ? (preferred(rt)?.loadCatalog ?? fallback) : fallback)()
|
|
420
|
+
if (rt) rt.catalogLoaded = true
|
|
421
|
+
return models
|
|
382
422
|
}
|
|
383
423
|
|
|
384
424
|
/** JSON with object keys sorted at every level, so equal options compare equal whatever their key order (undefined values are left out, as in `JSON.stringify`). */
|
|
@@ -512,7 +552,13 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
512
552
|
} else if (canonicalJson(keys) !== canonicalJson(rt.keys ?? {})) {
|
|
513
553
|
const live = rt.keys ?? {}
|
|
514
554
|
if (rt.generations.length === 0) rt.keys = keys
|
|
515
|
-
else if (
|
|
555
|
+
else if (Object.keys(keys).length === 0) {
|
|
556
|
+
// #152: removing the last key fails closed. The old key must not keep working, and the set must not go keyless.
|
|
557
|
+
if (canonicalJson(live) !== canonicalJson(REVOKED_KEYS)) {
|
|
558
|
+
log("listener.keys_updated", { removed: Object.keys(live).length, rotated: 0 })
|
|
559
|
+
rt.keys = REVOKED_KEYS
|
|
560
|
+
}
|
|
561
|
+
} else if (revokesOrRotatesOnly(live, keys)) {
|
|
516
562
|
log("listener.keys_updated", {
|
|
517
563
|
removed: Object.keys(live).filter((app) => !Object.hasOwn(keys, app)).length,
|
|
518
564
|
rotated: Object.keys(keys).filter((app) => Object.hasOwn(live, app) && live[app] !== keys[app]).length,
|
|
@@ -562,6 +608,13 @@ export function registerGeneration(input: GenerationInput): Registration {
|
|
|
562
608
|
}
|
|
563
609
|
}
|
|
564
610
|
|
|
611
|
+
/**
|
|
612
|
+
* #152: the live key set once the last key is revoked while a generation serves. An empty key set means
|
|
613
|
+
* keyless, so this one keeps an entry no request can match: `*` is not a valid app name, and its key is
|
|
614
|
+
* empty, which `Bearer` (it needs a non-empty token) never presents. Every request gets 401 until restart.
|
|
615
|
+
*/
|
|
616
|
+
const REVOKED_KEYS: Readonly<Record<string, string>> = { "*": "" }
|
|
617
|
+
|
|
565
618
|
/** #90: `next` names no app that `live` lacks, and names at least one app: it only removes or changes keys. */
|
|
566
619
|
function revokesOrRotatesOnly(live: Readonly<Record<string, string>>, next: Readonly<Record<string, string>>): boolean {
|
|
567
620
|
const apps = Object.keys(next)
|
package/src/session/caller.ts
CHANGED
|
@@ -18,15 +18,15 @@
|
|
|
18
18
|
|
|
19
19
|
import { randomUUID } from "node:crypto"
|
|
20
20
|
import { raceAbort } from "../abort.js"
|
|
21
|
-
import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
|
|
21
|
+
import { APP_NAME_PATTERN, DEFAULT_APP, DEFAULT_SERVER_TOOL_STEPS, 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
|
-
import { log, logError } from "../log.js"
|
|
24
|
-
import { checkAppModel } from "../models.js"
|
|
23
|
+
import { log, logError, logWarn } from "../log.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"
|
|
28
28
|
import { ImageError, prepareImages, type RemoteImages } from "./media.js"
|
|
29
|
-
import {
|
|
29
|
+
import { requestPermissions, seedToolsFor } from "./seeds.js"
|
|
30
30
|
import type { SessionStore } from "./state.js"
|
|
31
31
|
|
|
32
32
|
export interface SessionCallerDeps {
|
|
@@ -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
|
|
|
@@ -99,6 +106,33 @@ function checkAppTools(request: PlugRequest, app: string, policy: AppPolicy | un
|
|
|
99
106
|
message: `Request has ${request.tools.length} tools; app ${app} allows ${max} (option \`apps.${app}.maxTools\`)`,
|
|
100
107
|
}
|
|
101
108
|
}
|
|
109
|
+
/** A caller tool may not share a name with one of the app's server tools: the model would see two tools under one name. */
|
|
110
|
+
function checkServerToolNames(request: PlugRequest, policy: AppPolicy | undefined): PlugError | undefined {
|
|
111
|
+
const clash = request.tools.find((tool) => policy?.serverTools?.includes(tool.name))
|
|
112
|
+
if (!clash) return undefined
|
|
113
|
+
return {
|
|
114
|
+
status: 400,
|
|
115
|
+
kind: "invalid_request",
|
|
116
|
+
code: "server_tool_collision",
|
|
117
|
+
param: "tools",
|
|
118
|
+
message: `Tool \`${clash.name}\` is one of this app's OpenCode server tools (option \`apps.<name>.serverTools\`); rename the caller's tool`,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every server tool must be a direct tool of this location right now (fail closed; checked per request, never at setup). */
|
|
123
|
+
async function checkServerToolsAvailable(port: SessionPort, policy: AppPolicy | undefined, signal: AbortSignal): Promise<PlugError | undefined> {
|
|
124
|
+
if (!policy?.serverTools) return undefined
|
|
125
|
+
const offered = new Set(await raceAbort(port.directTools(), signal))
|
|
126
|
+
const missing = policy.serverTools.filter((tool) => !offered.has(tool))
|
|
127
|
+
if (missing.length === 0) return undefined
|
|
128
|
+
return {
|
|
129
|
+
status: 400,
|
|
130
|
+
kind: "invalid_request",
|
|
131
|
+
code: "server_tool_unavailable",
|
|
132
|
+
message: `OpenCode tool(s) not available in this location: ${missing.join(", ")} (option \`apps.<name>.serverTools\`). CodeMode tools, including MCP tools by default, cannot be listed; set codemode: false on the tool`,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
102
136
|
export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
103
137
|
const { port, sessions, maxTools, ensureBackend } = deps
|
|
104
138
|
const apps = deps.apps ?? {}
|
|
@@ -113,9 +147,12 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
113
147
|
const requestID = randomUUID()
|
|
114
148
|
const startedAt = Date.now()
|
|
115
149
|
let outcome = "cancelled"
|
|
150
|
+
// Whether the model produced anything at all (text, reasoning or a tool call), for `answer.empty` (#172).
|
|
151
|
+
let produced = false
|
|
116
152
|
const note = (event: PlugEvent): PlugEvent => {
|
|
117
153
|
if (event.type === "finish") outcome = `finish:${event.reason}`
|
|
118
154
|
else if (event.type === "error") outcome = `error:${event.error.status}${event.error.code ? `:${event.error.code}` : ""}`
|
|
155
|
+
else produced = true
|
|
119
156
|
return event
|
|
120
157
|
}
|
|
121
158
|
|
|
@@ -123,9 +160,11 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
123
160
|
const policy = appPolicy({ apps }, app)
|
|
124
161
|
const outOfScope = checkAppModel(policy, called.model, called.ref)
|
|
125
162
|
if (outOfScope) return yield note({ type: "error", error: outOfScope })
|
|
126
|
-
|
|
163
|
+
let { request, stripped } = applyToolPolicy(called, policy)
|
|
127
164
|
const tooMany = checkAppTools(request, app, policy)
|
|
128
165
|
if (tooMany) return yield note({ type: "error", error: tooMany })
|
|
166
|
+
const clash = checkServerToolNames(request, policy)
|
|
167
|
+
if (clash) return yield note({ type: "error", error: clash })
|
|
129
168
|
if (stripped) {
|
|
130
169
|
try {
|
|
131
170
|
context.onPolicy?.(["tools-stripped"])
|
|
@@ -165,24 +204,60 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
165
204
|
const unavailable = await ensureBackend?.(app, context.signal)
|
|
166
205
|
if (unavailable) return yield note({ type: "error", error: unavailable })
|
|
167
206
|
if (context.signal.aborted) return
|
|
207
|
+
const missingServerTools = await checkServerToolsAvailable(port, policy, context.signal)
|
|
208
|
+
if (missingServerTools) return yield note({ type: "error", error: missingServerTools })
|
|
209
|
+
if (context.signal.aborted) return
|
|
210
|
+
|
|
211
|
+
// 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
|
|
212
|
+
// caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
|
|
213
|
+
const persona = deps.persona?.(called.model)
|
|
214
|
+
let prelude: ReadonlyArray<string> = []
|
|
215
|
+
if (persona?.kind === "skill") {
|
|
216
|
+
const content = await port.skill(directory, persona.skill)
|
|
217
|
+
if (content === undefined) return yield note({ type: "error", error: notFound(`Skill ${persona.skill} is not available`) })
|
|
218
|
+
prelude = [content]
|
|
219
|
+
} else if (persona?.kind === "agent") {
|
|
220
|
+
const agent = await port.agent(directory, persona.agent)
|
|
221
|
+
if (agent === undefined) return yield note({ type: "error", error: notFound(`Agent ${persona.agent} is not available`) })
|
|
222
|
+
request = { ...request, system: agent.system ? [agent.system] : [] }
|
|
223
|
+
}
|
|
224
|
+
if (context.signal.aborted) return
|
|
225
|
+
|
|
226
|
+
collector = new RequestCollector({
|
|
227
|
+
stopTurn: interrupt,
|
|
228
|
+
requestID,
|
|
229
|
+
app,
|
|
230
|
+
...(policy?.serverTools
|
|
231
|
+
? { serverTools: new Set(policy.serverTools), maxServerToolSteps: policy.maxServerToolSteps ?? DEFAULT_SERVER_TOOL_STEPS }
|
|
232
|
+
: {}),
|
|
233
|
+
})
|
|
168
234
|
|
|
169
|
-
|
|
170
|
-
const created = await raceAbort(port.create({
|
|
235
|
+
const creating = port.create({
|
|
171
236
|
directory,
|
|
172
237
|
model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
|
|
238
|
+
...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
|
|
173
239
|
title: makeTitle(request),
|
|
174
240
|
metadata: { plugboard: { app, model: request.model, requestID } },
|
|
175
|
-
permissions:
|
|
176
|
-
})
|
|
241
|
+
permissions: requestPermissions(policy?.serverTools ?? []),
|
|
242
|
+
})
|
|
243
|
+
const created = await raceAbort(creating, context.signal).catch((error) => {
|
|
244
|
+
// The host keeps creating after the abort. A session that lands now is in no index, so it is removed.
|
|
245
|
+
if (context.signal.aborted)
|
|
246
|
+
void creating.then(
|
|
247
|
+
(late) => port.remove(late.id).catch((removeError) => logError("session.late_remove_failed", { requestID, app, sessionID: late.id, error: describeError(removeError) })),
|
|
248
|
+
() => {},
|
|
249
|
+
)
|
|
250
|
+
throw error
|
|
251
|
+
})
|
|
177
252
|
sessionID = String(created.id)
|
|
178
|
-
sessions.register({ sessionID, app, requestID, request, collector })
|
|
253
|
+
sessions.register({ sessionID, app, requestID, request, collector, prelude })
|
|
179
254
|
|
|
180
255
|
context.signal.addEventListener("abort", onAbort, { once: true })
|
|
181
256
|
if (context.signal.aborted) return onAbort()
|
|
182
257
|
|
|
183
258
|
// The history id is known from here on: tell the front-end before the first event.
|
|
184
259
|
try {
|
|
185
|
-
context.onRecord?.(sessionID)
|
|
260
|
+
context.onRecord?.(sessionID, directory)
|
|
186
261
|
} catch (error) {
|
|
187
262
|
logError("request.on_record_failed", { requestID, app, error: describeError(error) })
|
|
188
263
|
}
|
|
@@ -217,6 +292,16 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
|
|
|
217
292
|
if (promptStarted) sessions.retire(sessionID, { timeoutMs: CLEANUP_TIMEOUT_MS, onTimeout: interrupt })
|
|
218
293
|
else sessions.release(sessionID)
|
|
219
294
|
}
|
|
295
|
+
const serverToolCalls = collector?.serverCallCount ?? 0
|
|
296
|
+
if (serverToolCalls > 0 && context.onServerTools) {
|
|
297
|
+
try {
|
|
298
|
+
context.onServerTools(serverToolCalls)
|
|
299
|
+
} catch (error) {
|
|
300
|
+
logError("request.on_server_tools_failed", { requestID, app, error: describeError(error) })
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// A turn that stopped with nothing in it is kept as a session for inspection: the session id finds it.
|
|
304
|
+
if (outcome === "finish:stop" && !produced) logWarn("answer.empty", { requestID, app, model: request.model, sessionID, tools: request.tools.length })
|
|
220
305
|
log("request.finished", { requestID, app, model: request.model, durationMs: Date.now() - startedAt, outcome })
|
|
221
306
|
}
|
|
222
307
|
},
|
package/src/session/context.ts
CHANGED
|
@@ -59,10 +59,13 @@ function generationOptions(request: PlugRequest): Record<string, unknown> {
|
|
|
59
59
|
* Returns how many caller tools got no seed (zero unless the seed pool is smaller than the request
|
|
60
60
|
* needs, or two caller tools share a name and would collapse into one key).
|
|
61
61
|
*
|
|
62
|
+
* The app's `serverTools` (option `apps`) are kept as the host offered them: their descriptors were
|
|
63
|
+
* never rewritten, so the host runs the real tool. Not offered when the caller set `tool_choice` none.
|
|
64
|
+
*
|
|
62
65
|
* The map is built with `Object.fromEntries`: a tool named `__proto__` becomes an own key like any
|
|
63
66
|
* other instead of assigning the prototype and vanishing.
|
|
64
67
|
*/
|
|
65
|
-
function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number {
|
|
68
|
+
function remapSeedTools(session: ContextHookEvent, request: PlugRequest, serverTools: ReadonlyArray<string>): number {
|
|
66
69
|
const seeds = Object.entries(session.tools ?? {}).filter(([name]) => name.startsWith(SEED_PREFIX))
|
|
67
70
|
const tools = seedToolsFor(request)
|
|
68
71
|
const next = new Map<string, HostToolDescriptor>()
|
|
@@ -77,8 +80,16 @@ function remapSeedTools(session: ContextHookEvent, request: PlugRequest): number
|
|
|
77
80
|
descriptor.input = tool.parameters
|
|
78
81
|
next.set(tool.name, descriptor)
|
|
79
82
|
}
|
|
83
|
+
const missing = tools.length - next.size
|
|
84
|
+
if (request.toolChoice !== "none") {
|
|
85
|
+
const offered = session.tools ?? {}
|
|
86
|
+
for (const name of serverTools) {
|
|
87
|
+
if (!Object.hasOwn(offered, name) || next.has(name)) continue
|
|
88
|
+
next.set(name, offered[name] as HostToolDescriptor)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
80
91
|
session.tools = Object.fromEntries(next)
|
|
81
|
-
return
|
|
92
|
+
return missing
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
/**
|
|
@@ -109,6 +120,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
|
|
|
109
120
|
const state = sessions.lookup(String(session.sessionID))
|
|
110
121
|
if (!state) return
|
|
111
122
|
const { request } = state
|
|
123
|
+
const policy = appPolicy({ apps }, state.app)
|
|
112
124
|
|
|
113
125
|
// Swap media placeholders for the Asset-carrying parts the host built from the prompt files.
|
|
114
126
|
const hostMedia = (session.messages ?? [])
|
|
@@ -123,7 +135,9 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
|
|
|
123
135
|
return text("[image unavailable]")
|
|
124
136
|
}
|
|
125
137
|
|
|
126
|
-
|
|
138
|
+
// Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
|
|
139
|
+
// then the tool-choice steering below.
|
|
140
|
+
const system = [...(state.prelude ?? []), ...systemFor(request.system, policy)].map(text)
|
|
127
141
|
// The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
|
|
128
142
|
const choice = request.toolChoice
|
|
129
143
|
if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
|
|
@@ -132,7 +146,7 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
|
|
|
132
146
|
session.messages = toHostMessages(request.messages).map((message) => ({ ...message, content: message.content.map(swap) }))
|
|
133
147
|
Object.assign(session.options ?? (session.options = {}), generationOptions(request))
|
|
134
148
|
|
|
135
|
-
const missingTools = remapSeedTools(session, request)
|
|
149
|
+
const missingTools = remapSeedTools(session, request, policy?.serverTools ?? [])
|
|
136
150
|
|
|
137
151
|
// The caller validates both before dispatch; this only fires if the host disagrees. The request is
|
|
138
152
|
// already rewritten (no real tool can run); fail it instead of answering from a truncated prompt.
|
package/src/session/events.ts
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
* ModelCaller streams out through `events()` (not buffered beyond the queue).
|
|
7
7
|
*
|
|
8
8
|
* The turn is stopped from inside the model loop (see `session/seeds.ts`): once the step has ended
|
|
9
|
-
* (`session.step.ended`) with at least one tool call, the owner is asked to interrupt the
|
|
9
|
+
* (`session.step.ended`) with at least one caller tool call, the owner is asked to interrupt the
|
|
10
10
|
* session before a second model step can start, and the request finishes as `tool-calls`.
|
|
11
11
|
* The collector never calls the host itself; the owner passes `stopTurn`.
|
|
12
|
+
*
|
|
13
|
+
* A call to one of the app's `serverTools` is not the caller's: OpenCode runs it, the runner starts
|
|
14
|
+
* the next step with its result, and the collector neither emits it nor stops the turn. A step with
|
|
15
|
+
* only server calls continues; a step that also holds a caller call is returned to the caller as usual.
|
|
16
|
+
* Server steps are counted and capped (`maxServerToolSteps`); each server call is logged once.
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import { isPlugError, type PlugError, type PlugEvent, type PlugFinish, type PlugUsage } from "../contract.js"
|
|
15
|
-
import { logError } from "../log.js"
|
|
20
|
+
import { log, logError } from "../log.js"
|
|
16
21
|
import type { BusEvent } from "./host.js"
|
|
17
22
|
import { FAILURE_EVENT } from "./state.js"
|
|
18
23
|
|
|
@@ -145,6 +150,18 @@ const toUsage = (tokens?: RawUsage): PlugUsage | undefined =>
|
|
|
145
150
|
const CONTEXT_OVERFLOW =
|
|
146
151
|
/context[_ ](?:length|window)|maximum context|prompt (?:is )?too long|too many tokens|token limit|input length.*exceeds|exceeds the (?:available )?context|reduce the length of the messages/i
|
|
147
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Provider wording that the status does not settle (#171). A ChatGPT subscription at its limit is a
|
|
155
|
+
* `provider.invalid-request` with `subscription_sharing_usage_limit_exceeded` and no status. These
|
|
156
|
+
* are matched on the message and the raw body, and only ever decide a class; the text never leaves.
|
|
157
|
+
*/
|
|
158
|
+
const USAGE_LIMIT = /usage[_ ]limit|quota/i
|
|
159
|
+
const RATE_LIMIT = /rate[_ ]limit|too many requests/i
|
|
160
|
+
const AUTH_FAILURE = /unauthori[sz]ed|invalid[_ ]api[_ ]key|incorrect api key|not logged in|log in again|sign in|permission denied/i
|
|
161
|
+
const MODEL_NOT_FOUND = /model[^\n]{0,60}(?:not found|does not exist|not supported|unknown)|unknown model|model_not_found/i
|
|
162
|
+
|
|
163
|
+
const AUTH_MESSAGE = "The model provider rejected its credentials; the plugboard host needs to sign in again"
|
|
164
|
+
|
|
148
165
|
/**
|
|
149
166
|
* Host `SessionError` (`{ type, message, status?, response? }`) to a `PlugError`. Provider and host
|
|
150
167
|
* error text can carry endpoints, account or organization ids, so it never reaches the caller:
|
|
@@ -167,10 +184,9 @@ function toPlugError(error: RawError | undefined, fallback: string): PlugError {
|
|
|
167
184
|
return { status: 400, kind: "invalid_request", code: "context_length_exceeded", param: "messages", message: "The request is longer than the model's context window" }
|
|
168
185
|
}
|
|
169
186
|
if (type === "provider.auth" || status === 401 || status === 403) {
|
|
170
|
-
|
|
171
|
-
if (status ===
|
|
172
|
-
|
|
173
|
-
return { status: 502, kind: "server", code: "provider_auth_failed", message }
|
|
187
|
+
if (status === 401) return { status, kind: "authentication", code: "provider_auth_failed", message: AUTH_MESSAGE }
|
|
188
|
+
if (status === 403) return { status, kind: "permission", code: "provider_auth_failed", message: AUTH_MESSAGE }
|
|
189
|
+
return { status: 502, kind: "server", code: "provider_auth_failed", message: AUTH_MESSAGE }
|
|
174
190
|
}
|
|
175
191
|
if (type === "provider.content-filter") {
|
|
176
192
|
return { status: 400, kind: "invalid_request", code: "content_filter", message: "The model provider's content filter blocked the request" }
|
|
@@ -179,6 +195,11 @@ function toPlugError(error: RawError | undefined, fallback: string): PlugError {
|
|
|
179
195
|
return { status: 504, kind: "timeout", code: "provider_timeout", message: "The model provider timed out" }
|
|
180
196
|
}
|
|
181
197
|
if (type.startsWith("provider.")) {
|
|
198
|
+
const detail = `${error?.message ?? ""}\n${error?.response?.body ?? ""}`
|
|
199
|
+
if (USAGE_LIMIT.test(detail)) return { status: 429, kind: "rate_limit", code: "quota_exceeded", message: "The model provider's usage quota is exhausted" }
|
|
200
|
+
if (RATE_LIMIT.test(detail)) return { status: 429, kind: "rate_limit", code: "rate_limited", message: "The model provider is rate limiting requests; retry later" }
|
|
201
|
+
if (MODEL_NOT_FOUND.test(detail)) return { status: 404, kind: "not_found", code: "model_not_found", param: "model", message: "The model provider does not know the requested model" }
|
|
202
|
+
if (AUTH_FAILURE.test(detail)) return { status: 502, kind: "server", code: "provider_auth_failed", message: AUTH_MESSAGE }
|
|
182
203
|
if (status === 404) return { status, kind: "not_found", code: "provider_rejected_request", message: "The model provider rejected the request" }
|
|
183
204
|
if (status !== undefined && status < 500) return { status, kind: "invalid_request", code: "provider_rejected_request", message: "The model provider rejected the request" }
|
|
184
205
|
return { status: status ?? 502, kind: "server", code: "provider_unavailable", message: "The model provider is unavailable or returned an error" }
|
|
@@ -191,6 +212,18 @@ export interface CollectorHooks {
|
|
|
191
212
|
readonly stopTurn: () => void
|
|
192
213
|
/** The request's id, for the log line that carries a failure's raw host text. */
|
|
193
214
|
readonly requestID?: string
|
|
215
|
+
/** The app, for the `server_tool.call` log lines. */
|
|
216
|
+
readonly app?: string
|
|
217
|
+
/** The app's `serverTools` (option `apps`): OpenCode runs these calls, the caller never sees them. */
|
|
218
|
+
readonly serverTools?: ReadonlySet<string>
|
|
219
|
+
/** Steps that may run server tools before the turn is stopped. The owner passes it with `serverTools`. */
|
|
220
|
+
readonly maxServerToolSteps?: number
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
interface ServerCall {
|
|
224
|
+
readonly name: string
|
|
225
|
+
readonly startedAt: number
|
|
226
|
+
done: boolean
|
|
194
227
|
}
|
|
195
228
|
|
|
196
229
|
export class RequestCollector {
|
|
@@ -198,6 +231,9 @@ export class RequestCollector {
|
|
|
198
231
|
private readonly emittedText = new Map<number, number>()
|
|
199
232
|
private readonly toolNames = new Map<string, string>()
|
|
200
233
|
private readonly calls = new Set<string>()
|
|
234
|
+
private readonly serverCalls = new Map<string, ServerCall>()
|
|
235
|
+
private stepServerCalls = 0
|
|
236
|
+
private serverSteps = 0
|
|
201
237
|
private usage?: RawUsage
|
|
202
238
|
private finished = false
|
|
203
239
|
private executionDone = false
|
|
@@ -221,6 +257,11 @@ export class RequestCollector {
|
|
|
221
257
|
return this.settled
|
|
222
258
|
}
|
|
223
259
|
|
|
260
|
+
/** How many server tool calls OpenCode ran in this session (each counted when it started). */
|
|
261
|
+
get serverCallCount(): number {
|
|
262
|
+
return this.serverCalls.size
|
|
263
|
+
}
|
|
264
|
+
|
|
224
265
|
/** True once the owner cancelled the request (client gone or timeout). */
|
|
225
266
|
get cancelled(): boolean {
|
|
226
267
|
return this.wasCancelled
|
|
@@ -274,23 +315,48 @@ export class RequestCollector {
|
|
|
274
315
|
case "session.tool.called": {
|
|
275
316
|
if (this.finished) return
|
|
276
317
|
const id = String(item.id)
|
|
277
|
-
if (this.calls.has(id)) return
|
|
318
|
+
if (this.calls.has(id) || this.serverCalls.has(id)) return
|
|
319
|
+
const name = String(item.name ?? this.toolNames.get(id) ?? "")
|
|
320
|
+
if (this.hooks.serverTools?.has(name)) {
|
|
321
|
+
this.serverCalls.set(id, { name, startedAt: Date.now(), done: false })
|
|
322
|
+
this.stepServerCalls++
|
|
323
|
+
return
|
|
324
|
+
}
|
|
278
325
|
this.calls.add(id)
|
|
279
|
-
this.emit({ type: "tool-call", id, name
|
|
326
|
+
this.emit({ type: "tool-call", id, name, input: item.input })
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
case "session.tool.success":
|
|
330
|
+
case "session.tool.failed": {
|
|
331
|
+
const call = this.serverCalls.get(String(item.id))
|
|
332
|
+
if (!call || call.done) return
|
|
333
|
+
call.done = true
|
|
334
|
+
log("server_tool.call", {
|
|
335
|
+
requestID: this.hooks.requestID,
|
|
336
|
+
app: this.hooks.app,
|
|
337
|
+
tool: call.name,
|
|
338
|
+
durationMs: Date.now() - call.startedAt,
|
|
339
|
+
ok: item.type === "session.tool.success",
|
|
340
|
+
})
|
|
280
341
|
return
|
|
281
342
|
}
|
|
282
343
|
case "session.usage.updated":
|
|
283
344
|
// Assumed invariant: `tokens` is the host's token-usage record (RawUsage shape).
|
|
284
345
|
this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
|
|
285
346
|
return
|
|
286
|
-
case "session.step.ended":
|
|
347
|
+
case "session.step.ended": {
|
|
287
348
|
this.usage = (item.tokens as RawUsage | undefined) ?? this.usage
|
|
349
|
+
const serverCalls = this.stepServerCalls
|
|
350
|
+
this.stepServerCalls = 0
|
|
288
351
|
// Also after the response finished: a later step that ended with calls means the first
|
|
289
352
|
// interrupt lost the race or failed; ask again (the guard stays until the execution ends).
|
|
290
353
|
if (this.calls.size > 0 && !this.executionDone) this.hooks.stopTurn()
|
|
291
|
-
|
|
292
|
-
|
|
354
|
+
const reason = toFinish(item.finish)
|
|
355
|
+
if (this.calls.size === 0 && serverCalls > 0) return this.serverStepEnded(reason)
|
|
356
|
+
if (this.calls.size === 0 && reason === "stop") this.settled = true
|
|
357
|
+
this.finishWith(withCalls(reason, this.calls.size > 0))
|
|
293
358
|
return
|
|
359
|
+
}
|
|
294
360
|
case FAILURE_EVENT:
|
|
295
361
|
// Synthetic, from inside the process (lost event stream, preparation mismatch).
|
|
296
362
|
if (isPlugError(item.error)) this.abort(item.error)
|
|
@@ -308,12 +374,14 @@ export class RequestCollector {
|
|
|
308
374
|
}
|
|
309
375
|
case "session.execution.failed":
|
|
310
376
|
this.executionDone = true
|
|
377
|
+
this.logUnfinishedServerCalls()
|
|
311
378
|
if (!this.finished) this.failFromHost(item.error as RawError | undefined, "Session execution failed")
|
|
312
379
|
this.end()
|
|
313
380
|
return
|
|
314
381
|
case "session.execution.interrupted":
|
|
315
382
|
case "session.execution.succeeded":
|
|
316
383
|
this.executionDone = true
|
|
384
|
+
this.logUnfinishedServerCalls()
|
|
317
385
|
if (!this.finished) {
|
|
318
386
|
if (this.calls.size > 0) this.finishWith("tool-calls")
|
|
319
387
|
else if (this.wasCancelled) this.end()
|
|
@@ -346,16 +414,52 @@ export class RequestCollector {
|
|
|
346
414
|
this.end()
|
|
347
415
|
}
|
|
348
416
|
|
|
417
|
+
/**
|
|
418
|
+
* A step that ran only server tools: the runner starts the next step with their results. The cap is
|
|
419
|
+
* checked on each such step; past it the request fails and the turn is stopped.
|
|
420
|
+
*/
|
|
421
|
+
private serverStepEnded(reason: PlugFinish): void {
|
|
422
|
+
this.serverSteps++
|
|
423
|
+
if (this.serverSteps > (this.hooks.maxServerToolSteps ?? 0)) {
|
|
424
|
+
this.abort({
|
|
425
|
+
status: 502,
|
|
426
|
+
kind: "server",
|
|
427
|
+
code: "server_tool_steps_exceeded",
|
|
428
|
+
message: "The model used more OpenCode tool steps than this app allows",
|
|
429
|
+
})
|
|
430
|
+
return
|
|
431
|
+
}
|
|
432
|
+
if (reason === "stop" || reason === "tool-calls") return
|
|
433
|
+
this.finishWith(reason)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Server calls the execution ended before they reported back are logged as not ok, once each. */
|
|
437
|
+
private logUnfinishedServerCalls(): void {
|
|
438
|
+
for (const call of this.serverCalls.values()) {
|
|
439
|
+
if (call.done) continue
|
|
440
|
+
call.done = true
|
|
441
|
+
log("server_tool.call", {
|
|
442
|
+
requestID: this.hooks.requestID,
|
|
443
|
+
app: this.hooks.app,
|
|
444
|
+
tool: call.name,
|
|
445
|
+
durationMs: Date.now() - call.startedAt,
|
|
446
|
+
ok: false,
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
349
451
|
/** Fail with the caller-safe mapping of a host error; the raw text is logged here and goes nowhere else. */
|
|
350
452
|
private failFromHost(raw: RawError | undefined, fallback: string): void {
|
|
351
453
|
if (this.finished) return
|
|
454
|
+
const error = toPlugError(raw, fallback)
|
|
352
455
|
logError("model.failed", {
|
|
353
456
|
requestID: this.hooks.requestID,
|
|
354
457
|
type: raw?.type,
|
|
355
458
|
status: raw?.status,
|
|
459
|
+
code: error.code,
|
|
356
460
|
message: raw?.message?.slice(0, 500),
|
|
357
461
|
})
|
|
358
|
-
this.fail(
|
|
462
|
+
this.fail(error)
|
|
359
463
|
}
|
|
360
464
|
|
|
361
465
|
private fail(error: PlugError): void {
|