opencode-plugboard 0.6.0 → 0.7.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 CHANGED
@@ -4,7 +4,7 @@
4
4
  [![license](https://img.shields.io/npm/l/opencode-plugboard)](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
5
5
  [![CI](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml/badge.svg)](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
6
6
 
7
- An OpenCode V2 plugin that serves an OpenAI-compatible API from your OpenCode server, so your other apps reuse the provider logins you already made in OpenCode.
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`, `/v1/responses` and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
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`) 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.6.0",
33
+ "package": "opencode-plugboard@0.7.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"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `POST /v1/messages` (and a clear 404 for `POST /v1/messages/count_tokens`): the Anthropic
3
+ * Messages front-end's half of the route. `src/openai/route.ts` hands every request for these
4
+ * paths over before it does anything else, because this route differs from the OpenAI ones in
5
+ * three ways: errors use Anthropic's body shape from the first check on, `x-api-key` authenticates
6
+ * as well as `Authorization: Bearer`, and there is no `[DONE]`.
7
+ *
8
+ * Same flow as the other front-ends: origin check, authenticate, parse, resolve the model, call
9
+ * the ModelCaller, encode. Nothing is committed until the encoder emits its first event, so a
10
+ * failure before any output is a real HTTP error even when the client asked for a stream.
11
+ * `anthropic-version` and `anthropic-beta` are accepted and ignored.
12
+ */
13
+
14
+ import type { IncomingMessage, ServerResponse } from "node:http"
15
+ import { authenticate } from "../config.js"
16
+ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
17
+ import {
18
+ BodyError,
19
+ RequestAborted,
20
+ boundEvents,
21
+ checkJsonContentType,
22
+ checkOrigin,
23
+ readJsonBody,
24
+ sendJson,
25
+ sseWriter,
26
+ type RequestScope,
27
+ } from "../http.js"
28
+ import { resolveModel } from "../models.js"
29
+ import { dropRejected } from "../openai/sampling.js"
30
+ import type { RouteHost } from "../openai/route.js"
31
+ import { parseMessagesRequest } from "./request.js"
32
+ import { encodeError, runMessage, sseFrame } from "./response.js"
33
+
34
+ const SESSION_HEADER = "x-plugboard-session"
35
+ const DROPPED_HEADER = "x-plugboard-dropped-params"
36
+ const MESSAGES = "/v1/messages"
37
+ const COUNT_TOKENS = "/v1/messages/count_tokens"
38
+
39
+ /** Paths this front-end owns, whatever the method. */
40
+ export const isMessagesPath = (path: string) => path === MESSAGES || path === COUNT_TOKENS
41
+
42
+ export interface MessagesCall {
43
+ readonly path: string
44
+ readonly request: IncomingMessage
45
+ readonly response: ServerResponse
46
+ readonly scope: RequestScope
47
+ /** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
48
+ readonly info: { route: string; app?: string; model?: string; failed?: string; dropped?: string }
49
+ }
50
+
51
+ const header = (request: IncomingMessage, name: string): string | undefined => {
52
+ const value = request.headers[name]
53
+ return (Array.isArray(value) ? value[0] : value)?.trim() || undefined
54
+ }
55
+
56
+ /** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
57
+ const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
58
+ const bearer = authenticate(host.options, request.headers.authorization)
59
+ const apiKey = header(request, "x-api-key")
60
+ return isPlugError(bearer) && apiKey ? authenticate(host.options, `Bearer ${apiKey}`) : bearer
61
+ }
62
+
63
+ export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
64
+ const { path, request, response, scope, info } = call
65
+ const fail = (error: PlugError, close = false) => sendJson(response, error.status, encodeError(error), { close })
66
+ if (request.method === "POST") info.route = `POST ${path}`
67
+ response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
68
+
69
+ try {
70
+ await serve()
71
+ } catch (caught) {
72
+ // Answer in this route's error shape, then rethrow: the shared handler logs the outcome and
73
+ // finds the response already sent.
74
+ if (!response.headersSent) {
75
+ if (caught instanceof BodyError) fail({ status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
76
+ else if (caught instanceof RequestAborted) {
77
+ if (caught.kind === "timeout") fail(scope.timeoutError, true)
78
+ } else fail({ status: 500, kind: "server", message: "Internal server error" })
79
+ }
80
+ throw caught
81
+ }
82
+
83
+ async function serve(): Promise<void> {
84
+ if (request.method !== "POST")
85
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
86
+ const rejected = checkOrigin(request, host.options)
87
+ if (rejected) return fail(rejected)
88
+ const auth = authenticateRequest(host, request)
89
+ if (isPlugError(auth)) return fail(auth)
90
+ info.app = auth.app
91
+ if (path === COUNT_TOKENS)
92
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
93
+ const unsupported = checkJsonContentType(request)
94
+ if (unsupported) return fail(unsupported)
95
+
96
+ const body = await readJsonBody(request, scope.signal)
97
+ const parsed = parseMessagesRequest(body, host.options)
98
+ if (isPlugError(parsed)) return fail(parsed)
99
+ const catalog = await scope.race(host.loadCatalog())
100
+ const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
101
+ if (isPlugError(ref)) return fail(ref)
102
+ info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
103
+ // Sampling parameters the model is known to reject are dropped; `top_k` has no neutral
104
+ // equivalent and is always dropped. Both are reported before any response bytes.
105
+ const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.request.generation) : undefined
106
+ const dropped = [...(sampled?.dropped ?? []), ...(parsed.topKIgnored ? ["top_k"] : [])]
107
+ if (dropped.length > 0) {
108
+ info.dropped = dropped.join(",")
109
+ response.setHeader(DROPPED_HEADER, info.dropped)
110
+ }
111
+ const model: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
112
+ const { exchange } = parsed
113
+
114
+ // Abandoned requests (client gone, deadline passed) must never reach the model caller.
115
+ if (scope.signal.aborted) throw scope.signal.reason
116
+ const events = boundEvents(
117
+ host.caller.stream(model, {
118
+ app: auth.app,
119
+ signal: scope.signal,
120
+ onRecord: (recordID) => {
121
+ if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
122
+ response.setHeader(SESSION_HEADER, recordID)
123
+ },
124
+ }),
125
+ scope,
126
+ )
127
+
128
+ if (exchange.stream) {
129
+ const sse = sseWriter(response, scope.signal)
130
+ const { failure, started } = await runMessage(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
131
+ if (!sse.started && !response.headersSent) {
132
+ if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
133
+ } else {
134
+ if (failure) {
135
+ info.failed = failure.code ?? failure.kind
136
+ // Committed by a keepalive before the encoder wrote anything: the encoder left the error for us.
137
+ if (!started && !response.writableEnded) sse.write(sseFrame(encodeError(failure)))
138
+ }
139
+ if (!response.writableEnded) response.end()
140
+ }
141
+ return
142
+ }
143
+
144
+ const run = await runMessage(events, exchange, () => {})
145
+ if (run.failure || !run.message) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
146
+ sendJson(response, 200, run.message)
147
+ }
148
+ }
@@ -38,6 +38,7 @@ import {
38
38
  import { errorMessage, log, logError, withRequestId } from "../log.js"
39
39
  import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
40
40
  import { listAvailableModels, resolveModel } from "../models.js"
41
+ import { isMessagesPath, serveMessages } from "../anthropic/route.js"
41
42
  import { serveResponses } from "../responses/route.js"
42
43
  import { processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
43
44
  import { parseChatRequest } from "./request.js"
@@ -135,6 +136,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
135
136
  const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
136
137
  // Arbitrary client paths stay out of the log.
137
138
  info.route = modelId !== undefined || known(route) || route === "GET /healthz" ? route : "unknown"
139
+ // Anthropic Messages: its own error shape and x-api-key auth, so it takes the request from here.
140
+ if (isMessagesPath(path)) return serveMessages(host, { path, request, response, scope, info })
138
141
 
139
142
  const rejected = checkOrigin(request, host.options)
140
143
  if (rejected) return fail(response, rejected)