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
package/src/config.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { createHash, timingSafeEqual } from "node:crypto"
|
|
4
4
|
import type { CacheConfig } from "./cache.js"
|
|
5
5
|
import { isPlugError, type PlugError } from "./contract.js"
|
|
6
|
-
import { allowedBy, parseModelRef } from "./models.js"
|
|
6
|
+
import { allowedBy, parseAliasTarget, parseModelRef, SKILL_PREFIX } from "./models.js"
|
|
7
7
|
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
8
8
|
import type { HistoryLimits } from "./session/prune.js"
|
|
9
9
|
|
|
@@ -21,6 +21,11 @@ export interface PlugboardOptions {
|
|
|
21
21
|
readonly models: ReadonlyArray<string>
|
|
22
22
|
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
23
23
|
readonly aliases: Readonly<Record<string, string>>
|
|
24
|
+
/**
|
|
25
|
+
* Agent name -> `provider/model[#variant]` (option `agents`): each is listed as `agent/<name>` and runs its
|
|
26
|
+
* OpenCode agent's system prompt on that model. The model must pass the allowlist.
|
|
27
|
+
*/
|
|
28
|
+
readonly agents: Readonly<Record<string, string>>
|
|
24
29
|
/**
|
|
25
30
|
* Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
|
|
26
31
|
* first target, so everything that resolves an alias keeps working; the model caller retries the rest
|
|
@@ -50,12 +55,16 @@ export interface PlugboardOptions {
|
|
|
50
55
|
readonly modelIds: ModelIds
|
|
51
56
|
/** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
|
|
52
57
|
readonly limits?: Limits
|
|
58
|
+
/** Per-app budgets (see `src/budgets.ts`): ceilings on requests, tokens and cost per UTC day, week or month. Needs `usage`. Absent: no budgets. */
|
|
59
|
+
readonly budgets?: Budgets
|
|
53
60
|
/** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
|
|
54
61
|
readonly cache?: CacheConfig
|
|
55
62
|
/** Per-app usage statistics (see `src/usage.ts`): recorded and served by `GET /v1/usage` and the RPC. */
|
|
56
63
|
readonly usage: UsageConfig
|
|
57
64
|
/** A key that may read every app's usage on `GET /v1/usage`. Compared like app keys; must differ from every one of them. */
|
|
58
65
|
readonly adminKey?: string
|
|
66
|
+
/** A template for the OpenCode session page of each request: `{id}`, and optional `{directory}` (see src/session/link.ts, #77). */
|
|
67
|
+
readonly historyUrl?: string
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/** Option `usage` after defaults: on, and days of history kept. */
|
|
@@ -99,6 +108,34 @@ export interface Limits {
|
|
|
99
108
|
readonly apps?: Readonly<Record<string, LimitCaps>>
|
|
100
109
|
}
|
|
101
110
|
|
|
111
|
+
/** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
|
|
112
|
+
export type BudgetMetric = "requests" | "tokens" | "costUsd"
|
|
113
|
+
/** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
|
|
114
|
+
export type BudgetWindow = "day" | "week" | "month"
|
|
115
|
+
|
|
116
|
+
/** Ceilings per window. A window that is not set has no ceiling. */
|
|
117
|
+
export interface BudgetWindows {
|
|
118
|
+
readonly day?: number
|
|
119
|
+
readonly week?: number
|
|
120
|
+
readonly month?: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One set of ceilings. A metric that is not set is unlimited. `costUsd` is an estimate at list prices. */
|
|
124
|
+
export interface Budget {
|
|
125
|
+
readonly requests?: BudgetWindows
|
|
126
|
+
readonly tokens?: BudgetWindows
|
|
127
|
+
readonly costUsd?: BudgetWindows
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Option `budgets`. `perApp` applies to every app; `apps.<name>` replaces `perApp` metric by metric for
|
|
132
|
+
* that app (its `requests` replaces `perApp.requests`, the other metrics still come from `perApp`).
|
|
133
|
+
*/
|
|
134
|
+
export interface Budgets {
|
|
135
|
+
readonly perApp?: Budget
|
|
136
|
+
readonly apps?: Readonly<Record<string, Budget>>
|
|
137
|
+
}
|
|
138
|
+
|
|
102
139
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
103
140
|
export const DEFAULT_APP = "default"
|
|
104
141
|
const DEFAULTS = {
|
|
@@ -109,7 +146,7 @@ const DEFAULTS = {
|
|
|
109
146
|
requestTimeoutMs: 600_000,
|
|
110
147
|
} as const
|
|
111
148
|
|
|
112
|
-
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "cache", "usage"])
|
|
149
|
+
const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "agents", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage", "historyUrl"])
|
|
113
150
|
const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
|
|
114
151
|
const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
|
|
115
152
|
/** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
|
|
@@ -304,6 +341,117 @@ function parseCache(value: unknown, keys: Readonly<Record<string, string>>): Cac
|
|
|
304
341
|
return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
|
|
305
342
|
}
|
|
306
343
|
|
|
344
|
+
const BUDGET_METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
|
|
345
|
+
const BUDGET_WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
|
|
346
|
+
/** The most days a window can span: a month is up to 31. Retention must keep at least that many, or a budget would count from pruned records. */
|
|
347
|
+
const WINDOW_DAYS: Readonly<Record<BudgetWindow, number>> = { day: 1, week: 7, month: 31 }
|
|
348
|
+
|
|
349
|
+
/** The longest window any ceiling of `budgets` uses, in days (0 without budgets). */
|
|
350
|
+
function widestWindowDays(budgets: Budgets | undefined): number {
|
|
351
|
+
let most = 0
|
|
352
|
+
for (const budget of [budgets?.perApp, ...Object.values(budgets?.apps ?? {})])
|
|
353
|
+
for (const metric of [budget?.requests, budget?.tokens, budget?.costUsd])
|
|
354
|
+
for (const window of Object.keys(metric ?? {}) as BudgetWindow[]) most = Math.max(most, WINDOW_DAYS[window])
|
|
355
|
+
return most
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Validates `budgets`: `{ perApp?, apps? }`, each a budget of `requests`, `tokens` and `costUsd`, each of
|
|
360
|
+
* those a map of window (`day`, `week`, `month`) to a positive limit. Counts are integers, costs any positive
|
|
361
|
+
* number of USD. An `apps` entry must name a configured app, as in `limits`.
|
|
362
|
+
*/
|
|
363
|
+
function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Budgets | undefined {
|
|
364
|
+
if (raw === undefined) return undefined
|
|
365
|
+
if (!isRecord(raw)) throw new Error("plugboard option `budgets` must be an object")
|
|
366
|
+
for (const key of Object.keys(raw))
|
|
367
|
+
if (key !== "perApp" && key !== "apps") throw new Error(`Unknown plugboard option \`budgets.${key}\` (known: perApp, apps)`)
|
|
368
|
+
|
|
369
|
+
const budget = (value: unknown, at: string): Budget => {
|
|
370
|
+
if (!isRecord(value)) throw new Error(`plugboard option \`${at}\` must be an object`)
|
|
371
|
+
for (const key of Object.keys(value))
|
|
372
|
+
if (!BUDGET_METRICS.includes(key as BudgetMetric)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${BUDGET_METRICS.join(", ")})`)
|
|
373
|
+
const parsed: Record<string, Record<string, number>> = {}
|
|
374
|
+
for (const metric of BUDGET_METRICS) {
|
|
375
|
+
const windows = value[metric]
|
|
376
|
+
if (windows === undefined) continue
|
|
377
|
+
const field = `${at}.${metric}`
|
|
378
|
+
if (!isRecord(windows)) throw new Error(`plugboard option \`${field}\` must be an object of window (${BUDGET_WINDOWS.join(", ")}) to limit`)
|
|
379
|
+
const limits: Record<string, number> = {}
|
|
380
|
+
for (const window of Object.keys(windows)) {
|
|
381
|
+
if (!BUDGET_WINDOWS.includes(window as BudgetWindow)) throw new Error(`Unknown plugboard option \`${field}.${window}\` (known: ${BUDGET_WINDOWS.join(", ")})`)
|
|
382
|
+
const limit = windows[window]
|
|
383
|
+
if (limit === undefined) continue
|
|
384
|
+
if (metric === "costUsd") {
|
|
385
|
+
if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) throw new Error(`plugboard option \`${field}.${window}\` must be a positive number of USD`)
|
|
386
|
+
limits[window] = limit
|
|
387
|
+
} else limits[window] = positiveInt(limit, `${field}.${window}`, 0, Number.MAX_SAFE_INTEGER)
|
|
388
|
+
}
|
|
389
|
+
parsed[metric] = limits
|
|
390
|
+
}
|
|
391
|
+
return parsed as Budget
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const perApp = raw.perApp === undefined ? undefined : budget(raw.perApp, "budgets.perApp")
|
|
395
|
+
if (raw.apps !== undefined && !isRecord(raw.apps)) throw new Error("plugboard option `budgets.apps` must be an object of app name -> budget")
|
|
396
|
+
const apps: Record<string, Budget> = {}
|
|
397
|
+
for (const [app, value] of Object.entries(isRecord(raw.apps) ? raw.apps : {})) {
|
|
398
|
+
const at = `budgets.apps.${app}`
|
|
399
|
+
const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
|
|
400
|
+
if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
|
|
401
|
+
apps[app] = budget(value, at)
|
|
402
|
+
}
|
|
403
|
+
return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const AGENT_KNOWN = new Set(["name", "model"])
|
|
407
|
+
/** An OpenCode agent name: letters, digits, `_` and `-`, starting with a letter or digit. */
|
|
408
|
+
const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Option `agents`: `{ name, model }` per OpenCode agent to expose. `model` is required and must pass the
|
|
412
|
+
* allowlist, so an agent's own configured model never runs outside `models` (the agent's model setting is
|
|
413
|
+
* not read). Names must be unique.
|
|
414
|
+
*/
|
|
415
|
+
function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string, string> {
|
|
416
|
+
if (raw === undefined) return {}
|
|
417
|
+
if (!Array.isArray(raw)) throw new Error("plugboard option `agents` must be an array of { name, model } objects")
|
|
418
|
+
const entries: Array<[string, string]> = []
|
|
419
|
+
for (const item of raw) {
|
|
420
|
+
if (!isRecord(item)) throw new Error("plugboard option `agents` entries must be objects of the form { name, model }")
|
|
421
|
+
for (const key of Object.keys(item))
|
|
422
|
+
if (!AGENT_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`agents.${key}\` (known: ${[...AGENT_KNOWN].join(", ")})`)
|
|
423
|
+
const { name, model } = item
|
|
424
|
+
if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error(`plugboard agent name \`${String(name)}\` must match ${AGENT_NAME} (an OpenCode agent name)`)
|
|
425
|
+
if (entries.some(([known]) => known === name)) throw new Error(`plugboard agent \`${name}\` is listed more than once in \`agents\``)
|
|
426
|
+
if (typeof model !== "string" || !ALIAS_TARGET.test(model))
|
|
427
|
+
throw new Error(`plugboard agent \`${name}\` must set \`model\` to \`provider/model\` or \`provider/model#variant\``)
|
|
428
|
+
if (!allowedBy(models as string[], parseModelRef(model)!))
|
|
429
|
+
throw new Error(`plugboard agent \`${name}\` model \`${model}\` is not allowed by the \`models\` option`)
|
|
430
|
+
entries.push([name, model])
|
|
431
|
+
}
|
|
432
|
+
return Object.fromEntries(entries)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
|
|
437
|
+
* `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
|
|
438
|
+
*/
|
|
439
|
+
function parseHistoryUrl(value: unknown): string | undefined {
|
|
440
|
+
if (value === undefined) return undefined
|
|
441
|
+
const at = "plugboard option `historyUrl`"
|
|
442
|
+
if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
|
|
443
|
+
const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
|
|
444
|
+
if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
|
|
445
|
+
let url: URL
|
|
446
|
+
try {
|
|
447
|
+
url = new URL(value.replaceAll("{id}", "x").replaceAll("{directory}", "x"))
|
|
448
|
+
} catch {
|
|
449
|
+
throw new Error(`${at} must be an http(s) URL template`)
|
|
450
|
+
}
|
|
451
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${at} must be http or https`)
|
|
452
|
+
return value
|
|
453
|
+
}
|
|
454
|
+
|
|
307
455
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
308
456
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
309
457
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -360,6 +508,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
360
508
|
const chain = Array.isArray(target) ? target : [target]
|
|
361
509
|
if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
|
|
362
510
|
for (const entry of chain) {
|
|
511
|
+
// A skill target binds one model and takes no fallbacks; like a list entry it must pass the allowlist at load.
|
|
512
|
+
if (typeof entry === "string" && entry.startsWith(SKILL_PREFIX)) {
|
|
513
|
+
const parsed = parseAliasTarget(entry)
|
|
514
|
+
if (parsed?.skill === undefined) throw new Error(`plugboard alias \`${alias}\` skill target \`${entry}\` must be \`skill:<id>@provider/model\` or \`skill:<id>@provider/model#variant\``)
|
|
515
|
+
if (Array.isArray(target)) throw new Error(`plugboard alias \`${alias}\` binds a skill, so it takes one target and no list of fallbacks`)
|
|
516
|
+
if (!allowedBy(models as string[], parsed.ref)) throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
363
519
|
if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
|
|
364
520
|
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
|
|
365
521
|
// A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
|
|
@@ -373,6 +529,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
373
529
|
}
|
|
374
530
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
375
531
|
const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
|
|
532
|
+
const agents = parseAgents(input.agents, models as string[])
|
|
376
533
|
|
|
377
534
|
const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
|
|
378
535
|
const apps = parseApps(input.apps, { keys, aliases, maxTools })
|
|
@@ -397,6 +554,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
397
554
|
const limits = parseLimits(input.limits, keys)
|
|
398
555
|
const cache = parseCache(input.cache, keys)
|
|
399
556
|
const usage = parseUsage(input.usage)
|
|
557
|
+
const budgets = parseBudgets(input.budgets, keys)
|
|
558
|
+
if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
|
|
559
|
+
if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
|
|
560
|
+
throw new Error(`plugboard option \`usage.retentionDays\` must be at least ${widestWindowDays(budgets)} for the budget windows in use (a month is 31 days, a week 7): older days are deleted and would stop counting`)
|
|
561
|
+
const historyUrl = parseHistoryUrl(input.historyUrl)
|
|
400
562
|
|
|
401
563
|
return {
|
|
402
564
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
@@ -404,6 +566,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
404
566
|
apps,
|
|
405
567
|
models: models as string[],
|
|
406
568
|
aliases,
|
|
569
|
+
agents,
|
|
407
570
|
fallbacks,
|
|
408
571
|
maxTools,
|
|
409
572
|
maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
|
|
@@ -417,7 +580,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
417
580
|
...(limits === undefined ? {} : { limits }),
|
|
418
581
|
...(cache ? { cache } : {}),
|
|
419
582
|
usage,
|
|
583
|
+
...(budgets === undefined ? {} : { budgets }),
|
|
420
584
|
...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
|
|
585
|
+
...(historyUrl === undefined ? {} : { historyUrl }),
|
|
421
586
|
}
|
|
422
587
|
}
|
|
423
588
|
|
|
@@ -446,24 +611,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
|
|
|
446
611
|
/** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
|
|
447
612
|
export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
|
|
448
613
|
|
|
614
|
+
/** The digest of a runtime key (src/keys.ts), with its app: checked like a config key (#55). */
|
|
615
|
+
export type RuntimeDigest = readonly [app: string, digest: Buffer]
|
|
616
|
+
|
|
449
617
|
/** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
|
|
450
|
-
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
|
|
618
|
+
export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
|
|
451
619
|
const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
452
620
|
if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
|
|
453
|
-
const auth = authenticate(options, authorization)
|
|
621
|
+
const auth = authenticate(options, authorization, runtime)
|
|
454
622
|
if (isPlugError(auth)) return auth
|
|
455
623
|
return { admin: false, app: auth.app }
|
|
456
624
|
}
|
|
457
625
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
626
|
+
/**
|
|
627
|
+
* No config keys -> app `default` (keyless), as before. With config keys every request needs
|
|
628
|
+
* `Authorization: Bearer <key>`. A runtime key (#55) matches like a config key, so it names its app
|
|
629
|
+
* whenever it is presented. It never makes a keyless server require a key, so deleting the last
|
|
630
|
+
* runtime key cannot open or close access by itself.
|
|
631
|
+
*/
|
|
632
|
+
export function authenticate(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): { app: string } | PlugError {
|
|
460
633
|
const entries = digestsOf(options.keys)
|
|
461
|
-
if (entries.length === 0) return { app: DEFAULT_APP }
|
|
462
634
|
const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
463
|
-
if (
|
|
635
|
+
if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
464
636
|
const digest = sha256(token)
|
|
465
637
|
let app: string | undefined
|
|
466
638
|
// Every key is compared, no early exit.
|
|
467
|
-
for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
|
|
468
|
-
|
|
639
|
+
for (const [name, expected] of [...entries, ...runtime]) if (timingSafeEqual(digest, expected)) app = name
|
|
640
|
+
if (app !== undefined) return { app }
|
|
641
|
+
return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
|
|
469
642
|
}
|
package/src/contract.ts
CHANGED
|
@@ -114,6 +114,8 @@ export interface PlugRequest {
|
|
|
114
114
|
readonly tools: ReadonlyArray<PlugTool>
|
|
115
115
|
readonly toolChoice?: PlugToolChoice
|
|
116
116
|
readonly generation: PlugGeneration
|
|
117
|
+
/** The end user the caller named, when kept (src/user.ts, #57). Labels the session title and the usage counts. */
|
|
118
|
+
readonly user?: string
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
// ---------------------------------------------------------------- events
|
|
@@ -172,7 +174,7 @@ export interface CallContext {
|
|
|
172
174
|
/** Aborted on client disconnect or timeout. */
|
|
173
175
|
readonly signal: AbortSignal
|
|
174
176
|
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
175
|
-
readonly onRecord?: (recordID: string) => void
|
|
177
|
+
readonly onRecord?: (recordID: string, directory?: string) => void
|
|
176
178
|
/**
|
|
177
179
|
* Called once, before the first event, only for a request whose model is an alias with fallbacks
|
|
178
180
|
* (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
|
|
@@ -186,6 +188,20 @@ export interface CallContext {
|
|
|
186
188
|
readonly onCache?: (result: "hit" | "miss") => void
|
|
187
189
|
/** Bypass the response cache for this call. Fan-out sets it: each choice of `n` above 1 repeats one request and must be a fresh sample. */
|
|
188
190
|
readonly noCache?: boolean
|
|
191
|
+
/**
|
|
192
|
+
* Called once per answered call, before its finish event, when usage recording is on (`withUsage`,
|
|
193
|
+
* src/usage.ts): the call's tokens and USD cost. Front-ends set `x-plugboard-usage` from it while the
|
|
194
|
+
* headers are open, so a stream (headers sent first) never gets it.
|
|
195
|
+
*/
|
|
196
|
+
readonly onUsage?: (totals: CallTotals) => void
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
|
|
200
|
+
export interface CallTotals {
|
|
201
|
+
readonly inputTokens: number
|
|
202
|
+
readonly outputTokens: number
|
|
203
|
+
readonly cacheReadTokens: number
|
|
204
|
+
readonly costUsd?: number
|
|
189
205
|
}
|
|
190
206
|
|
|
191
207
|
/** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
|
package/src/discovery.ts
CHANGED
|
@@ -92,6 +92,14 @@ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
|
92
92
|
]
|
|
93
93
|
: []),
|
|
94
94
|
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
95
|
+
// The admin key only (option `adminKey`); without it these paths are a 404 (src/admin/route.ts).
|
|
96
|
+
...(options.adminKey === undefined
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
{ method: "GET", path: "/v1/admin/keys", auth: true, description: "Admin: the config and runtime app keys, without key values." },
|
|
100
|
+
{ method: "POST", path: "/v1/admin/keys", auth: true, description: "Admin: a new app key for `{ app }`, returned once." },
|
|
101
|
+
{ method: "DELETE", path: "/v1/admin/keys/{keyId}", auth: true, description: "Admin: revoke a runtime app key." },
|
|
102
|
+
]),
|
|
95
103
|
]
|
|
96
104
|
}
|
|
97
105
|
|
package/src/fallback.ts
CHANGED
|
@@ -51,6 +51,8 @@ export const FALLBACKS_HEADER = "x-plugboard-fallbacks"
|
|
|
51
51
|
export interface ServedInfo {
|
|
52
52
|
served?: string
|
|
53
53
|
fallbacks?: number
|
|
54
|
+
/** The session page link of the request (option `historyUrl`, src/session/link.ts), for the log line. */
|
|
55
|
+
sessionUrl?: string
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -90,9 +92,9 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
90
92
|
let attempt = 0
|
|
91
93
|
for (;;) {
|
|
92
94
|
// The history id of this attempt: handed on only if the attempt is the one that answers (or the last).
|
|
93
|
-
let
|
|
95
|
+
let recorded: { id: string; directory?: string } | undefined
|
|
94
96
|
const next = attempt === 0 ? request : retarget(deps.options, request, ref)
|
|
95
|
-
const iterator = inner.stream(next, { ...context, onRecord: (id) => void (
|
|
97
|
+
const iterator = inner.stream(next, { ...context, onRecord: (id, directory) => void (recorded = { id, directory }) })[Symbol.asyncIterator]()
|
|
96
98
|
|
|
97
99
|
let first: IteratorResult<PlugEvent> | undefined
|
|
98
100
|
let thrown: { readonly error: unknown } | undefined
|
|
@@ -124,7 +126,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
// Answered, ran out of targets, or the request ended: this attempt's outcome is the response.
|
|
127
|
-
if (
|
|
129
|
+
if (recorded !== undefined) report(context, recorded)
|
|
128
130
|
if (thrown) throw thrown.error
|
|
129
131
|
if (first!.done) return
|
|
130
132
|
// An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
|
|
@@ -153,9 +155,9 @@ async function resolveRest(deps: FallbackDeps, request: PlugRequest, app: string
|
|
|
153
155
|
}
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
const report = (context: CallContext,
|
|
158
|
+
const report = (context: CallContext, recorded: { readonly id: string; readonly directory?: string }): void => {
|
|
157
159
|
try {
|
|
158
|
-
context.onRecord?.(
|
|
160
|
+
context.onRecord?.(recorded.id, recorded.directory)
|
|
159
161
|
} catch {}
|
|
160
162
|
}
|
|
161
163
|
|
package/src/gemini/request.ts
CHANGED
|
@@ -439,6 +439,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
|
|
|
439
439
|
stream,
|
|
440
440
|
...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
441
441
|
...(format.instruction ? { jsonObject: true } : {}),
|
|
442
|
+
...(generation.stop ? { stop: generation.stop } : {}),
|
|
442
443
|
},
|
|
443
444
|
...(reasoningEffort ? { reasoningEffort } : {}),
|
|
444
445
|
}
|
package/src/gemini/response.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
10
|
import type { ListedModel } from "../models.js"
|
|
11
11
|
import { unfence } from "../openai/response.js"
|
|
12
|
+
import { StopHoldback } from "../stop.js"
|
|
12
13
|
import { WRAP_KEY } from "./request.js"
|
|
13
14
|
|
|
14
15
|
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
@@ -30,6 +31,8 @@ export interface GeminiExchange {
|
|
|
30
31
|
readonly callerTools?: boolean
|
|
31
32
|
/** `responseMimeType: application/json` without a schema: a single code fence around JSON text is stripped. */
|
|
32
33
|
readonly jsonObject?: boolean
|
|
34
|
+
/** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
|
|
35
|
+
readonly stop?: ReadonlyArray<string>
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
@@ -160,10 +163,18 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
160
163
|
else parts.push(part)
|
|
161
164
|
emit(envelope([part]))
|
|
162
165
|
}
|
|
166
|
+
// A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
|
|
167
|
+
const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
|
|
163
168
|
const text = (delta: string) => {
|
|
164
|
-
|
|
169
|
+
const out = holdback ? holdback.push(delta) : delta
|
|
170
|
+
if (out.length > 0) push({ text: out })
|
|
171
|
+
}
|
|
172
|
+
const releaseHeld = () => {
|
|
173
|
+
const rest = holdback?.release() ?? ""
|
|
174
|
+
if (rest.length > 0) push({ text: rest })
|
|
165
175
|
}
|
|
166
176
|
const toolCall = (call: ToolCall) => {
|
|
177
|
+
releaseHeld()
|
|
167
178
|
push({ functionCall: { name: call.name, args: argsOf(call.input) } })
|
|
168
179
|
calls++
|
|
169
180
|
}
|
|
@@ -221,6 +232,10 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
|
|
|
221
232
|
failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
|
|
222
233
|
if (failure) return { failure, started }
|
|
223
234
|
flush()
|
|
235
|
+
if (holdback) {
|
|
236
|
+
const rest = holdback.finish(finish === "stop" && calls === 0).text
|
|
237
|
+
if (rest.length > 0) push({ text: rest })
|
|
238
|
+
}
|
|
224
239
|
if (held && !passthrough) text(structuredText(exchange, held))
|
|
225
240
|
const candidate = { finishReason: FINISH_REASONS[finish!] }
|
|
226
241
|
const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
|
package/src/gemini/route.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, s
|
|
|
21
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
22
|
import { reportCache } from "../cache.js"
|
|
23
23
|
import { reportPolicy } from "../policy.js"
|
|
24
|
+
import { reportUsage } from "../usage-header.js"
|
|
25
|
+
import { setSessionLink } from "../session/link.js"
|
|
24
26
|
import type { RouteHost } from "../openai/route.js"
|
|
25
27
|
import { dropRejected } from "../openai/sampling.js"
|
|
26
28
|
import { estimateTokens } from "../anthropic/tokens.js"
|
|
@@ -156,12 +158,14 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
|
|
|
156
158
|
host.caller.stream(call, {
|
|
157
159
|
app,
|
|
158
160
|
signal: scope.signal,
|
|
159
|
-
onRecord: (recordID) => {
|
|
161
|
+
onRecord: (recordID, directory) => {
|
|
160
162
|
if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
|
|
161
163
|
response.setHeader(SESSION_HEADER, recordID)
|
|
164
|
+
setSessionLink(response, info, host.options.historyUrl, recordID, directory)
|
|
162
165
|
},
|
|
163
166
|
onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
|
|
164
167
|
onPolicy: reportPolicy(response),
|
|
168
|
+
onUsage: reportUsage(response, match.stream),
|
|
165
169
|
onCache: reportCache(response),
|
|
166
170
|
}),
|
|
167
171
|
scope,
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime app keys (#55): keys created through `/v1/admin/keys` (src/admin/route.ts), kept beside
|
|
3
|
+
* the keys of the config.
|
|
4
|
+
*
|
|
5
|
+
* Each runtime key is one record in the plugin's host storage (`ctx.storage`, the table the history
|
|
6
|
+
* index and usage use) under `keys/<id>`, as JSON `{ app, digest, createdAt }`. The digest is the
|
|
7
|
+
* SHA-256 of the key, so the key value is never stored: it is returned once, by the create call.
|
|
8
|
+
* The id is the first 8 hex chars of that digest, the name `DELETE` takes; it is safe to show.
|
|
9
|
+
*
|
|
10
|
+
* The records are read lazily, from request handling, by the first request that authenticates (never
|
|
11
|
+
* from plugin setup, I3), and cached on the process runtime (`KeyState`), so the cache outlives a
|
|
12
|
+
* plugin generation. A failed read leaves the cache unloaded: runtime keys then fail closed (401) and
|
|
13
|
+
* the next request reads again. Create and revoke write storage first and the cache second, so a
|
|
14
|
+
* revoked key stops authenticating on the next request.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash, randomBytes } from "node:crypto"
|
|
18
|
+
import { APP_NAME_PATTERN } from "./config.js"
|
|
19
|
+
import { errorMessage, logError } from "./log.js"
|
|
20
|
+
import type { StoragePort } from "./session/host.js"
|
|
21
|
+
|
|
22
|
+
const PREFIX = "keys/"
|
|
23
|
+
const SCAN_PAGE = 200
|
|
24
|
+
/** A collision of two 32-bit ids is rare; a fresh key is drawn instead of sharing an id. */
|
|
25
|
+
const ID_ATTEMPTS = 5
|
|
26
|
+
|
|
27
|
+
export interface RuntimeKey {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly app: string
|
|
30
|
+
readonly digest: Buffer
|
|
31
|
+
readonly createdAt: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The live cache of runtime keys on the process runtime (src/runtime.ts). */
|
|
35
|
+
export interface KeyState {
|
|
36
|
+
loaded: boolean
|
|
37
|
+
readonly keys: Map<string, RuntimeKey>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const createKeyState = (): KeyState => ({ loaded: false, keys: new Map() })
|
|
41
|
+
|
|
42
|
+
/** SHA-256 of a key: what is stored and compared. */
|
|
43
|
+
export const keyDigest = (key: string): Buffer => createHash("sha256").update(key).digest()
|
|
44
|
+
|
|
45
|
+
/** The id of a key: the first 8 hex chars of its digest. */
|
|
46
|
+
export const keyIdOf = (digest: Buffer): string => digest.subarray(0, 4).toString("hex")
|
|
47
|
+
|
|
48
|
+
export interface KeyListing {
|
|
49
|
+
readonly id: string
|
|
50
|
+
readonly app: string
|
|
51
|
+
readonly source: "config" | "runtime"
|
|
52
|
+
readonly createdAt?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface CreatedKey {
|
|
56
|
+
readonly id: string
|
|
57
|
+
readonly app: string
|
|
58
|
+
/** The key value. Returned by `create` only; it is never stored or listed. */
|
|
59
|
+
readonly key: string
|
|
60
|
+
readonly createdAt: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface KeyStore {
|
|
64
|
+
/** The digests of the loaded runtime keys, as `[app, digest]`. Reads storage once; a failed read answers with what is loaded. */
|
|
65
|
+
digests(): Promise<ReadonlyArray<readonly [string, Buffer]>>
|
|
66
|
+
/** Config keys and runtime keys, without key values. */
|
|
67
|
+
list(config: Readonly<Record<string, string>>): Promise<ReadonlyArray<KeyListing>>
|
|
68
|
+
/** A new random key for `app`. The caller checks the app name and that the config has no key for it. */
|
|
69
|
+
create(app: string, config: Readonly<Record<string, string>>): Promise<CreatedKey>
|
|
70
|
+
/** `config` when the id names a config key (not revocable here), `not_found` when no runtime key has it. */
|
|
71
|
+
revoke(id: string, config: Readonly<Record<string, string>>): Promise<"revoked" | "config" | "not_found">
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface KeyStoreDeps {
|
|
75
|
+
readonly state: KeyState
|
|
76
|
+
readonly storage: StoragePort
|
|
77
|
+
/** Epoch ms; tests inject a clock. */
|
|
78
|
+
readonly now?: () => number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const configIds = (config: Readonly<Record<string, string>>): Set<string> => new Set(Object.values(config).map((key) => keyIdOf(keyDigest(key))))
|
|
82
|
+
|
|
83
|
+
/** A stored record, or undefined when the value is not one (logged; the key then never authenticates). */
|
|
84
|
+
function parseRecord(id: string, raw: string | undefined): RuntimeKey | undefined {
|
|
85
|
+
try {
|
|
86
|
+
const value = JSON.parse(raw ?? "") as { app?: unknown; digest?: unknown; createdAt?: unknown }
|
|
87
|
+
const digest = typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest) ? Buffer.from(value.digest, "hex") : undefined
|
|
88
|
+
if (typeof value.app !== "string" || !APP_NAME_PATTERN.test(value.app) || digest === undefined || typeof value.createdAt !== "string") throw new Error("shape")
|
|
89
|
+
if (keyIdOf(digest) !== id) throw new Error("id")
|
|
90
|
+
return { id, app: value.app, digest, createdAt: value.createdAt }
|
|
91
|
+
} catch {
|
|
92
|
+
logError("keys.unreadable", { id })
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createKeyStore(deps: KeyStoreDeps): KeyStore {
|
|
98
|
+
const { state, storage } = deps
|
|
99
|
+
const now = deps.now ?? Date.now
|
|
100
|
+
let loading: Promise<void> | undefined
|
|
101
|
+
|
|
102
|
+
/** Reads every stored key once. Concurrent callers share one read. */
|
|
103
|
+
const ensure = (): Promise<void> => {
|
|
104
|
+
if (state.loaded) return Promise.resolve()
|
|
105
|
+
loading ??= (async () => {
|
|
106
|
+
const found = new Map<string, RuntimeKey>()
|
|
107
|
+
let after: string | undefined
|
|
108
|
+
for (;;) {
|
|
109
|
+
const page = await storage.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: SCAN_PAGE })
|
|
110
|
+
for (const key of page.keys) {
|
|
111
|
+
const record = parseRecord(key.slice(PREFIX.length), await storage.get(key))
|
|
112
|
+
if (record) found.set(record.id, record)
|
|
113
|
+
}
|
|
114
|
+
if (page.next === undefined) break
|
|
115
|
+
after = page.next
|
|
116
|
+
}
|
|
117
|
+
state.keys.clear()
|
|
118
|
+
for (const [id, record] of found) state.keys.set(id, record)
|
|
119
|
+
state.loaded = true
|
|
120
|
+
})().finally(() => {
|
|
121
|
+
loading = undefined
|
|
122
|
+
})
|
|
123
|
+
return loading
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
async digests() {
|
|
128
|
+
try {
|
|
129
|
+
await ensure()
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logError("keys.load_failed", { error: errorMessage(error) })
|
|
132
|
+
}
|
|
133
|
+
return [...state.keys.values()].map((record) => [record.app, record.digest] as const)
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async list(config) {
|
|
137
|
+
await ensure()
|
|
138
|
+
const runtime = [...state.keys.values()].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
139
|
+
return [
|
|
140
|
+
...Object.entries(config).map(([app, key]) => ({ id: keyIdOf(keyDigest(key)), app, source: "config" as const })),
|
|
141
|
+
...runtime.map((record) => ({ id: record.id, app: record.app, source: "runtime" as const, createdAt: record.createdAt })),
|
|
142
|
+
]
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
async create(app, config) {
|
|
146
|
+
await ensure()
|
|
147
|
+
const taken = configIds(config)
|
|
148
|
+
for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
|
|
149
|
+
const key = randomBytes(32).toString("base64url")
|
|
150
|
+
const digest = keyDigest(key)
|
|
151
|
+
const id = keyIdOf(digest)
|
|
152
|
+
if (taken.has(id) || state.keys.has(id)) continue
|
|
153
|
+
const createdAt = new Date(now()).toISOString()
|
|
154
|
+
// Storage first: a failed write creates no key, and the value is never returned for a key that does not exist.
|
|
155
|
+
await storage.put(PREFIX + id, JSON.stringify({ app, digest: digest.toString("hex"), createdAt }))
|
|
156
|
+
state.keys.set(id, { id, app, digest, createdAt })
|
|
157
|
+
return { id, app, key, createdAt }
|
|
158
|
+
}
|
|
159
|
+
throw new Error("could not draw an unused key id")
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async revoke(id, config) {
|
|
163
|
+
await ensure()
|
|
164
|
+
if (configIds(config).has(id)) return "config"
|
|
165
|
+
if (!state.keys.has(id)) return "not_found"
|
|
166
|
+
// Storage first: if the delete fails the key keeps working and the caller can retry.
|
|
167
|
+
await storage.drop(PREFIX + id)
|
|
168
|
+
state.keys.delete(id)
|
|
169
|
+
return "revoked"
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -142,5 +142,8 @@ export const log = (event: string, fields?: LogFields): void => state().logger.i
|
|
|
142
142
|
|
|
143
143
|
export const logError = (event: string, fields?: LogFields): void => state().logger.error(record(event, fields))
|
|
144
144
|
|
|
145
|
+
/** A line for something that needs attention but did not fail a request (a budget at 80%). Info-level destination, `warn` in the level field. */
|
|
146
|
+
export const logWarn = (event: string, fields?: LogFields): void => state().logger.warn(record(event, fields))
|
|
147
|
+
|
|
145
148
|
/** The message of a thrown value, without its stack or attached properties. */
|
|
146
149
|
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|