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 +3 -3
- 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/openai/route.ts +3 -0
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`) 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.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.
|
|
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
|
+
}
|
package/src/openai/route.ts
CHANGED
|
@@ -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)
|