opencode-plugboard 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -31
- package/dist/tui.js +334 -0
- package/index.ts +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 +99 -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/README.md
CHANGED
|
@@ -43,7 +43,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
|
|
|
43
43
|
{
|
|
44
44
|
"plugins": [
|
|
45
45
|
{
|
|
46
|
-
"package": "opencode-plugboard@0.
|
|
46
|
+
"package": "opencode-plugboard@0.21.0",
|
|
47
47
|
"options": {
|
|
48
48
|
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
49
49
|
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
@@ -64,7 +64,7 @@ Keep the version pinned. A bare `"opencode-plugboard"` means `latest`, so a cach
|
|
|
64
64
|
opencode api get /api/agent
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
For an always-on server, run that from the service unit. See [warm-up]
|
|
67
|
+
For an always-on server, run that from the service unit. See [warm-up][warm-up].
|
|
68
68
|
|
|
69
69
|
**4. Call it.** The listener is on `127.0.0.1:4010`. This prints `pong`:
|
|
70
70
|
|
|
@@ -107,7 +107,7 @@ Give every tool its own key. Traffic, history and usage are then separated by ap
|
|
|
107
107
|
}
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
Callers send the key as `Authorization: Bearer <key>` (or `x-api-key` on the Anthropic routes). They never need the OpenCode server password. Removing a key takes effect on the running server at once.
|
|
110
|
+
Callers send the key as `Authorization: Bearer <key>` (or `x-api-key` on the Anthropic routes). They never need the OpenCode server password. Removing a key takes effect on the running server at once; see [reloads and restarts][configuration-reload].
|
|
111
111
|
|
|
112
112
|
#### Model allowlist
|
|
113
113
|
|
|
@@ -390,7 +390,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
|
|
|
390
390
|
{
|
|
391
391
|
"plugins": [
|
|
392
392
|
{
|
|
393
|
-
"package": "opencode-plugboard@0.
|
|
393
|
+
"package": "opencode-plugboard@0.21.0",
|
|
394
394
|
"options": {
|
|
395
395
|
// Reachable from the LAN. Non-loopback hosts refuse to start without keys.
|
|
396
396
|
"listen": { "host": "10.0.1.50", "port": 4010 },
|
|
@@ -468,8 +468,8 @@ The protocol-level results are in the [compatibility matrix][compatibility]. It
|
|
|
468
468
|
|
|
469
469
|
## Known gaps
|
|
470
470
|
|
|
471
|
-
- **Responses
|
|
472
|
-
- **Stop sequences** are removed from the answer
|
|
471
|
+
- **Responses keeps no conversation.** Every request carries the whole conversation, and `previous_response_id` and `conversation` are rejected. The output items of recent answers are held in memory for an hour, so the `item_reference`s that the Vercel AI SDK sends back resolve; a restart clears them ([#147](https://github.com/jjjermiah/opencode-plugboard/issues/147)). For n8n, turn "Use Responses API" off.
|
|
472
|
+
- **Stop sequences** are removed from the answer when they end it, on the Anthropic, OpenAI and Gemini routes. The host reports only that the turn stopped, so a sequence the provider ended on without returning it is not removed.
|
|
473
473
|
- **Anthropic structured outputs** (`output_config.format`) are refused with a 400. Use `json_schema` on the OpenAI or Gemini endpoints.
|
|
474
474
|
- **Gemini `candidateCount` above 1** is refused. The OpenAI endpoints serve `n`.
|
|
475
475
|
- **Gemini model ids with a `/`** sent by the AI SDK's Google provider or LangChain's `google-genai` lack the `models/` prefix and get a 401. The `@google/genai` SDK works.
|
|
@@ -485,7 +485,7 @@ Open work is tracked in the [v0.1 milestone](https://github.com/jjjermiah/openco
|
|
|
485
485
|
## Security
|
|
486
486
|
|
|
487
487
|
- **Bind address.** The listener binds `127.0.0.1` by default. A non-loopback `listen.host` refuses to start without `keys`. On a loopback bind, a `Host` header that is not `localhost`, `127.0.0.1` or `[::1]` gets a 403.
|
|
488
|
-
- **Keys cross the network in clear text.** The endpoint speaks plain HTTP. For remote access, allow only known addresses in the firewall, tunnel over SSH, or put a TLS-terminating proxy in front. See [reaching plugboard from another host or container][reach].
|
|
488
|
+
- **Keys cross the network in clear text.** The endpoint speaks plain HTTP. For remote access, allow only known addresses in the firewall, tunnel over SSH, or put a TLS-terminating proxy in front. See [reaching plugboard from another host or container][reach] and [remote access][remote-access].
|
|
489
489
|
- **Keep keys out of the config file.** Use `{env:VAR}` and keep the values in the environment or a secret store. Every app needs its own key.
|
|
490
490
|
- **`adminKey` reads usage only.** It cannot call the model routes, and it must differ from every app key.
|
|
491
491
|
- **Keyless mode is loopback only.** Without `keys`, the one app is `default`, and only local callers can reach it.
|
|
@@ -510,28 +510,32 @@ The plugin swaps the caller's prompt, messages and tools in for OpenCode's own.
|
|
|
510
510
|
|
|
511
511
|
MIT
|
|
512
512
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
[configuration
|
|
516
|
-
[configuration-
|
|
517
|
-
[configuration-
|
|
518
|
-
[
|
|
519
|
-
[
|
|
520
|
-
[
|
|
521
|
-
[
|
|
522
|
-
[
|
|
523
|
-
[
|
|
524
|
-
[
|
|
525
|
-
[
|
|
526
|
-
[
|
|
513
|
+
Not affiliated with or endorsed by OpenAI, Anthropic, Google, GitHub or Microsoft. Product names are trademarks of their owners.
|
|
514
|
+
|
|
515
|
+
[configuration]: https://jjjermiah.github.io/opencode-plugboard/configuration
|
|
516
|
+
[configuration-apps]: https://jjjermiah.github.io/opencode-plugboard/configuration#apps-and-keys
|
|
517
|
+
[configuration-aliases]: https://jjjermiah.github.io/opencode-plugboard/configuration#aliases-and-fallbacks
|
|
518
|
+
[configuration-limits]: https://jjjermiah.github.io/opencode-plugboard/configuration#concurrency-and-rate-limits
|
|
519
|
+
[configuration-usage]: https://jjjermiah.github.io/opencode-plugboard/configuration#usage-statistics
|
|
520
|
+
[configuration-reload]: https://jjjermiah.github.io/opencode-plugboard/configuration#reloads-and-restarts
|
|
521
|
+
[responses]: https://jjjermiah.github.io/opencode-plugboard/responses
|
|
522
|
+
[messages]: https://jjjermiah.github.io/opencode-plugboard/messages
|
|
523
|
+
[gemini]: https://jjjermiah.github.io/opencode-plugboard/gemini
|
|
524
|
+
[completions]: https://jjjermiah.github.io/opencode-plugboard/completions
|
|
525
|
+
[history]: https://jjjermiah.github.io/opencode-plugboard/history
|
|
526
|
+
[warm-up]: https://jjjermiah.github.io/opencode-plugboard/warm-up
|
|
527
|
+
[architecture]: https://jjjermiah.github.io/opencode-plugboard/architecture
|
|
528
|
+
[development]: https://jjjermiah.github.io/opencode-plugboard/development
|
|
529
|
+
[compatibility]: https://jjjermiah.github.io/opencode-plugboard/compatibility
|
|
527
530
|
[release-candidate]: https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/results/release-candidate.md
|
|
528
|
-
[reach]: https://github.
|
|
529
|
-
[
|
|
530
|
-
[client-openai-
|
|
531
|
-
[client-
|
|
532
|
-
[client-
|
|
533
|
-
[client-langchain-
|
|
534
|
-
[client-
|
|
535
|
-
[client-
|
|
536
|
-
[client-
|
|
537
|
-
[client-
|
|
531
|
+
[reach]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app#reaching-plugboard-from-another-host-or-container
|
|
532
|
+
[remote-access]: https://jjjermiah.github.io/opencode-plugboard/remote-access
|
|
533
|
+
[client-openai-python]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-python
|
|
534
|
+
[client-openai-js]: https://jjjermiah.github.io/opencode-plugboard/clients/openai-js
|
|
535
|
+
[client-vercel-ai-sdk]: https://jjjermiah.github.io/opencode-plugboard/clients/vercel-ai-sdk
|
|
536
|
+
[client-langchain-python]: https://jjjermiah.github.io/opencode-plugboard/clients/langchain-python
|
|
537
|
+
[client-langchain-js]: https://jjjermiah.github.io/opencode-plugboard/clients/langchain-js
|
|
538
|
+
[client-pydantic-ai]: https://jjjermiah.github.io/opencode-plugboard/clients/pydantic-ai
|
|
539
|
+
[client-open-webui]: https://jjjermiah.github.io/opencode-plugboard/clients/open-webui
|
|
540
|
+
[client-n8n]: https://jjjermiah.github.io/opencode-plugboard/clients/n8n
|
|
541
|
+
[client-any-app]: https://jjjermiah.github.io/opencode-plugboard/clients/any-app
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// Generated by .github/scripts/build-tui.ts from src/tui.tsx. Do not edit.
|
|
2
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
3
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
4
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
5
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
6
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
7
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
8
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
9
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
10
|
+
/** @jsxImportSource @opentui/solid */
|
|
11
|
+
/**
|
|
12
|
+
* The `/plugboard` usage page in OpenCode's terminal UI (package entry `./tui`). OpenCode loads it
|
|
13
|
+
* for every server that runs plugboard with the TUI feature: it reads the usage report over the
|
|
14
|
+
* server's own RPC (`context.client.rpc(UsageRpc).get`, src/rpc.ts), so it sees every app there.
|
|
15
|
+
* Nothing is served over HTTP, and nothing here runs on the server.
|
|
16
|
+
*
|
|
17
|
+
* Page keys: t, w, m, q pick the range (today, 7, 30, 90 UTC days); r reloads; esc goes back.
|
|
18
|
+
* Setup only registers the page and the command (no awaits). Shaping and formats: src/tui-format.ts.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
22
|
+
import { TextAttributes } from "@opentui/core";
|
|
23
|
+
import { createResource, createSignal, For, Show } from "solid-js";
|
|
24
|
+
import { UsageRpc } from "../src/rpc.js";
|
|
25
|
+
import { APP_COLUMNS, appRows, appValues, barCells, formatLine, MODEL_COLUMNS, modelRows, modelValues, presetRange, RANGE_PRESETS, totalItems, unavailableMessage } from "../src/tui-format.js";
|
|
26
|
+
const PRESET_LABEL = preset => RANGE_PRESETS.find(item => item.preset === preset)?.label ?? preset;
|
|
27
|
+
function UsagePage(props) {
|
|
28
|
+
const theme = props.context.theme;
|
|
29
|
+
const [query, setQuery] = createSignal({
|
|
30
|
+
preset: "month",
|
|
31
|
+
reload: 0
|
|
32
|
+
});
|
|
33
|
+
const [report] = createResource(query, async current => {
|
|
34
|
+
const range = presetRange(current.preset, Date.now());
|
|
35
|
+
try {
|
|
36
|
+
return await props.context.client.rpc(UsageRpc).get({
|
|
37
|
+
from: range.from,
|
|
38
|
+
to: range.to
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
props.context.ui.toast.show({
|
|
42
|
+
variant: "error",
|
|
43
|
+
title: "Plugboard",
|
|
44
|
+
message: unavailableMessage(error)
|
|
45
|
+
});
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
props.context.keymap.layer(() => ({
|
|
50
|
+
commands: [{
|
|
51
|
+
bind: "escape",
|
|
52
|
+
title: "back",
|
|
53
|
+
run: props.onClose
|
|
54
|
+
}, ...RANGE_PRESETS.map(item => ({
|
|
55
|
+
bind: item.key,
|
|
56
|
+
title: `range ${item.label}`,
|
|
57
|
+
run: () => setQuery(current => ({
|
|
58
|
+
preset: item.preset,
|
|
59
|
+
reload: current.reload
|
|
60
|
+
}))
|
|
61
|
+
})), {
|
|
62
|
+
bind: "r",
|
|
63
|
+
title: "refresh",
|
|
64
|
+
run: () => setQuery(current => ({
|
|
65
|
+
...current,
|
|
66
|
+
reload: current.reload + 1
|
|
67
|
+
}))
|
|
68
|
+
}]
|
|
69
|
+
}));
|
|
70
|
+
return (() => {
|
|
71
|
+
var _el$ = _$createElement("box"),
|
|
72
|
+
_el$2 = _$createElement("scrollbox"),
|
|
73
|
+
_el$3 = _$createElement("box"),
|
|
74
|
+
_el$4 = _$createElement("text"),
|
|
75
|
+
_el$6 = _$createElement("text"),
|
|
76
|
+
_el$7 = _$createTextNode(` · t today · w 7 days · m 30 days · q 90 days · r refresh · esc back`);
|
|
77
|
+
_$insertNode(_el$, _el$2);
|
|
78
|
+
_$setProp(_el$, "width", "100%");
|
|
79
|
+
_$setProp(_el$, "height", "100%");
|
|
80
|
+
_$insertNode(_el$2, _el$3);
|
|
81
|
+
_$setProp(_el$2, "flexGrow", 1);
|
|
82
|
+
_$insertNode(_el$3, _el$4);
|
|
83
|
+
_$insertNode(_el$3, _el$6);
|
|
84
|
+
_$setProp(_el$3, "flexDirection", "column");
|
|
85
|
+
_$setProp(_el$3, "gap", 1);
|
|
86
|
+
_$setProp(_el$3, "paddingLeft", 2);
|
|
87
|
+
_$setProp(_el$3, "paddingRight", 2);
|
|
88
|
+
_$setProp(_el$3, "paddingTop", 1);
|
|
89
|
+
_$setProp(_el$3, "paddingBottom", 1);
|
|
90
|
+
_$insertNode(_el$4, _$createTextNode(`Plugboard usage`));
|
|
91
|
+
_$insertNode(_el$6, _el$7);
|
|
92
|
+
_$insert(_el$6, (() => {
|
|
93
|
+
var _c$ = _$memo(() => !!report());
|
|
94
|
+
return () => _c$() ? `${report().from} to ${report().to} (UTC)` : PRESET_LABEL(query().preset);
|
|
95
|
+
})(), _el$7);
|
|
96
|
+
_$insert(_el$3, _$createComponent(Show, {
|
|
97
|
+
get when() {
|
|
98
|
+
return !report.error;
|
|
99
|
+
},
|
|
100
|
+
get fallback() {
|
|
101
|
+
return (() => {
|
|
102
|
+
var _el$9 = _$createElement("text");
|
|
103
|
+
_$insert(_el$9, () => unavailableMessage(report.error));
|
|
104
|
+
_$effect(_$p => _$setProp(_el$9, "fg", theme.text.feedback.error.base, _$p));
|
|
105
|
+
return _el$9;
|
|
106
|
+
})();
|
|
107
|
+
},
|
|
108
|
+
get children() {
|
|
109
|
+
return _$createComponent(Show, {
|
|
110
|
+
get when() {
|
|
111
|
+
return report();
|
|
112
|
+
},
|
|
113
|
+
get fallback() {
|
|
114
|
+
return (() => {
|
|
115
|
+
var _el$0 = _$createElement("text");
|
|
116
|
+
_$insertNode(_el$0, _$createTextNode(`Loading usage…`));
|
|
117
|
+
_$effect(_$p => _$setProp(_el$0, "fg", theme.text.muted, _$p));
|
|
118
|
+
return _el$0;
|
|
119
|
+
})();
|
|
120
|
+
},
|
|
121
|
+
get children() {
|
|
122
|
+
return _$createComponent(UsageBody, {
|
|
123
|
+
get report() {
|
|
124
|
+
return report();
|
|
125
|
+
},
|
|
126
|
+
theme: theme
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}), null);
|
|
132
|
+
_$effect(_p$ => {
|
|
133
|
+
var _v$ = theme.background.base,
|
|
134
|
+
_v$2 = theme.text.base,
|
|
135
|
+
_v$3 = TextAttributes.BOLD,
|
|
136
|
+
_v$4 = theme.text.muted;
|
|
137
|
+
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "backgroundColor", _v$, _p$.e));
|
|
138
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$4, "fg", _v$2, _p$.t));
|
|
139
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$4, "attributes", _v$3, _p$.a));
|
|
140
|
+
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$6, "fg", _v$4, _p$.o));
|
|
141
|
+
return _p$;
|
|
142
|
+
}, {
|
|
143
|
+
e: undefined,
|
|
144
|
+
t: undefined,
|
|
145
|
+
a: undefined,
|
|
146
|
+
o: undefined
|
|
147
|
+
});
|
|
148
|
+
return _el$;
|
|
149
|
+
})();
|
|
150
|
+
}
|
|
151
|
+
function UsageBody(props) {
|
|
152
|
+
const apps = () => appRows(props.report);
|
|
153
|
+
const models = () => modelRows(props.report, 10);
|
|
154
|
+
return (() => {
|
|
155
|
+
var _el$10 = _$createElement("box"),
|
|
156
|
+
_el$11 = _$createElement("box"),
|
|
157
|
+
_el$12 = _$createElement("text"),
|
|
158
|
+
_el$15 = _$createElement("text");
|
|
159
|
+
_$insertNode(_el$10, _el$11);
|
|
160
|
+
_$insertNode(_el$10, _el$12);
|
|
161
|
+
_$insertNode(_el$10, _el$15);
|
|
162
|
+
_$setProp(_el$10, "flexDirection", "column");
|
|
163
|
+
_$setProp(_el$10, "gap", 1);
|
|
164
|
+
_$setProp(_el$11, "flexDirection", "row");
|
|
165
|
+
_$setProp(_el$11, "flexWrap", "wrap");
|
|
166
|
+
_$setProp(_el$11, "gap", 2);
|
|
167
|
+
_$insert(_el$11, _$createComponent(For, {
|
|
168
|
+
get each() {
|
|
169
|
+
return totalItems(props.report.totals);
|
|
170
|
+
},
|
|
171
|
+
children: item => (() => {
|
|
172
|
+
var _el$18 = _$createElement("box"),
|
|
173
|
+
_el$19 = _$createElement("text"),
|
|
174
|
+
_el$20 = _$createElement("text");
|
|
175
|
+
_$insertNode(_el$18, _el$19);
|
|
176
|
+
_$insertNode(_el$18, _el$20);
|
|
177
|
+
_$setProp(_el$18, "flexDirection", "row");
|
|
178
|
+
_$setProp(_el$18, "gap", 1);
|
|
179
|
+
_$insert(_el$19, () => item.label);
|
|
180
|
+
_$insert(_el$20, () => item.value);
|
|
181
|
+
_$effect(_p$ => {
|
|
182
|
+
var _v$9 = props.theme.text.muted,
|
|
183
|
+
_v$0 = props.theme.text.base,
|
|
184
|
+
_v$1 = TextAttributes.BOLD;
|
|
185
|
+
_v$9 !== _p$.e && (_p$.e = _$setProp(_el$19, "fg", _v$9, _p$.e));
|
|
186
|
+
_v$0 !== _p$.t && (_p$.t = _$setProp(_el$20, "fg", _v$0, _p$.t));
|
|
187
|
+
_v$1 !== _p$.a && (_p$.a = _$setProp(_el$20, "attributes", _v$1, _p$.a));
|
|
188
|
+
return _p$;
|
|
189
|
+
}, {
|
|
190
|
+
e: undefined,
|
|
191
|
+
t: undefined,
|
|
192
|
+
a: undefined
|
|
193
|
+
});
|
|
194
|
+
return _el$18;
|
|
195
|
+
})()
|
|
196
|
+
}));
|
|
197
|
+
_$insertNode(_el$12, _$createTextNode(`Apps`));
|
|
198
|
+
_$insert(_el$10, _$createComponent(Show, {
|
|
199
|
+
get when() {
|
|
200
|
+
return apps().length > 0;
|
|
201
|
+
},
|
|
202
|
+
get fallback() {
|
|
203
|
+
return (() => {
|
|
204
|
+
var _el$21 = _$createElement("text");
|
|
205
|
+
_$insertNode(_el$21, _$createTextNode(`No requests in this range.`));
|
|
206
|
+
_$effect(_$p => _$setProp(_el$21, "fg", props.theme.text.muted, _$p));
|
|
207
|
+
return _el$21;
|
|
208
|
+
})();
|
|
209
|
+
},
|
|
210
|
+
get children() {
|
|
211
|
+
return [(() => {
|
|
212
|
+
var _el$14 = _$createElement("text");
|
|
213
|
+
_$insert(_el$14, () => formatLine(APP_COLUMNS.map(column => column.title), APP_COLUMNS));
|
|
214
|
+
_$effect(_$p => _$setProp(_el$14, "fg", props.theme.text.muted, _$p));
|
|
215
|
+
return _el$14;
|
|
216
|
+
})(), _$createComponent(For, {
|
|
217
|
+
get each() {
|
|
218
|
+
return apps();
|
|
219
|
+
},
|
|
220
|
+
children: row => (() => {
|
|
221
|
+
var _el$23 = _$createElement("box"),
|
|
222
|
+
_el$24 = _$createElement("text"),
|
|
223
|
+
_el$25 = _$createElement("text");
|
|
224
|
+
_$insertNode(_el$23, _el$24);
|
|
225
|
+
_$insertNode(_el$23, _el$25);
|
|
226
|
+
_$setProp(_el$23, "flexDirection", "row");
|
|
227
|
+
_$insert(_el$24, () => formatLine(appValues(row), APP_COLUMNS));
|
|
228
|
+
_$insert(_el$25, () => ` ${barCells(row.share, 24)}`);
|
|
229
|
+
_$effect(_p$ => {
|
|
230
|
+
var _v$10 = props.theme.text.base,
|
|
231
|
+
_v$11 = props.theme.categorical[0][200];
|
|
232
|
+
_v$10 !== _p$.e && (_p$.e = _$setProp(_el$24, "fg", _v$10, _p$.e));
|
|
233
|
+
_v$11 !== _p$.t && (_p$.t = _$setProp(_el$25, "fg", _v$11, _p$.t));
|
|
234
|
+
return _p$;
|
|
235
|
+
}, {
|
|
236
|
+
e: undefined,
|
|
237
|
+
t: undefined
|
|
238
|
+
});
|
|
239
|
+
return _el$23;
|
|
240
|
+
})()
|
|
241
|
+
})];
|
|
242
|
+
}
|
|
243
|
+
}), _el$15);
|
|
244
|
+
_$insertNode(_el$15, _$createTextNode(`Models`));
|
|
245
|
+
_$insert(_el$10, _$createComponent(Show, {
|
|
246
|
+
get when() {
|
|
247
|
+
return models().length > 0;
|
|
248
|
+
},
|
|
249
|
+
get fallback() {
|
|
250
|
+
return (() => {
|
|
251
|
+
var _el$26 = _$createElement("text");
|
|
252
|
+
_$insertNode(_el$26, _$createTextNode(`No model calls in this range.`));
|
|
253
|
+
_$effect(_$p => _$setProp(_el$26, "fg", props.theme.text.muted, _$p));
|
|
254
|
+
return _el$26;
|
|
255
|
+
})();
|
|
256
|
+
},
|
|
257
|
+
get children() {
|
|
258
|
+
return [(() => {
|
|
259
|
+
var _el$17 = _$createElement("text");
|
|
260
|
+
_$insert(_el$17, () => formatLine(MODEL_COLUMNS.map(column => column.title), MODEL_COLUMNS));
|
|
261
|
+
_$effect(_$p => _$setProp(_el$17, "fg", props.theme.text.muted, _$p));
|
|
262
|
+
return _el$17;
|
|
263
|
+
})(), _$createComponent(For, {
|
|
264
|
+
get each() {
|
|
265
|
+
return models();
|
|
266
|
+
},
|
|
267
|
+
children: row => (() => {
|
|
268
|
+
var _el$28 = _$createElement("text");
|
|
269
|
+
_$insert(_el$28, () => formatLine(modelValues(row), MODEL_COLUMNS));
|
|
270
|
+
_$effect(_$p => _$setProp(_el$28, "fg", props.theme.text.base, _$p));
|
|
271
|
+
return _el$28;
|
|
272
|
+
})()
|
|
273
|
+
})];
|
|
274
|
+
}
|
|
275
|
+
}), null);
|
|
276
|
+
_$effect(_p$ => {
|
|
277
|
+
var _v$5 = props.theme.text.muted,
|
|
278
|
+
_v$6 = TextAttributes.BOLD,
|
|
279
|
+
_v$7 = props.theme.text.muted,
|
|
280
|
+
_v$8 = TextAttributes.BOLD;
|
|
281
|
+
_v$5 !== _p$.e && (_p$.e = _$setProp(_el$12, "fg", _v$5, _p$.e));
|
|
282
|
+
_v$6 !== _p$.t && (_p$.t = _$setProp(_el$12, "attributes", _v$6, _p$.t));
|
|
283
|
+
_v$7 !== _p$.a && (_p$.a = _$setProp(_el$15, "fg", _v$7, _p$.a));
|
|
284
|
+
_v$8 !== _p$.o && (_p$.o = _$setProp(_el$15, "attributes", _v$8, _p$.o));
|
|
285
|
+
return _p$;
|
|
286
|
+
}, {
|
|
287
|
+
e: undefined,
|
|
288
|
+
t: undefined,
|
|
289
|
+
a: undefined,
|
|
290
|
+
o: undefined
|
|
291
|
+
});
|
|
292
|
+
return _el$10;
|
|
293
|
+
})();
|
|
294
|
+
}
|
|
295
|
+
export default Plugin.define({
|
|
296
|
+
id: "opencode-plugboard.tui",
|
|
297
|
+
setup(context) {
|
|
298
|
+
const [previous, setPrevious] = createSignal({
|
|
299
|
+
...context.ui.router.current()
|
|
300
|
+
});
|
|
301
|
+
context.ui.router.register({
|
|
302
|
+
name: "plugboard.usage",
|
|
303
|
+
render: () => _$createComponent(UsagePage, {
|
|
304
|
+
context: context,
|
|
305
|
+
onClose: () => context.ui.router.navigate(previous())
|
|
306
|
+
})
|
|
307
|
+
});
|
|
308
|
+
context.keymap.layer(() => ({
|
|
309
|
+
mode: "global",
|
|
310
|
+
commands: [{
|
|
311
|
+
id: "plugboard.usage.open",
|
|
312
|
+
title: "Plugboard usage",
|
|
313
|
+
group: "Plugboard",
|
|
314
|
+
slash: {
|
|
315
|
+
name: "plugboard"
|
|
316
|
+
},
|
|
317
|
+
palette: true,
|
|
318
|
+
run() {
|
|
319
|
+
const current = context.ui.router.current();
|
|
320
|
+
if (current.type === "plugin" && current.name === "plugboard.usage") return;
|
|
321
|
+
// The router exposes a mutable store; keep the route before navigating.
|
|
322
|
+
setPrevious({
|
|
323
|
+
...current
|
|
324
|
+
});
|
|
325
|
+
context.ui.dialog.clear();
|
|
326
|
+
context.ui.router.navigate({
|
|
327
|
+
type: "plugin",
|
|
328
|
+
name: "plugboard.usage"
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}]
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
});
|
package/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { withFallbacks } from "./src/fallback.js"
|
|
|
27
27
|
import { withBudgets } from "./src/budgets.js"
|
|
28
28
|
import { withLimits } from "./src/limits.js"
|
|
29
29
|
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
30
|
-
import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
|
|
30
|
+
import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, setConfigError, sessionStore, withResponseCache } from "./src/runtime.js"
|
|
31
31
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
32
32
|
import { installSessionHooks } from "./src/session/context.js"
|
|
33
33
|
import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
|
|
@@ -43,10 +43,16 @@ export default Plugin.define({
|
|
|
43
43
|
try {
|
|
44
44
|
options = parseOptions(ctx.options)
|
|
45
45
|
} catch (error) {
|
|
46
|
-
// Invalid options
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
// Invalid options fail this setup, so the host lists the plugin as `failed` with the message
|
|
47
|
+
// (opencode packages/core/src/plugin.ts: a failed setup becomes slot.error, then the state).
|
|
48
|
+
// Nothing is registered, so the Location stays inert. The same message goes to the error log,
|
|
49
|
+
// and to `/v1/status` and `/readyz` while a listener from an earlier good generation serves (#159).
|
|
50
|
+
const message = errorMessage(error)
|
|
51
|
+
setConfigError(message)
|
|
52
|
+
logError("options.invalid", { error: message })
|
|
53
|
+
return yield* Effect.die(new Error(`plugboard options are invalid: ${message}`))
|
|
49
54
|
}
|
|
55
|
+
setConfigError(undefined)
|
|
50
56
|
|
|
51
57
|
// ---- every Location: join the shared listener (sync registration only).
|
|
52
58
|
const sessions = sessionStore()
|
|
@@ -82,12 +88,13 @@ export default Plugin.define({
|
|
|
82
88
|
// Usage is outermost, so it records the answer the client gets (src/usage.ts).
|
|
83
89
|
// Caps are outside the fallbacks, so one request takes one slot even when it falls back (src/limits.ts).
|
|
84
90
|
// Aliases written as a list retry the next target before the first output (src/fallback.ts).
|
|
85
|
-
// The response cache
|
|
91
|
+
// The response cache is outside budgets and limits: a hit spends nothing, takes no slot and counts toward no
|
|
92
|
+
// rate window, and never reaches a provider (src/cache.ts). Only a miss is budgeted and limited.
|
|
86
93
|
caller: withUsage(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
withResponseCache(
|
|
95
|
+
// Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
|
|
96
|
+
withBudgets(
|
|
97
|
+
withLimits(
|
|
91
98
|
withFallbacks(createSessionCaller(callerDeps), {
|
|
92
99
|
options,
|
|
93
100
|
loadCatalog: servingCatalog,
|
|
@@ -96,26 +103,26 @@ export default Plugin.define({
|
|
|
96
103
|
usage?.fallback(app)
|
|
97
104
|
},
|
|
98
105
|
}),
|
|
99
|
-
|
|
106
|
+
() => limits,
|
|
107
|
+
processLimits(),
|
|
108
|
+
{
|
|
109
|
+
onLimited: (app, reason) => {
|
|
110
|
+
if (options.metrics) processMetrics().limited(app, reason)
|
|
111
|
+
usage?.limited(app)
|
|
112
|
+
},
|
|
113
|
+
},
|
|
100
114
|
),
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
options.budgets,
|
|
116
|
+
usage,
|
|
117
|
+
processBudgets(),
|
|
103
118
|
{
|
|
104
|
-
|
|
105
|
-
if (options.metrics) processMetrics().limited(app,
|
|
119
|
+
onRefused: (app) => {
|
|
120
|
+
if (options.metrics) processMetrics().limited(app, "budget")
|
|
106
121
|
usage?.limited(app)
|
|
107
122
|
},
|
|
108
123
|
},
|
|
109
124
|
),
|
|
110
|
-
options
|
|
111
|
-
usage,
|
|
112
|
-
processBudgets(),
|
|
113
|
-
{
|
|
114
|
-
onRefused: (app) => {
|
|
115
|
-
if (options.metrics) processMetrics().limited(app, "budget")
|
|
116
|
-
usage?.limited(app)
|
|
117
|
-
},
|
|
118
|
-
},
|
|
125
|
+
options,
|
|
119
126
|
),
|
|
120
127
|
usage,
|
|
121
128
|
),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
|
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
"exports": {
|
|
19
19
|
".": { "types": "./index.ts", "default": "./index.ts" },
|
|
20
20
|
"./rpc": { "types": "./rpc.ts", "default": "./rpc.ts" },
|
|
21
|
-
"./tui": { "types": "./tui.ts", "default": "./tui.
|
|
21
|
+
"./tui": { "types": "./tui.ts", "default": "./dist/tui.js" }
|
|
22
22
|
},
|
|
23
|
-
"files": ["index.ts", "rpc.ts", "tui.ts", "src/", "README.md", "LICENSE"],
|
|
23
|
+
"files": ["index.ts", "rpc.ts", "tui.ts", "src/", "dist/", "README.md", "LICENSE"],
|
|
24
24
|
"engines": {
|
|
25
25
|
"bun": ">=1.4.2"
|
|
26
26
|
},
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
"mutations": "bun .github/scripts/mutation-check.ts",
|
|
32
32
|
"flake-hunt": "bun .github/scripts/flake-hunt.ts",
|
|
33
33
|
"coverage-manifest": "bun .github/scripts/coverage-manifest.ts",
|
|
34
|
+
"live-matrix": "bun test/live/matrix.ts",
|
|
35
|
+
"build:tui": "bun .github/scripts/build-tui.ts",
|
|
36
|
+
"prepack": "bun run build:tui",
|
|
34
37
|
"smoke:pack": "bun .github/scripts/smoke-pack.ts",
|
|
35
38
|
"docs:dev": "vitepress dev docs",
|
|
36
39
|
"docs:build": "vitepress build docs",
|
|
@@ -38,6 +41,7 @@
|
|
|
38
41
|
},
|
|
39
42
|
"dependencies": {
|
|
40
43
|
"@prometheus-io/client": "0.16.1",
|
|
44
|
+
"@types/escape-html": "1.0.4",
|
|
41
45
|
"escape-html": "1.0.3",
|
|
42
46
|
"ipaddr.js": "2.5.0",
|
|
43
47
|
"lru-cache": "11.5.3",
|
|
@@ -52,6 +56,8 @@
|
|
|
52
56
|
"@ai-sdk/openai": "4.0.91",
|
|
53
57
|
"@ai-sdk/openai-compatible": "3.0.67",
|
|
54
58
|
"@anthropic-ai/sdk": "0.133.0",
|
|
59
|
+
"@babel/core": "7.29.7",
|
|
60
|
+
"@babel/preset-typescript": "7.27.1",
|
|
55
61
|
"@commitlint/cli": "21.2.3",
|
|
56
62
|
"@commitlint/config-conventional": "21.2.3",
|
|
57
63
|
"@google/genai": "2.28.0",
|
|
@@ -62,12 +68,12 @@
|
|
|
62
68
|
"@opentui/core": "0.5.17",
|
|
63
69
|
"@opentui/solid": "0.5.17",
|
|
64
70
|
"@types/bun": "1.4.2",
|
|
65
|
-
"@types/escape-html": "^1.0.4",
|
|
66
71
|
"@types/lcov-parse": "1.0.2",
|
|
67
72
|
"@types/node": "^26.6.2",
|
|
68
73
|
"@types/semver": "7.8.0",
|
|
69
74
|
"ai": "7.0.137",
|
|
70
75
|
"ajv": "8.20.0",
|
|
76
|
+
"babel-preset-solid": "1.9.12",
|
|
71
77
|
"conventional-commits-parser": "7.1.3",
|
|
72
78
|
"effect": "4.0.0-rc.112",
|
|
73
79
|
"eventsource-parser": "4.1.1",
|
|
@@ -114,6 +114,8 @@ export async function runMessage(
|
|
|
114
114
|
emit: (event: MessagesStreamEvent) => void,
|
|
115
115
|
): Promise<MessageRun> {
|
|
116
116
|
const id = newId("msg")
|
|
117
|
+
// Only a non-streamed body needs the aggregate: a stream is already on the wire, so nothing grows (#153).
|
|
118
|
+
const aggregate = !exchange.stream
|
|
117
119
|
const content: Block[] = []
|
|
118
120
|
/** Blocks opened so far: the next block's `index`. */
|
|
119
121
|
let nextIndex = 0
|
|
@@ -155,7 +157,7 @@ export async function runMessage(
|
|
|
155
157
|
emit({ type: "content_block_stop", index: open.index })
|
|
156
158
|
// The signature is always empty: the host does not expose the provider's thinking signature (#146), so a
|
|
157
159
|
// replayed thinking block cannot carry the real one. Clients that replay thinking are told by the docs.
|
|
158
|
-
content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
|
|
160
|
+
if (aggregate) content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
|
|
159
161
|
open = undefined
|
|
160
162
|
}
|
|
161
163
|
const releaseHeld = () => delta("text", holdback.release())
|
|
@@ -173,7 +175,7 @@ export async function runMessage(
|
|
|
173
175
|
})
|
|
174
176
|
ping()
|
|
175
177
|
}
|
|
176
|
-
open.text += text
|
|
178
|
+
if (aggregate) open.text += text
|
|
177
179
|
emit({
|
|
178
180
|
type: "content_block_delta",
|
|
179
181
|
index: open.index,
|
|
@@ -192,7 +194,7 @@ export async function runMessage(
|
|
|
192
194
|
ping()
|
|
193
195
|
emit({ type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: JSON.stringify(input) } })
|
|
194
196
|
emit({ type: "content_block_stop", index })
|
|
195
|
-
content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
|
|
197
|
+
if (aggregate) content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
|
|
196
198
|
calls++
|
|
197
199
|
}
|
|
198
200
|
|