opencode-plugboard 0.6.0 → 0.8.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 +4 -4
- package/package.json +1 -1
- package/src/anthropic/request.ts +343 -0
- package/src/anthropic/response.ts +221 -0
- package/src/anthropic/route.ts +148 -0
- package/src/completions/request.ts +103 -0
- package/src/completions/response.ts +147 -0
- package/src/completions/route.ts +54 -0
- package/src/config.ts +5 -1
- package/src/openai/fanout.ts +278 -0
- package/src/openai/request.ts +15 -5
- package/src/openai/response.ts +35 -7
- package/src/openai/route.ts +12 -2
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
|
|
5
5
|
[](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
An OpenCode V2 plugin that
|
|
7
|
+
An OpenCode V2 plugin that turns your OpenCode server into an OpenAI- and Anthropic-compatible API, so every other app reuses the provider logins you already made in OpenCode. Point Claude Code at a GPT model, or n8n at your Claude subscription.
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
client ──▶ plugboard listener ──▶ OpenCode session ──▶ provider
|
|
@@ -12,7 +12,7 @@ client ──▶ plugboard listener ──▶ OpenCode session ──▶ provide
|
|
|
12
12
|
└──── reply; tool calls go back to the caller ◀──────────┘
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves `/v1/chat/completions
|
|
15
|
+
Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`), legacy completions (`/v1/completions`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
|
|
16
16
|
|
|
17
17
|
## Quick start
|
|
18
18
|
|
|
@@ -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.8.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"],
|
|
@@ -77,7 +77,7 @@ Plugboard is early (0.x) and narrow on purpose.
|
|
|
77
77
|
|
|
78
78
|
- **V2 only.** OpenCode V2 has no OpenAI endpoint, and the V1 plugins that had one stopped working when the plugin API changed. V1 is not planned.
|
|
79
79
|
- **Two formats:** Chat Completions and Responses. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md). Anthropic Messages and Gemini front-ends are on the [roadmap](#roadmap).
|
|
80
|
-
- **Not planned:** embeddings, audio, image generation
|
|
80
|
+
- **Not planned:** embeddings, audio, image generation, `logprobs`.
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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-compatible Chat Completions, Responses and model endpoints.",
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages request -> neutral `PlugRequest` plus the `MessagesExchange` the response
|
|
3
|
+
* encoder needs.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: no I/O. Stateless like the other front-ends: the caller sends the whole
|
|
6
|
+
* conversation in `messages`. Server tools (web search, code execution, computer use, ...) run on
|
|
7
|
+
* Anthropic's side and plugboard has no equivalent, so they are rejected with a 400
|
|
8
|
+
* `invalid_request_error` naming the field. The route resolves `model` to a `ModelRef`
|
|
9
|
+
* afterwards, which is why the request omits `ref`.
|
|
10
|
+
*/
|
|
11
|
+
import type { PlugboardOptions } from "../config.js"
|
|
12
|
+
import {
|
|
13
|
+
isPlugError,
|
|
14
|
+
type PlugError,
|
|
15
|
+
type PlugGeneration,
|
|
16
|
+
type PlugMessage,
|
|
17
|
+
type PlugPart,
|
|
18
|
+
type PlugRequest,
|
|
19
|
+
type PlugTool,
|
|
20
|
+
type PlugToolChoice,
|
|
21
|
+
} from "../contract.js"
|
|
22
|
+
import type { MessagesExchange } from "./response.js"
|
|
23
|
+
|
|
24
|
+
type JsonObject = Record<string, unknown>
|
|
25
|
+
|
|
26
|
+
export interface ParsedMessages {
|
|
27
|
+
/** The model call, minus the model ref the route resolves from `request.model`. */
|
|
28
|
+
readonly request: Omit<PlugRequest, "ref">
|
|
29
|
+
/** Response presentation state for the encoder; never reaches the model call. */
|
|
30
|
+
readonly exchange: MessagesExchange
|
|
31
|
+
/** Variant name to try on the model (from `output_config.effort` or `thinking.budget_tokens`). */
|
|
32
|
+
readonly reasoningEffort?: string
|
|
33
|
+
/** `top_k` was sent; plugboard has no such sampling option, so it is ignored and reported. */
|
|
34
|
+
readonly topKIgnored: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isRecord = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
38
|
+
|
|
39
|
+
const bad = (message: string): PlugError => ({ status: 400, kind: "invalid_request", message })
|
|
40
|
+
|
|
41
|
+
/** Anthropic's own minimum for `thinking.budget_tokens`. */
|
|
42
|
+
const MIN_THINKING_BUDGET = 1024
|
|
43
|
+
|
|
44
|
+
/** Budget ranges -> the variant names OpenCode models commonly offer (`low`, `medium`, `high`). Unknown names are ignored by the resolver. */
|
|
45
|
+
const effortOfBudget = (budget: number) => (budget <= 4096 ? "low" : budget <= 16384 ? "medium" : "high")
|
|
46
|
+
|
|
47
|
+
const MEDIA_TYPE = /^[\w.+-]+\/[\w.+-]+$/
|
|
48
|
+
|
|
49
|
+
/** A base64 or url `source` of an image or document block -> media part. */
|
|
50
|
+
const mediaPart = (block: JsonObject, at: string, what: "image" | "document"): PlugPart | PlugError => {
|
|
51
|
+
const source = block.source
|
|
52
|
+
if (!isRecord(source)) return bad(`${at}.source: Field required`)
|
|
53
|
+
if (source.type === "base64") {
|
|
54
|
+
if (typeof source.media_type !== "string" || !MEDIA_TYPE.test(source.media_type)) return bad(`${at}.source.media_type: must be a media type such as image/png`)
|
|
55
|
+
if (typeof source.data !== "string") return bad(`${at}.source.data: must be a base64 string`)
|
|
56
|
+
return { type: "media", url: `data:${source.media_type};base64,${source.data}`, mediaType: source.media_type }
|
|
57
|
+
}
|
|
58
|
+
if (source.type === "url") {
|
|
59
|
+
if (typeof source.url !== "string" || source.url.length === 0) return bad(`${at}.source.url: must be a non-empty string`)
|
|
60
|
+
return { type: "media", url: source.url }
|
|
61
|
+
}
|
|
62
|
+
if (what === "document" && source.type === "text") {
|
|
63
|
+
if (typeof source.data !== "string") return bad(`${at}.source.data: must be a string`)
|
|
64
|
+
return { type: "text", text: source.data }
|
|
65
|
+
}
|
|
66
|
+
return bad(`${at}.source.type: "${String(source.type)}" is not supported for ${what} blocks (use base64 or url)`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Blocks that carry nothing plugboard can replay or act on. */
|
|
70
|
+
const IGNORED = new Set(["thinking", "redacted_thinking"])
|
|
71
|
+
|
|
72
|
+
/** Text of a tool_result `content`: a string, or text blocks; image blocks are returned separately. */
|
|
73
|
+
const toolResultContent = (content: unknown, at: string): { text: string; media: PlugPart[] } | PlugError => {
|
|
74
|
+
if (content == null) return { text: "", media: [] }
|
|
75
|
+
if (typeof content === "string") return { text: content, media: [] }
|
|
76
|
+
if (!Array.isArray(content)) return bad(`${at}.content: must be a string or an array of content blocks`)
|
|
77
|
+
const texts: string[] = []
|
|
78
|
+
const media: PlugPart[] = []
|
|
79
|
+
for (const [index, block] of content.entries()) {
|
|
80
|
+
const here = `${at}.content.${index}`
|
|
81
|
+
if (!isRecord(block)) return bad(`${here}: must be an object`)
|
|
82
|
+
if (block.type === "text" && typeof block.text === "string") texts.push(block.text)
|
|
83
|
+
else if (block.type === "image" || block.type === "document") {
|
|
84
|
+
const part = mediaPart(block, here, block.type)
|
|
85
|
+
if (isPlugError(part)) return part
|
|
86
|
+
if (part.type === "text") texts.push(part.text)
|
|
87
|
+
else media.push(part)
|
|
88
|
+
} else return bad(`${here}.type: "${String(block.type)}" is not supported in tool_result content (use text or image)`)
|
|
89
|
+
}
|
|
90
|
+
return { text: texts.join("\n"), media }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Plain content blocks (text, image, document) of a user message, in order. */
|
|
94
|
+
const userBlock = (block: JsonObject, at: string): PlugPart | undefined | PlugError => {
|
|
95
|
+
switch (block.type) {
|
|
96
|
+
case "text":
|
|
97
|
+
if (typeof block.text !== "string") return bad(`${at}.text: must be a string`)
|
|
98
|
+
return block.text.length > 0 ? { type: "text", text: block.text } : undefined
|
|
99
|
+
case "image":
|
|
100
|
+
case "document":
|
|
101
|
+
return mediaPart(block, at, block.type)
|
|
102
|
+
default:
|
|
103
|
+
return bad(`${at}.type: "${String(block.type)}" is not supported in user messages (use text, image, document or tool_result)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parseMessages = (raw: unknown): PlugMessage[] | PlugError => {
|
|
108
|
+
if (raw == null) return bad("messages: Field required")
|
|
109
|
+
if (!Array.isArray(raw) || raw.length === 0) return bad("messages: must be a non-empty array")
|
|
110
|
+
const messages: PlugMessage[] = []
|
|
111
|
+
const toolNames = new Map<string, string>()
|
|
112
|
+
|
|
113
|
+
for (const [index, message] of raw.entries()) {
|
|
114
|
+
const at = `messages.${index}`
|
|
115
|
+
if (!isRecord(message)) return bad(`${at}: must be an object`)
|
|
116
|
+
if (message.role !== "user" && message.role !== "assistant") return bad(`${at}.role: must be "user" or "assistant" (put system prompts in the top-level system field)`)
|
|
117
|
+
const content = message.content
|
|
118
|
+
if (content == null) return bad(`${at}.content: Field required`)
|
|
119
|
+
const blocks: unknown[] = typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : []
|
|
120
|
+
if (typeof content !== "string" && !Array.isArray(content)) return bad(`${at}.content: must be a string or an array of content blocks`)
|
|
121
|
+
|
|
122
|
+
if (message.role === "assistant") {
|
|
123
|
+
const parts: PlugPart[] = []
|
|
124
|
+
for (const [i, block] of blocks.entries()) {
|
|
125
|
+
const here = `${at}.content.${i}`
|
|
126
|
+
if (!isRecord(block)) return bad(`${here}: must be an object`)
|
|
127
|
+
if (typeof block.type === "string" && IGNORED.has(block.type)) continue
|
|
128
|
+
if (block.type === "text") {
|
|
129
|
+
if (typeof block.text !== "string") return bad(`${here}.text: must be a string`)
|
|
130
|
+
if (block.text.length > 0) parts.push({ type: "text", text: block.text })
|
|
131
|
+
} else if (block.type === "tool_use") {
|
|
132
|
+
if (typeof block.id !== "string" || block.id.length === 0) return bad(`${here}.id: Field required`)
|
|
133
|
+
if (typeof block.name !== "string" || block.name.length === 0) return bad(`${here}.name: Field required`)
|
|
134
|
+
if (block.input != null && !isRecord(block.input)) return bad(`${here}.input: must be an object`)
|
|
135
|
+
toolNames.set(block.id, block.name)
|
|
136
|
+
parts.push({ type: "tool-call", id: block.id, name: block.name, input: block.input ?? {} })
|
|
137
|
+
} else return bad(`${here}.type: "${String(block.type)}" is not supported in assistant messages (use text or tool_use)`)
|
|
138
|
+
}
|
|
139
|
+
if (parts.length > 0) messages.push({ role: "assistant", content: parts })
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A user turn: text, images and documents, plus tool_result blocks that answer the previous
|
|
144
|
+
// assistant turn. Each result becomes its own `tool` message; images inside results (which a
|
|
145
|
+
// tool message cannot carry) follow the results as user media.
|
|
146
|
+
let user: PlugPart[] = []
|
|
147
|
+
let hoisted: PlugPart[] = []
|
|
148
|
+
const flushUser = () => {
|
|
149
|
+
if (user.length > 0) messages.push({ role: "user", content: user })
|
|
150
|
+
user = []
|
|
151
|
+
}
|
|
152
|
+
const takeHoisted = () => {
|
|
153
|
+
if (hoisted.length === 0) return
|
|
154
|
+
user.push({ type: "text", text: "Images returned by the tool calls above:" }, ...hoisted)
|
|
155
|
+
hoisted = []
|
|
156
|
+
}
|
|
157
|
+
for (const [i, block] of blocks.entries()) {
|
|
158
|
+
const here = `${at}.content.${i}`
|
|
159
|
+
if (!isRecord(block)) return bad(`${here}: must be an object`)
|
|
160
|
+
if (block.type === "tool_result") {
|
|
161
|
+
if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) return bad(`${here}.tool_use_id: Field required`)
|
|
162
|
+
const name = toolNames.get(block.tool_use_id)
|
|
163
|
+
if (name === undefined) return bad(`${here}.tool_use_id: "${block.tool_use_id}" matches no tool_use block in an earlier assistant message`)
|
|
164
|
+
const result = toolResultContent(block.content, here)
|
|
165
|
+
if (isPlugError(result)) return result
|
|
166
|
+
flushUser()
|
|
167
|
+
messages.push({
|
|
168
|
+
role: "tool",
|
|
169
|
+
content: [{ type: "tool-result", id: block.tool_use_id, name, result: result.text, ...(block.is_error === true ? { isError: true } : {}) }],
|
|
170
|
+
})
|
|
171
|
+
hoisted.push(...result.media)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
takeHoisted()
|
|
175
|
+
const part = userBlock(block, here)
|
|
176
|
+
if (isPlugError(part)) return part
|
|
177
|
+
if (part) user.push(part)
|
|
178
|
+
}
|
|
179
|
+
takeHoisted()
|
|
180
|
+
flushUser()
|
|
181
|
+
}
|
|
182
|
+
if (messages.length === 0) return bad("messages: no non-empty content")
|
|
183
|
+
return messages
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `system`: a string or text blocks (`cache_control` is accepted and ignored). */
|
|
187
|
+
const parseSystem = (raw: unknown): string[] | PlugError => {
|
|
188
|
+
if (raw == null) return []
|
|
189
|
+
if (typeof raw === "string") return raw.length > 0 ? [raw] : []
|
|
190
|
+
if (!Array.isArray(raw)) return bad("system: must be a string or an array of text blocks")
|
|
191
|
+
const system: string[] = []
|
|
192
|
+
for (const [index, block] of raw.entries()) {
|
|
193
|
+
if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string") return bad(`system.${index}: must be a text block`)
|
|
194
|
+
if (block.text.length > 0) system.push(block.text)
|
|
195
|
+
}
|
|
196
|
+
return system
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const parseTools = (raw: unknown): PlugTool[] | PlugError => {
|
|
200
|
+
if (raw == null) return []
|
|
201
|
+
if (!Array.isArray(raw)) return bad("tools: must be an array")
|
|
202
|
+
const tools: PlugTool[] = []
|
|
203
|
+
const names = new Set<string>()
|
|
204
|
+
for (const [index, entry] of raw.entries()) {
|
|
205
|
+
const at = `tools.${index}`
|
|
206
|
+
if (!isRecord(entry)) return bad(`${at}: must be an object`)
|
|
207
|
+
if (entry.type != null && entry.type !== "custom")
|
|
208
|
+
return bad(
|
|
209
|
+
`${at}.type: "${String(entry.type)}" is not supported. Only custom tools with an input_schema are: server tools (web_search, web_fetch, code_execution, ...) run on Anthropic's side, and computer use, bash and text editor tools are not available through plugboard`,
|
|
210
|
+
)
|
|
211
|
+
if (typeof entry.name !== "string" || entry.name.length === 0) return bad(`${at}.name: Field required`)
|
|
212
|
+
if (names.has(entry.name)) return bad(`${at}.name: duplicate tool name "${entry.name}"`)
|
|
213
|
+
if (entry.description != null && typeof entry.description !== "string") return bad(`${at}.description: must be a string`)
|
|
214
|
+
if (!isRecord(entry.input_schema)) return bad(`${at}.input_schema: Field required (a JSON Schema object)`)
|
|
215
|
+
names.add(entry.name)
|
|
216
|
+
tools.push({ name: entry.name, description: typeof entry.description === "string" ? entry.description : "", parameters: entry.input_schema })
|
|
217
|
+
}
|
|
218
|
+
return tools
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError => {
|
|
222
|
+
if (raw == null) return undefined
|
|
223
|
+
if (!isRecord(raw)) return bad("tool_choice: must be an object")
|
|
224
|
+
switch (raw.type) {
|
|
225
|
+
case "auto":
|
|
226
|
+
return "auto"
|
|
227
|
+
case "any":
|
|
228
|
+
return "required"
|
|
229
|
+
case "none":
|
|
230
|
+
return "none"
|
|
231
|
+
case "tool":
|
|
232
|
+
if (typeof raw.name !== "string" || raw.name.length === 0) return bad("tool_choice.name: Field required")
|
|
233
|
+
return { type: "tool", name: raw.name }
|
|
234
|
+
default:
|
|
235
|
+
return bad(`tool_choice.type: must be one of auto, any, tool, none`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const parseGeneration = (body: JsonObject): PlugGeneration | PlugError => {
|
|
240
|
+
const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = {}
|
|
241
|
+
if (typeof body.max_tokens !== "number" || !Number.isInteger(body.max_tokens)) {
|
|
242
|
+
return bad(body.max_tokens == null ? "max_tokens: Field required" : "max_tokens: must be an integer")
|
|
243
|
+
}
|
|
244
|
+
if (body.max_tokens < 1) return bad("max_tokens: must be at least 1")
|
|
245
|
+
out.maxTokens = body.max_tokens
|
|
246
|
+
for (const [key, field] of [
|
|
247
|
+
["temperature", "temperature"],
|
|
248
|
+
["top_p", "topP"],
|
|
249
|
+
] as const) {
|
|
250
|
+
const value = body[key]
|
|
251
|
+
if (value == null) continue
|
|
252
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return bad(`${key}: must be a number`)
|
|
253
|
+
if (value < 0 || value > 1) return bad(`${key}: must be between 0 and 1`)
|
|
254
|
+
out[field] = value
|
|
255
|
+
}
|
|
256
|
+
if (body.top_k != null && (typeof body.top_k !== "number" || !Number.isInteger(body.top_k) || body.top_k < 0)) return bad("top_k: must be a non-negative integer")
|
|
257
|
+
if (body.stop_sequences != null) {
|
|
258
|
+
if (!Array.isArray(body.stop_sequences) || body.stop_sequences.some((s) => typeof s !== "string" || s.length === 0))
|
|
259
|
+
return bad("stop_sequences: must be an array of non-empty strings")
|
|
260
|
+
if (body.stop_sequences.length > 0) out.stop = body.stop_sequences as string[]
|
|
261
|
+
}
|
|
262
|
+
return out
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `thinking` and `output_config.effort` -> whether thinking blocks are wanted and a variant name to try. */
|
|
266
|
+
const parseThinking = (body: JsonObject, maxTokens: number): { enabled: boolean; effort?: string } | PlugError => {
|
|
267
|
+
let enabled = false
|
|
268
|
+
let effort: string | undefined
|
|
269
|
+
const thinking = body.thinking
|
|
270
|
+
if (thinking != null) {
|
|
271
|
+
if (!isRecord(thinking)) return bad("thinking: must be an object")
|
|
272
|
+
if (thinking.type === "enabled") {
|
|
273
|
+
const budget = thinking.budget_tokens
|
|
274
|
+
if (typeof budget !== "number" || !Number.isInteger(budget)) return bad("thinking.budget_tokens: Field required (an integer)")
|
|
275
|
+
if (budget < MIN_THINKING_BUDGET) return bad(`thinking.budget_tokens: must be at least ${MIN_THINKING_BUDGET}`)
|
|
276
|
+
if (budget >= maxTokens) return bad("thinking.budget_tokens: must be less than max_tokens")
|
|
277
|
+
enabled = true
|
|
278
|
+
effort = effortOfBudget(budget)
|
|
279
|
+
} else if (thinking.type === "adaptive") enabled = true
|
|
280
|
+
else if (thinking.type !== "disabled") return bad('thinking.type: must be "enabled", "adaptive" or "disabled"')
|
|
281
|
+
}
|
|
282
|
+
const config = body.output_config
|
|
283
|
+
if (config != null) {
|
|
284
|
+
if (!isRecord(config)) return bad("output_config: must be an object")
|
|
285
|
+
if (config.format != null) return bad("output_config.format: structured outputs are not supported")
|
|
286
|
+
if (config.effort != null) {
|
|
287
|
+
if (typeof config.effort !== "string" || config.effort.length === 0) return bad("output_config.effort: must be a string")
|
|
288
|
+
effort = config.effort
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { enabled, ...(effort ? { effort } : {}) }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function parseMessagesRequest(body: unknown, options: PlugboardOptions): ParsedMessages | PlugError {
|
|
295
|
+
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
296
|
+
if (typeof body.model !== "string" || body.model.length === 0) return bad("model: Field required")
|
|
297
|
+
if (body.stream != null && typeof body.stream !== "boolean") return bad("stream: must be a boolean")
|
|
298
|
+
if (Array.isArray(body.mcp_servers) && body.mcp_servers.length > 0) return bad("mcp_servers: not supported (MCP connectors run on Anthropic's side)")
|
|
299
|
+
|
|
300
|
+
const generation = parseGeneration(body)
|
|
301
|
+
if (isPlugError(generation)) return generation
|
|
302
|
+
// Claude Code sends a `system` role message for models it doesn't know (any non-Claude id), which the real API
|
|
303
|
+
// would reject. Lift those into the system prompt so any OpenCode model works behind Claude Code.
|
|
304
|
+
const rawMessages: unknown = body.messages
|
|
305
|
+
const lifted = Array.isArray(rawMessages) ? rawMessages.filter((m) => isRecord(m) && m.role === "system") : []
|
|
306
|
+
const messages = parseMessages(Array.isArray(rawMessages) && lifted.length > 0 ? rawMessages.filter((m) => !lifted.includes(m)) : rawMessages)
|
|
307
|
+
if (isPlugError(messages)) return messages
|
|
308
|
+
const base = parseSystem(body.system)
|
|
309
|
+
if (isPlugError(base)) return base
|
|
310
|
+
const extra: string[] = []
|
|
311
|
+
for (const m of lifted) {
|
|
312
|
+
const s = parseSystem((m as Record<string, unknown>).content)
|
|
313
|
+
if (isPlugError(s)) return s
|
|
314
|
+
extra.push(...s)
|
|
315
|
+
}
|
|
316
|
+
const system = [...base, ...extra]
|
|
317
|
+
const tools = parseTools(body.tools)
|
|
318
|
+
if (isPlugError(tools)) return tools
|
|
319
|
+
const toolChoice = parseToolChoice(body.tool_choice)
|
|
320
|
+
if (isPlugError(toolChoice)) return toolChoice
|
|
321
|
+
const thinking = parseThinking(body, generation.maxTokens ?? 1)
|
|
322
|
+
if (isPlugError(thinking)) return thinking
|
|
323
|
+
|
|
324
|
+
if (tools.length > options.maxTools) return bad(`tools: ${tools.length} tools; plugboard allows ${options.maxTools} (option \`maxTools\`)`)
|
|
325
|
+
if (toolChoice !== undefined && toolChoice !== "none" && toolChoice !== "auto" && tools.length === 0) return bad("tool_choice: needs at least one tool in tools")
|
|
326
|
+
if (typeof toolChoice === "object" && !tools.some((tool) => tool.name === toolChoice.name))
|
|
327
|
+
return bad(`tool_choice.name: "${toolChoice.name}" is not in tools`)
|
|
328
|
+
|
|
329
|
+
const request: Omit<PlugRequest, "ref"> = {
|
|
330
|
+
model: body.model,
|
|
331
|
+
system,
|
|
332
|
+
messages,
|
|
333
|
+
tools,
|
|
334
|
+
...(toolChoice && tools.length > 0 ? { toolChoice } : {}),
|
|
335
|
+
generation,
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
request,
|
|
339
|
+
exchange: { model: body.model, stream: body.stream === true, thinking: thinking.enabled },
|
|
340
|
+
...(thinking.effort ? { reasoningEffort: thinking.effort } : {}),
|
|
341
|
+
topKIgnored: body.top_k != null,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral results -> Anthropic Messages wire objects: one message object, or the named SSE
|
|
3
|
+
* events of a stream, plus the Anthropic error body.
|
|
4
|
+
*
|
|
5
|
+
* One state machine (`runMessage`) serves both: it emits stream events as it goes and returns the
|
|
6
|
+
* final message, so the JSON body and the stream never disagree. A non-streaming call passes a
|
|
7
|
+
* no-op `emit`. Thinking blocks are produced only when the request asked for thinking.
|
|
8
|
+
*/
|
|
9
|
+
import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
10
|
+
|
|
11
|
+
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
12
|
+
|
|
13
|
+
/** What the encoder needs besides the model events: Messages presentation state. The request decoder produces it. */
|
|
14
|
+
export interface MessagesExchange {
|
|
15
|
+
/** The model string the client sent; echoed in the message object. */
|
|
16
|
+
readonly model: string
|
|
17
|
+
readonly stream: boolean
|
|
18
|
+
/** `thinking` was enabled or adaptive: reasoning text is returned as thinking blocks. */
|
|
19
|
+
readonly thinking: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A stream event as emitted: `type` is also the SSE event name. */
|
|
23
|
+
export type MessagesStreamEvent = { readonly type: string } & Record<string, unknown>
|
|
24
|
+
|
|
25
|
+
/** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
26
|
+
export interface MessageRun {
|
|
27
|
+
readonly failure?: PlugError
|
|
28
|
+
/** Whether `message_start` was emitted (a streamed response is committed as a 200 SSE stream). */
|
|
29
|
+
readonly started: boolean
|
|
30
|
+
/** The final message object. */
|
|
31
|
+
readonly message?: object
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Block = Record<string, unknown>
|
|
35
|
+
|
|
36
|
+
const newId = (prefix: string) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`
|
|
37
|
+
|
|
38
|
+
/** Host call ids are not always Anthropic-shaped; `toolu_` plus the id with unsafe characters replaced. */
|
|
39
|
+
const toolId = (id: string) => (/^toolu_[A-Za-z0-9_-]+$/.test(id) ? id : `toolu_${id.replace(/[^A-Za-z0-9_-]/g, "_")}`)
|
|
40
|
+
|
|
41
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
42
|
+
|
|
43
|
+
/** Tool input must be a JSON object; a string is parsed, anything else becomes `{}`. */
|
|
44
|
+
const toolInput = (input: unknown): Record<string, unknown> => {
|
|
45
|
+
if (isRecord(input)) return input
|
|
46
|
+
if (typeof input === "string") {
|
|
47
|
+
try {
|
|
48
|
+
const parsed: unknown = JSON.parse(input)
|
|
49
|
+
if (isRecord(parsed)) return parsed
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
return {}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Anthropic's `input_tokens` excludes cached tokens, which are reported on their own, while
|
|
57
|
+
* `PlugUsage.inputTokens` includes them.
|
|
58
|
+
*/
|
|
59
|
+
const encodeUsage = (usage: PlugUsage | undefined) => {
|
|
60
|
+
const read = usage?.cacheReadTokens ?? 0
|
|
61
|
+
const write = usage?.cacheWriteTokens ?? 0
|
|
62
|
+
return {
|
|
63
|
+
input_tokens: Math.max(0, (usage?.inputTokens ?? 0) - read - write),
|
|
64
|
+
output_tokens: usage?.outputTokens ?? 0,
|
|
65
|
+
cache_creation_input_tokens: write,
|
|
66
|
+
cache_read_input_tokens: read,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const STOP_REASON: Record<Exclude<PlugFinish, "error">, string> = {
|
|
71
|
+
stop: "end_turn",
|
|
72
|
+
length: "max_tokens",
|
|
73
|
+
"tool-calls": "tool_use",
|
|
74
|
+
"content-filter": "refusal",
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
|
|
78
|
+
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
79
|
+
|
|
80
|
+
/** Anthropic's error `type` for a neutral error. */
|
|
81
|
+
const errorType = (error: PlugError): string => {
|
|
82
|
+
if (error.status === 413) return "request_too_large"
|
|
83
|
+
if (error.status === 529) return "overloaded_error"
|
|
84
|
+
switch (error.kind) {
|
|
85
|
+
case "invalid_request":
|
|
86
|
+
return "invalid_request_error"
|
|
87
|
+
case "authentication":
|
|
88
|
+
return "authentication_error"
|
|
89
|
+
case "permission":
|
|
90
|
+
return "permission_error"
|
|
91
|
+
case "not_found":
|
|
92
|
+
return "not_found_error"
|
|
93
|
+
case "rate_limit":
|
|
94
|
+
return "rate_limit_error"
|
|
95
|
+
case "timeout":
|
|
96
|
+
return "timeout_error"
|
|
97
|
+
default:
|
|
98
|
+
return "api_error"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `{"type":"error","error":{"type","message"}}`: the body of every error on this route, and the data of a stream `error` event. */
|
|
103
|
+
export const encodeError = (error: PlugError) => ({ type: "error", error: { type: errorType(error), message: error.message } })
|
|
104
|
+
|
|
105
|
+
/** SSE frame of one stream event: `event: <type>` then `data: <json>`. */
|
|
106
|
+
export const sseFrame = (event: MessagesStreamEvent): string => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
|
|
107
|
+
|
|
108
|
+
export async function runMessage(
|
|
109
|
+
events: AsyncIterable<PlugEvent>,
|
|
110
|
+
exchange: MessagesExchange,
|
|
111
|
+
emit: (event: MessagesStreamEvent) => void,
|
|
112
|
+
): Promise<MessageRun> {
|
|
113
|
+
const id = newId("msg")
|
|
114
|
+
const content: Block[] = []
|
|
115
|
+
/** Blocks opened so far: the next block's `index`. */
|
|
116
|
+
let nextIndex = 0
|
|
117
|
+
let open: { kind: "text" | "thinking"; index: number; text: string } | undefined
|
|
118
|
+
let started = false
|
|
119
|
+
let pinged = false
|
|
120
|
+
let calls = 0
|
|
121
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
122
|
+
let usage: PlugUsage | undefined
|
|
123
|
+
let failure: PlugError | undefined
|
|
124
|
+
|
|
125
|
+
const message = (extra: Record<string, unknown>) => ({
|
|
126
|
+
id,
|
|
127
|
+
type: "message",
|
|
128
|
+
role: "assistant",
|
|
129
|
+
model: exchange.model,
|
|
130
|
+
content,
|
|
131
|
+
stop_reason: null,
|
|
132
|
+
stop_sequence: null,
|
|
133
|
+
usage: encodeUsage(undefined),
|
|
134
|
+
...extra,
|
|
135
|
+
})
|
|
136
|
+
const start = () => {
|
|
137
|
+
if (started) return
|
|
138
|
+
started = true
|
|
139
|
+
emit({ type: "message_start", message: message({ content: [] }) })
|
|
140
|
+
}
|
|
141
|
+
/** The first content block is followed by one `ping`, as the Anthropic API does. */
|
|
142
|
+
const ping = () => {
|
|
143
|
+
if (pinged) return
|
|
144
|
+
pinged = true
|
|
145
|
+
emit({ type: "ping" })
|
|
146
|
+
}
|
|
147
|
+
const close = () => {
|
|
148
|
+
if (!open) return
|
|
149
|
+
emit({ type: "content_block_stop", index: open.index })
|
|
150
|
+
content.push(open.kind === "text" ? { type: "text", text: open.text } : { type: "thinking", thinking: open.text, signature: "" })
|
|
151
|
+
open = undefined
|
|
152
|
+
}
|
|
153
|
+
const delta = (kind: "text" | "thinking", text: string) => {
|
|
154
|
+
if (text.length === 0) return
|
|
155
|
+
start()
|
|
156
|
+
if (open && open.kind !== kind) close()
|
|
157
|
+
if (!open) {
|
|
158
|
+
open = { kind, index: nextIndex++, text: "" }
|
|
159
|
+
emit({
|
|
160
|
+
type: "content_block_start",
|
|
161
|
+
index: open.index,
|
|
162
|
+
content_block: kind === "text" ? { type: "text", text: "" } : { type: "thinking", thinking: "", signature: "" },
|
|
163
|
+
})
|
|
164
|
+
ping()
|
|
165
|
+
}
|
|
166
|
+
open.text += text
|
|
167
|
+
emit({
|
|
168
|
+
type: "content_block_delta",
|
|
169
|
+
index: open.index,
|
|
170
|
+
delta: kind === "text" ? { type: "text_delta", text } : { type: "thinking_delta", thinking: text },
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
/** Tool calls arrive whole, so each goes out as one `input_json_delta` holding the full input. */
|
|
174
|
+
const toolCall = (call: ToolCall) => {
|
|
175
|
+
start()
|
|
176
|
+
close()
|
|
177
|
+
const index = nextIndex++
|
|
178
|
+
const toolUseId = toolId(call.id)
|
|
179
|
+
const input = toolInput(call.input)
|
|
180
|
+
emit({ type: "content_block_start", index, content_block: { type: "tool_use", id: toolUseId, name: call.name, input: {} } })
|
|
181
|
+
ping()
|
|
182
|
+
emit({ type: "content_block_delta", index, delta: { type: "input_json_delta", partial_json: JSON.stringify(input) } })
|
|
183
|
+
emit({ type: "content_block_stop", index })
|
|
184
|
+
content.push({ type: "tool_use", id: toolUseId, name: call.name, input })
|
|
185
|
+
calls++
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for await (const event of events) {
|
|
189
|
+
if (event.type === "error") {
|
|
190
|
+
failure = event.error
|
|
191
|
+
break
|
|
192
|
+
}
|
|
193
|
+
if (event.type === "finish") {
|
|
194
|
+
usage = event.usage ?? usage
|
|
195
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
196
|
+
else finish = event.reason
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
if (event.type === "reasoning-delta") {
|
|
200
|
+
if (exchange.thinking) delta("thinking", event.text)
|
|
201
|
+
} else if (event.type === "text-delta") delta("text", event.text)
|
|
202
|
+
else toolCall(event)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
failure ??= finish ? undefined : noFinish()
|
|
206
|
+
if (failure) {
|
|
207
|
+
if (!started) return { failure, started }
|
|
208
|
+
// The stream is already committed: close what is open and end with an `error` event (no `message_stop`).
|
|
209
|
+
close()
|
|
210
|
+
emit(encodeError(failure))
|
|
211
|
+
return { failure, started }
|
|
212
|
+
}
|
|
213
|
+
start()
|
|
214
|
+
close()
|
|
215
|
+
ping()
|
|
216
|
+
const reason = finish === "stop" && calls > 0 ? "tool-calls" : (finish ?? "stop")
|
|
217
|
+
const final = message({ stop_reason: STOP_REASON[reason as Exclude<PlugFinish, "error">], usage: encodeUsage(usage) })
|
|
218
|
+
emit({ type: "message_delta", delta: { stop_reason: final.stop_reason, stop_sequence: null }, usage: final.usage })
|
|
219
|
+
emit({ type: "message_stop" })
|
|
220
|
+
return { started, message: final }
|
|
221
|
+
}
|