opencode-plugboard 0.4.1 → 0.6.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 +3 -4
- package/package.json +3 -3
- package/src/config.ts +38 -6
- package/src/contract.ts +21 -0
- package/src/discovery.ts +280 -0
- package/src/log.ts +56 -5
- package/src/metrics.ts +176 -0
- package/src/openai/response.ts +29 -1
- package/src/openai/route.ts +100 -12
- package/src/runtime.ts +103 -18
- package/src/session/caller.ts +5 -5
- package/src/session/events.ts +60 -12
- package/src/session/history.ts +18 -6
- package/src/session/host.ts +46 -2
- package/src/session/media.ts +256 -23
- package/src/session/state.ts +3 -0
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
30
30
|
{
|
|
31
31
|
"plugins": [
|
|
32
32
|
{
|
|
33
|
-
"package": "opencode-plugboard@0.
|
|
33
|
+
"package": "opencode-plugboard@0.6.0",
|
|
34
34
|
"options": {
|
|
35
35
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
36
36
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -81,10 +81,9 @@ Plugboard is early (0.x) and narrow on purpose.
|
|
|
81
81
|
- **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
|
|
82
82
|
- **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
|
|
83
83
|
|
|
84
|
-
How it differs from
|
|
84
|
+
How it differs from a standalone gateway:
|
|
85
85
|
|
|
86
|
-
- [
|
|
87
|
-
- [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription.
|
|
86
|
+
- [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription. Plugboard also gives each app its own key and history, an explicit model allowlist, and rules that keep it from ever freezing the host.
|
|
88
87
|
|
|
89
88
|
The most common first failure is an SDK defaulting to `/v1/responses` for newer models. Plugboard serves it, but some clients need `store: false` or a "use chat completions" switch. Check the client's page below.
|
|
90
89
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"description": "
|
|
7
|
-
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions"],
|
|
6
|
+
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
|
|
7
|
+
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions", "responses-api", "llm-gateway", "llm-proxy"],
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/jjjermiah/opencode-plugboard.git"
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
2
|
|
|
3
3
|
import type { PlugError } from "./contract.js"
|
|
4
|
+
import { parseAllowEntry, type RemoteImages } from "./session/media.js"
|
|
4
5
|
import type { HistoryLimits } from "./session/prune.js"
|
|
5
6
|
|
|
6
7
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
@@ -14,12 +15,21 @@ export interface PlugboardOptions {
|
|
|
14
15
|
readonly aliases: Readonly<Record<string, string>>
|
|
15
16
|
readonly maxTools: number
|
|
16
17
|
readonly requestTimeoutMs: number
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network.
|
|
20
|
+
* `true` fetches public addresses only (loopback, private, link-local, CGNAT and ULA ranges are refused);
|
|
21
|
+
* `{ allow }` is on too, and lists the hosts (`name`, `*.suffix`), addresses or CIDR ranges that may be
|
|
22
|
+
* fetched even when they are not public.
|
|
23
|
+
*/
|
|
24
|
+
readonly remoteImages: RemoteImages
|
|
19
25
|
/** Prune old request sessions (see `src/session/prune.ts`). A limit that is unset is not applied; with neither, history is kept forever. Applies per app. */
|
|
20
26
|
readonly history: HistoryLimits
|
|
21
27
|
/** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
|
|
22
28
|
readonly dropUnsupportedParams: boolean
|
|
29
|
+
/** Log line format: `text` (`key=value`, the default) or `json` (one object per line). Process-wide: the live generation's value applies. */
|
|
30
|
+
readonly logFormat: "text" | "json"
|
|
31
|
+
/** Serve `GET /metrics` (Prometheus text). Off by default; same authentication as `/v1`. */
|
|
32
|
+
readonly metrics: boolean
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
@@ -31,7 +41,7 @@ const DEFAULTS = {
|
|
|
31
41
|
requestTimeoutMs: 600_000,
|
|
32
42
|
} as const
|
|
33
43
|
|
|
34
|
-
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams"])
|
|
44
|
+
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
|
|
35
45
|
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
36
46
|
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
37
47
|
const MAX_AGE_DAYS = 36_500
|
|
@@ -64,6 +74,23 @@ function positiveInt(value: unknown, name: string, fallback: number, max = Numbe
|
|
|
64
74
|
return value
|
|
65
75
|
}
|
|
66
76
|
|
|
77
|
+
const REMOTE_IMAGES_ERROR =
|
|
78
|
+
"plugboard option `remoteImages` must be a boolean or `{ allow: [...] }` with host names, IP addresses or CIDR ranges that may be fetched even when they are not public"
|
|
79
|
+
|
|
80
|
+
function parseRemoteImages(value: unknown): RemoteImages {
|
|
81
|
+
if (value === undefined) return false
|
|
82
|
+
if (typeof value === "boolean") return value
|
|
83
|
+
if (!isRecord(value)) throw new Error(REMOTE_IMAGES_ERROR)
|
|
84
|
+
for (const key of Object.keys(value))
|
|
85
|
+
if (key !== "allow") throw new Error(`Unknown plugboard option \`remoteImages.${key}\` (known: allow)`)
|
|
86
|
+
const allow = value.allow ?? []
|
|
87
|
+
if (!Array.isArray(allow)) throw new Error(REMOTE_IMAGES_ERROR)
|
|
88
|
+
for (const entry of allow)
|
|
89
|
+
if (typeof entry !== "string" || parseAllowEntry(entry) === undefined)
|
|
90
|
+
throw new Error(`plugboard \`remoteImages.allow\` entry \`${String(entry)}\` must be a host name (\`images.internal\`, \`*.internal\`), an IP address or a CIDR range`)
|
|
91
|
+
return { allow: allow as string[] }
|
|
92
|
+
}
|
|
93
|
+
|
|
67
94
|
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
68
95
|
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
69
96
|
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
@@ -118,11 +145,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
118
145
|
}
|
|
119
146
|
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
120
147
|
|
|
121
|
-
|
|
122
|
-
throw new Error("plugboard option `remoteImages` must be a boolean")
|
|
148
|
+
const remoteImages = parseRemoteImages(input.remoteImages)
|
|
123
149
|
if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
|
|
124
150
|
throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
|
|
125
151
|
|
|
152
|
+
if (input.logFormat !== undefined && input.logFormat !== "text" && input.logFormat !== "json")
|
|
153
|
+
throw new Error("plugboard option `logFormat` must be `text` or `json`")
|
|
154
|
+
if (input.metrics !== undefined && typeof input.metrics !== "boolean") throw new Error("plugboard option `metrics` must be a boolean")
|
|
155
|
+
|
|
126
156
|
const historyInput = input.history
|
|
127
157
|
if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
|
|
128
158
|
for (const key of Object.keys(historyInput ?? {}))
|
|
@@ -137,9 +167,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
137
167
|
aliases,
|
|
138
168
|
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
139
169
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
140
|
-
remoteImages
|
|
170
|
+
remoteImages,
|
|
141
171
|
history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
|
|
142
172
|
dropUnsupportedParams: input.dropUnsupportedParams !== false,
|
|
173
|
+
logFormat: input.logFormat === "json" ? "json" : "text",
|
|
174
|
+
metrics: input.metrics === true,
|
|
143
175
|
}
|
|
144
176
|
}
|
|
145
177
|
|
package/src/contract.ts
CHANGED
|
@@ -20,6 +20,18 @@ export interface ModelRef {
|
|
|
20
20
|
readonly variant?: string
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* One price entry, USD per million tokens (the host's `Model.Cost`). An entry with `aboveContext`
|
|
25
|
+
* applies once the prompt context exceeds that many tokens; the entry without it is the base price.
|
|
26
|
+
*/
|
|
27
|
+
export interface CatalogCost {
|
|
28
|
+
readonly input: number
|
|
29
|
+
readonly output: number
|
|
30
|
+
readonly cacheRead: number
|
|
31
|
+
readonly cacheWrite: number
|
|
32
|
+
readonly aboveContext?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/** One enabled model as the server sees it: what model listing and the request resolver work from. */
|
|
24
36
|
export interface CatalogModel {
|
|
25
37
|
readonly providerID: string
|
|
@@ -28,6 +40,15 @@ export interface CatalogModel {
|
|
|
28
40
|
readonly variants: ReadonlyArray<string>
|
|
29
41
|
readonly capabilities?: { readonly tools?: boolean; readonly image?: boolean }
|
|
30
42
|
readonly contextLimit?: number
|
|
43
|
+
/** Token limits as the host reports them (`limit.{context,input,output}`). */
|
|
44
|
+
readonly limits?: { readonly context?: number; readonly input?: number; readonly output?: number }
|
|
45
|
+
/** Modalities the model accepts and produces (`capabilities.input` / `.output`, for example `text`, `image`). */
|
|
46
|
+
readonly modalities?: { readonly input: ReadonlyArray<string>; readonly output: ReadonlyArray<string> }
|
|
47
|
+
readonly cost?: ReadonlyArray<CatalogCost>
|
|
48
|
+
readonly status?: "alpha" | "beta" | "deprecated" | "active"
|
|
49
|
+
readonly family?: string
|
|
50
|
+
/** Release date, epoch milliseconds. */
|
|
51
|
+
readonly released?: number
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// ---------------------------------------------------------------- request
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run discovery and status documents: `GET /v1` (JSON), `GET /` (small HTML page) and the
|
|
3
|
+
* authenticated `GET /v1/status`. Pure builders over the options, the catalog and a runtime
|
|
4
|
+
* snapshot; no I/O, no host calls. `src/openai/route.ts` loads the catalog and sends the result.
|
|
5
|
+
*
|
|
6
|
+
* Disclosure rule: the discovery pages are reachable without a key, so they only describe the
|
|
7
|
+
* configuration when that is safe. On a keyless endpoint (loopback only) everything is shown.
|
|
8
|
+
* When `keys` are configured, a caller without an `Authorization` header sees the address, the
|
|
9
|
+
* authentication requirement, the endpoint list and a curl example, and nothing about apps,
|
|
10
|
+
* allowlist, aliases or the catalog. A caller with a valid key additionally sees its own app name
|
|
11
|
+
* (never the other apps), the allowlist, the aliases and the catalog suggestion. Keys themselves
|
|
12
|
+
* never appear anywhere, and nothing here echoes a request header or path.
|
|
13
|
+
*
|
|
14
|
+
* The HTML page is built from the same document as the JSON, has no scripts or external assets,
|
|
15
|
+
* and every dynamic value goes through `escapeHtml`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ServerResponse } from "node:http"
|
|
19
|
+
import type { PlugboardOptions } from "./config.js"
|
|
20
|
+
import type { CatalogModel } from "./contract.js"
|
|
21
|
+
import { listAvailableModels } from "./models.js"
|
|
22
|
+
import type { RuntimeSnapshot } from "./metrics.js"
|
|
23
|
+
|
|
24
|
+
const NAME = "opencode-plugboard"
|
|
25
|
+
/** Providers listed in the catalog summary; the rest are counted, not listed. */
|
|
26
|
+
const MAX_PROVIDERS = 50
|
|
27
|
+
const EXAMPLES_PER_PROVIDER = 3
|
|
28
|
+
const PLACEHOLDER_MODEL = "provider/model"
|
|
29
|
+
|
|
30
|
+
/** A text response with the headers every page of this module shares. */
|
|
31
|
+
export function sendText(response: ServerResponse, status: number, contentType: string, body: string, headers: Record<string, string> = {}): void {
|
|
32
|
+
if (response.headersSent || response.destroyed) return
|
|
33
|
+
response.writeHead(status, {
|
|
34
|
+
"content-type": contentType,
|
|
35
|
+
"content-length": Buffer.byteLength(body),
|
|
36
|
+
"x-content-type-options": "nosniff",
|
|
37
|
+
"cache-control": "no-store",
|
|
38
|
+
...headers,
|
|
39
|
+
})
|
|
40
|
+
response.end(body)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const escapeHtml = (value: unknown): string =>
|
|
44
|
+
String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char] ?? char)
|
|
45
|
+
|
|
46
|
+
/** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
|
|
47
|
+
function origin(listen: PlugboardOptions["listen"]): string {
|
|
48
|
+
const host = listen.host.includes(":") && !listen.host.startsWith("[") ? `[${listen.host}]` : listen.host
|
|
49
|
+
return `http://${host}:${listen.port}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
|
|
53
|
+
|
|
54
|
+
export interface Endpoint {
|
|
55
|
+
readonly method: string
|
|
56
|
+
readonly path: string
|
|
57
|
+
readonly auth: boolean
|
|
58
|
+
readonly description: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function endpoints(options: PlugboardOptions): Endpoint[] {
|
|
62
|
+
return [
|
|
63
|
+
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
64
|
+
{ method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
|
|
65
|
+
{ method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
|
|
66
|
+
{ method: "GET", path: "/v1/models", auth: true, description: "Models this app may call." },
|
|
67
|
+
{ method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
|
|
68
|
+
{ method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
|
|
69
|
+
{ method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
|
|
70
|
+
{ method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
|
|
71
|
+
...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
|
|
76
|
+
|
|
77
|
+
interface ProviderSummary {
|
|
78
|
+
readonly id: string
|
|
79
|
+
readonly models: number
|
|
80
|
+
readonly examples: ReadonlyArray<string>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The `GET /v1` document. Fields marked "detail" are present only when the caller may see the configuration. */
|
|
84
|
+
export interface Discovery {
|
|
85
|
+
readonly name: string
|
|
86
|
+
readonly baseUrl: string
|
|
87
|
+
readonly listen: { readonly host: string; readonly port: number }
|
|
88
|
+
readonly authentication: { readonly required: boolean; readonly scheme?: "Bearer" }
|
|
89
|
+
readonly hint?: string
|
|
90
|
+
/** detail: the caller's own app name, or `default` on a keyless endpoint. */
|
|
91
|
+
readonly apps?: ReadonlyArray<string>
|
|
92
|
+
/** detail */
|
|
93
|
+
readonly models?: {
|
|
94
|
+
readonly state: ModelsState
|
|
95
|
+
readonly allowlist: ReadonlyArray<string>
|
|
96
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
97
|
+
/** Ids `GET /v1/models` lists (allowlist intersected with the catalog, plus aliases). */
|
|
98
|
+
readonly available?: number
|
|
99
|
+
readonly hint?: string
|
|
100
|
+
}
|
|
101
|
+
/** detail: a paste-ready `models` option built from the catalog (one wildcard per provider). */
|
|
102
|
+
readonly suggestedModels?: ReadonlyArray<string>
|
|
103
|
+
/** detail: what the catalog offers, by provider. */
|
|
104
|
+
readonly catalog?: { readonly providers: ReadonlyArray<ProviderSummary>; readonly moreProviders?: number }
|
|
105
|
+
readonly endpoints: ReadonlyArray<Endpoint>
|
|
106
|
+
readonly example: { readonly curl: string }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface DiscoveryInput {
|
|
110
|
+
readonly options: PlugboardOptions
|
|
111
|
+
/** The authenticated app; undefined when the caller sent no credentials (only possible when `keys` are set). */
|
|
112
|
+
readonly app?: string
|
|
113
|
+
/** The serving Location's catalog; undefined when it could not be loaded (or was not asked for). */
|
|
114
|
+
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function summarize(catalog: ReadonlyArray<CatalogModel>): { providers: ProviderSummary[]; more: number } {
|
|
118
|
+
const byProvider = new Map<string, string[]>()
|
|
119
|
+
for (const model of catalog) {
|
|
120
|
+
const ids = byProvider.get(model.providerID) ?? []
|
|
121
|
+
if (!ids.includes(model.id)) ids.push(model.id)
|
|
122
|
+
byProvider.set(model.providerID, ids)
|
|
123
|
+
}
|
|
124
|
+
const all = [...byProvider.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
125
|
+
return {
|
|
126
|
+
providers: all.slice(0, MAX_PROVIDERS).map(([id, ids]) => ({ id, models: ids.length, examples: ids.slice(0, EXAMPLES_PER_PROVIDER).map((model) => `${id}/${model}`) })),
|
|
127
|
+
more: Math.max(0, all.length - MAX_PROVIDERS),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Discovery {
|
|
132
|
+
const keyed = Object.keys(options.keys).length > 0
|
|
133
|
+
const base = `${origin(options.listen)}/v1`
|
|
134
|
+
const detail = !keyed || app !== undefined
|
|
135
|
+
const auth = keyed ? ({ required: true, scheme: "Bearer" } as const) : { required: false }
|
|
136
|
+
|
|
137
|
+
const listed = detail && catalog ? listAvailableModels(options, catalog) : []
|
|
138
|
+
const summary = detail && catalog ? summarize(catalog) : undefined
|
|
139
|
+
const suggested = summary?.providers.map((provider) => `${provider.id}/*`)
|
|
140
|
+
|
|
141
|
+
let state: ModelsState = "ready"
|
|
142
|
+
let modelsHint: string | undefined
|
|
143
|
+
const example = JSON.stringify(suggested && suggested.length > 0 ? { models: suggested } : { models: ["provider/*"] })
|
|
144
|
+
if (options.models.length === 0) {
|
|
145
|
+
state = "no_models_configured"
|
|
146
|
+
modelsHint = `The \`models\` option is empty, so every model call fails with 403 no_models_configured. Add an allowlist to the plugin options, for example ${example}`
|
|
147
|
+
} else if (!catalog) {
|
|
148
|
+
state = "catalog_unavailable"
|
|
149
|
+
modelsHint = "The model catalog could not be loaded from the OpenCode server."
|
|
150
|
+
} else if (listed.length === 0) {
|
|
151
|
+
state = "none_available"
|
|
152
|
+
modelsHint = "Nothing in the `models` allowlist matches a model the server offers. Check the provider and model ids against the catalog below."
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const first = listed[0]?.id ?? PLACEHOLDER_MODEL
|
|
156
|
+
const body = JSON.stringify({ model: first, messages: [{ role: "user", content: "Say hello." }] })
|
|
157
|
+
const curl = [`curl -s ${base}/chat/completions`, `-H "Content-Type: application/json"`, ...(keyed ? [`-H "Authorization: Bearer $PLUGBOARD_KEY"`] : []), `-d ${shellQuote(body)}`].join(" ")
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
name: NAME,
|
|
161
|
+
baseUrl: base,
|
|
162
|
+
listen: { host: options.listen.host, port: options.listen.port },
|
|
163
|
+
authentication: auth,
|
|
164
|
+
...(detail
|
|
165
|
+
? {}
|
|
166
|
+
: { hint: "Authentication is required. Send `Authorization: Bearer <key>` to see this app's configuration: the allowlist, aliases and a model suggestion." }),
|
|
167
|
+
...(detail ? { apps: [app ?? "default"] } : {}),
|
|
168
|
+
...(detail
|
|
169
|
+
? {
|
|
170
|
+
models: {
|
|
171
|
+
state,
|
|
172
|
+
allowlist: options.models,
|
|
173
|
+
aliases: options.aliases,
|
|
174
|
+
...(catalog ? { available: listed.length } : {}),
|
|
175
|
+
...(modelsHint ? { hint: modelsHint } : {}),
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
179
|
+
...(suggested && suggested.length > 0 ? { suggestedModels: suggested } : {}),
|
|
180
|
+
...(summary ? { catalog: { providers: summary.providers, ...(summary.more > 0 ? { moreProviders: summary.more } : {}) } } : {}),
|
|
181
|
+
endpoints: endpoints(options),
|
|
182
|
+
example: { curl },
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const pre = (text: string) => `<pre>${escapeHtml(text)}</pre>`
|
|
187
|
+
const list = (items: ReadonlyArray<string>) => `<ul>${items.map((item) => `<li><code>${escapeHtml(item)}</code></li>`).join("")}</ul>`
|
|
188
|
+
|
|
189
|
+
/** The `GET /` page: the discovery document as small, script-free HTML. */
|
|
190
|
+
export function renderDiscoveryHtml(doc: Discovery): string {
|
|
191
|
+
const sections: string[] = []
|
|
192
|
+
sections.push(`<p>Base URL: <code>${escapeHtml(doc.baseUrl)}</code></p>`)
|
|
193
|
+
sections.push(
|
|
194
|
+
`<p>Authentication: ${doc.authentication.required ? "required. Send <code>Authorization: Bearer <key></code>." : "none (keyless, loopback only)."}</p>`,
|
|
195
|
+
)
|
|
196
|
+
if (doc.hint) sections.push(`<p class="hint">${escapeHtml(doc.hint)}</p>`)
|
|
197
|
+
if (doc.apps) sections.push(`<h2>App</h2>${list(doc.apps)}`)
|
|
198
|
+
if (doc.models) {
|
|
199
|
+
const models = doc.models
|
|
200
|
+
sections.push(`<h2>Models</h2>`)
|
|
201
|
+
if (models.hint) sections.push(`<p class="hint">${escapeHtml(models.hint)}</p>`)
|
|
202
|
+
if (models.available !== undefined) sections.push(`<p>${escapeHtml(models.available)} model ids available.</p>`)
|
|
203
|
+
sections.push(`<h3>Allowlist</h3>${models.allowlist.length > 0 ? list(models.allowlist) : "<p>Empty.</p>"}`)
|
|
204
|
+
const aliases = Object.entries(models.aliases)
|
|
205
|
+
if (aliases.length > 0) sections.push(`<h3>Aliases</h3>${list(aliases.map(([alias, target]) => `${alias} -> ${target}`))}`)
|
|
206
|
+
}
|
|
207
|
+
if (doc.suggestedModels) {
|
|
208
|
+
sections.push(`<h2>Suggested <code>models</code> option</h2>${pre(`"models": ${JSON.stringify(doc.suggestedModels)}`)}`)
|
|
209
|
+
}
|
|
210
|
+
if (doc.catalog) {
|
|
211
|
+
const rows = doc.catalog.providers
|
|
212
|
+
.map(
|
|
213
|
+
(provider) =>
|
|
214
|
+
`<tr><td><code>${escapeHtml(provider.id)}</code></td><td>${escapeHtml(provider.models)}</td><td>${provider.examples.map((example) => `<code>${escapeHtml(example)}</code>`).join(" ")}</td></tr>`,
|
|
215
|
+
)
|
|
216
|
+
.join("")
|
|
217
|
+
const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
|
|
218
|
+
sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
|
|
219
|
+
}
|
|
220
|
+
sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
|
|
221
|
+
sections.push(
|
|
222
|
+
`<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
|
|
223
|
+
.map(
|
|
224
|
+
(endpoint) =>
|
|
225
|
+
`<tr><td>${escapeHtml(endpoint.method)}</td><td><code>${escapeHtml(endpoint.path)}</code></td><td>${endpoint.auth ? "yes" : "no"}</td><td>${escapeHtml(endpoint.description)}</td></tr>`,
|
|
226
|
+
)
|
|
227
|
+
.join("")}</table>`,
|
|
228
|
+
)
|
|
229
|
+
return `<!doctype html>
|
|
230
|
+
<html lang="en">
|
|
231
|
+
<head>
|
|
232
|
+
<meta charset="utf-8">
|
|
233
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
234
|
+
<title>${escapeHtml(doc.name)}</title>
|
|
235
|
+
<style>
|
|
236
|
+
body{font:16px/1.5 system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;color:#1a1a1a}
|
|
237
|
+
code,pre{font-family:ui-monospace,monospace;font-size:.9em}
|
|
238
|
+
pre{background:#f4f4f4;padding:.75rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
|
|
239
|
+
table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:.25rem .5rem;text-align:left;vertical-align:top}
|
|
240
|
+
.hint{border-left:4px solid #c80;background:#fff7e6;padding:.5rem .75rem}
|
|
241
|
+
</style>
|
|
242
|
+
</head>
|
|
243
|
+
<body>
|
|
244
|
+
<h1>${escapeHtml(doc.name)}</h1>
|
|
245
|
+
${sections.join("\n")}
|
|
246
|
+
<p><a href="/v1">JSON version of this page</a></p>
|
|
247
|
+
</body>
|
|
248
|
+
</html>
|
|
249
|
+
`
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
|
|
253
|
+
export function buildStatus(input: {
|
|
254
|
+
readonly options: PlugboardOptions
|
|
255
|
+
readonly app: string
|
|
256
|
+
readonly runtime: RuntimeSnapshot & {
|
|
257
|
+
readonly runtimeVersion: number
|
|
258
|
+
readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
|
|
259
|
+
readonly servingConfirmed: boolean
|
|
260
|
+
}
|
|
261
|
+
readonly catalog?: ReadonlyArray<CatalogModel>
|
|
262
|
+
readonly now?: number
|
|
263
|
+
}): object {
|
|
264
|
+
const { options, app, runtime, catalog } = input
|
|
265
|
+
const now = input.now ?? Date.now()
|
|
266
|
+
return {
|
|
267
|
+
ok: true,
|
|
268
|
+
app,
|
|
269
|
+
runtimeVersion: runtime.runtimeVersion,
|
|
270
|
+
startedAt: new Date(runtime.startedAt).toISOString(),
|
|
271
|
+
uptimeSeconds: Math.max(0, Math.floor((now - runtime.startedAt) / 1000)),
|
|
272
|
+
listen: { host: options.listen.host, port: options.listen.port, bound: runtime.listener?.bound ?? false },
|
|
273
|
+
generations: runtime.generations,
|
|
274
|
+
servingConfirmed: runtime.servingConfirmed,
|
|
275
|
+
eventPump: { running: runtime.pumpRunning },
|
|
276
|
+
sessionGuards: runtime.guards,
|
|
277
|
+
models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog).length } : { error: "catalog_unavailable" },
|
|
278
|
+
metrics: options.metrics,
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -1,28 +1,79 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tiny structured logger: one line per event
|
|
2
|
+
* Tiny structured logger: one line per event.
|
|
3
|
+
*
|
|
4
|
+
* text (default) `[opencode-plugboard] event key=value ...`
|
|
5
|
+
* json `{"time":"...","level":"info","service":"opencode-plugboard","event":"request","id":"...",...}`
|
|
3
6
|
*
|
|
4
7
|
* Callers pass only identifiers and outcomes (event name, request id, app, model, duration, an
|
|
5
8
|
* error message). Never pass API keys, request or response bodies, URLs or raw provider
|
|
6
9
|
* objects: nothing here redacts them.
|
|
10
|
+
*
|
|
11
|
+
* The format is process-wide (`setLogFormat`, set by the runtime from the live generation's
|
|
12
|
+
* `logFormat` option). A line logged while a request is being handled also carries that request's
|
|
13
|
+
* id (`id`, the value of the `x-plugboard-request-id` response header): `withRequestId` scopes it
|
|
14
|
+
* to the request's async chain (node:async_hooks), and `outsideRequest` detaches work that merely
|
|
15
|
+
* started from a request but is not about it (the history sweep, the event pump).
|
|
16
|
+
*
|
|
17
|
+
* Both settings sit on `globalThis`, because the module can be evaluated once per Location.
|
|
7
18
|
*/
|
|
8
19
|
|
|
20
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
21
|
+
|
|
9
22
|
export type LogFields = Readonly<Record<string, string | number | boolean | undefined>>
|
|
23
|
+
export type LogFormat = "text" | "json"
|
|
10
24
|
|
|
11
25
|
const PREFIX = "[opencode-plugboard]"
|
|
26
|
+
const SERVICE = "opencode-plugboard"
|
|
27
|
+
const KEY = Symbol.for("opencode-plugboard.log.1")
|
|
28
|
+
|
|
29
|
+
interface LogState {
|
|
30
|
+
format: LogFormat
|
|
31
|
+
readonly requests: AsyncLocalStorage<{ readonly id: string } | undefined>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function state(): LogState {
|
|
35
|
+
const slot = globalThis as Record<symbol, unknown>
|
|
36
|
+
return (slot[KEY] ??= { format: "text", requests: new AsyncLocalStorage() } satisfies LogState) as LogState
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Set the line format for the whole process. */
|
|
40
|
+
export function setLogFormat(format: LogFormat): void {
|
|
41
|
+
state().format = format
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run `fn` so that every log line emitted from its async chain carries `id`. */
|
|
45
|
+
export const withRequestId = <T>(id: string, fn: () => T): T => state().requests.run({ id }, fn)
|
|
46
|
+
|
|
47
|
+
/** Run `fn` detached from the current request: its log lines carry no request id. */
|
|
48
|
+
export const outsideRequest = <T>(fn: () => T): T => state().requests.run(undefined, fn)
|
|
12
49
|
|
|
13
50
|
function formatValue(value: string | number | boolean): string {
|
|
14
51
|
return typeof value === "string" && !/^[\w.:/#-]+$/.test(value) ? JSON.stringify(value) : String(value)
|
|
15
52
|
}
|
|
16
53
|
|
|
17
|
-
|
|
54
|
+
/** The fields with the ambient request id added (an explicit `id` field wins). */
|
|
55
|
+
function withAmbientId(fields: LogFields | undefined): Array<[string, string | number | boolean | undefined]> {
|
|
56
|
+
const entries = Object.entries(fields ?? {})
|
|
57
|
+
const ambient = state().requests.getStore()?.id
|
|
58
|
+
return ambient !== undefined && !entries.some(([key]) => key === "id") ? [["id", ambient], ...entries] : entries
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function format(level: "info" | "error", event: string, fields: LogFields | undefined): string {
|
|
62
|
+
const entries = withAmbientId(fields)
|
|
63
|
+
if (state().format === "json") {
|
|
64
|
+
const line: Record<string, unknown> = { time: new Date().toISOString(), level, service: SERVICE, event }
|
|
65
|
+
// The envelope keys cannot be overwritten by a field of the same name.
|
|
66
|
+
for (const [key, value] of entries) if (value !== undefined && !(key in line)) line[key] = value
|
|
67
|
+
return JSON.stringify(line)
|
|
68
|
+
}
|
|
18
69
|
let line = `${PREFIX} ${event}`
|
|
19
|
-
for (const [key, value] of
|
|
70
|
+
for (const [key, value] of entries) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
|
|
20
71
|
return line
|
|
21
72
|
}
|
|
22
73
|
|
|
23
|
-
export const log = (event: string, fields?: LogFields): void => console.log(format(event, fields))
|
|
74
|
+
export const log = (event: string, fields?: LogFields): void => console.log(format("info", event, fields))
|
|
24
75
|
|
|
25
|
-
export const logError = (event: string, fields?: LogFields): void => console.error(format(event, fields))
|
|
76
|
+
export const logError = (event: string, fields?: LogFields): void => console.error(format("error", event, fields))
|
|
26
77
|
|
|
27
78
|
/** The message of a thrown value, without its stack or attached properties. */
|
|
28
79
|
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|