opencode-plugboard 0.20.0 → 0.21.1
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 +30 -23
- package/package.json +10 -4
- package/src/anthropic/response.ts +5 -3
- package/src/anthropic/route.ts +6 -4
- package/src/cache.ts +16 -5
- package/src/completions/request.ts +13 -4
- package/src/completions/response.ts +52 -10
- package/src/config.ts +69 -3
- package/src/contract.ts +6 -0
- package/src/discovery.ts +27 -0
- package/src/gemini/request.ts +3 -2
- package/src/gemini/response.ts +20 -20
- package/src/gemini/route.ts +7 -3
- package/src/http.ts +65 -7
- package/src/openai/fanout.ts +19 -5
- package/src/openai/request.ts +4 -4
- package/src/openai/response.ts +108 -19
- package/src/openai/route.ts +21 -11
- package/src/policy.ts +16 -0
- package/src/responses/request.ts +1 -1
- package/src/responses/response.ts +13 -17
- package/src/responses/route.ts +4 -2
- package/src/runtime.ts +51 -7
- package/src/session/caller.ts +69 -7
- package/src/session/context.ts +16 -4
- package/src/session/events.ts +116 -12
- package/src/session/host.ts +14 -0
- package/src/session/seeds.ts +10 -0
- package/tui.ts +5 -4
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 {
|
package/src/session/host.ts
CHANGED
|
@@ -32,10 +32,19 @@ export interface SessionPort {
|
|
|
32
32
|
}): Promise<{ id: string }>
|
|
33
33
|
prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
|
|
34
34
|
interrupt(sessionID: string): Promise<void>
|
|
35
|
+
/** Deletes a host session. Used only for a session that was created after its request was aborted (#155). */
|
|
36
|
+
remove(sessionID: string): Promise<void>
|
|
35
37
|
/** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
|
|
36
38
|
skill(directory: string, id: string): Promise<string | undefined>
|
|
37
39
|
/** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
|
|
38
40
|
agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
|
|
41
|
+
/**
|
|
42
|
+
* The ids of the OpenCode tools this location offers directly to a model (`codemode: false`), the only
|
|
43
|
+
* ones a request session can call by name. CodeMode-wrapped tools are reached through `execute`, which a
|
|
44
|
+
* request session denies, so they are not listed. A tool whose permission name differs from its id is
|
|
45
|
+
* not listed either: the session's permission rules are written by id.
|
|
46
|
+
*/
|
|
47
|
+
directTools(): Promise<ReadonlyArray<string>>
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
type HostSession = HostContext["session"]
|
|
@@ -112,6 +121,11 @@ export function createSessionPort(ctx: HostContext): SessionPort {
|
|
|
112
121
|
),
|
|
113
122
|
interrupt: (sessionID) =>
|
|
114
123
|
run(ctx.session.interrupt({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["interrupt"]>[0])).then(() => undefined),
|
|
124
|
+
remove: (sessionID) => run(ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0])).then(() => undefined),
|
|
125
|
+
directTools: () =>
|
|
126
|
+
run(ctx.tool.list()).then((tools) =>
|
|
127
|
+
tools.filter((tool) => tool.options?.codemode === false && (tool.options?.permission ?? tool.id) === tool.id).map((tool) => tool.id),
|
|
128
|
+
),
|
|
115
129
|
}
|
|
116
130
|
}
|
|
117
131
|
|
package/src/session/seeds.ts
CHANGED
|
@@ -39,6 +39,16 @@ export const REQUEST_PERMISSIONS: ReadonlyArray<{ action: string; resource: stri
|
|
|
39
39
|
{ action: `${SEED_PREFIX}*`, resource: "*", effect: "allow" },
|
|
40
40
|
]
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Session permissions of a request session of an app with option `serverTools`: the same deny-all, then
|
|
44
|
+
* each listed OpenCode tool id allowed by its exact name (no wildcard). The host takes the last matching
|
|
45
|
+
* rule (`Permission.evaluate`, findLast), so the later allows win over the deny. An app without
|
|
46
|
+
* `serverTools` gets exactly `REQUEST_PERMISSIONS`.
|
|
47
|
+
*/
|
|
48
|
+
export function requestPermissions(serverTools: ReadonlyArray<string>): ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }> {
|
|
49
|
+
return [...REQUEST_PERMISSIONS, ...serverTools.map((tool) => ({ action: tool, resource: "*", effect: "allow" as const }))]
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
/** The caller tools that get a seed in the model request (`tool_choice` none offers none, a named tool only that one). */
|
|
43
53
|
export function seedToolsFor(request: PlugRequest): ReadonlyArray<PlugTool> {
|
|
44
54
|
const choice = request.toolChoice
|
package/tui.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// The `./tui` entrypoint
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// The `./tui` entrypoint for a plugin configured by local path. OpenCode resolves that as
|
|
2
|
+
// `<directory>/tui` with no `exports` lookup (packages/plugin/src/host.ts in OpenCode 2.0.26), so the
|
|
3
|
+
// entry lives at the package root. It re-exports the compiled file (`bun run build:tui`), not
|
|
4
|
+
// src/tui.tsx: a local path under node_modules is never Solid-compiled (see build-tui.ts).
|
|
5
|
+
export { default } from "./dist/tui.js"
|