opencode-plugboard 0.15.0 → 0.16.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 +1 -1
- package/package.json +5 -1
- package/src/anthropic/route.ts +5 -3
- package/src/completions/response.ts +2 -2
- package/src/completions/route.ts +2 -2
- package/src/contract.ts +1 -1
- package/src/discovery.ts +24 -12
- package/src/gemini/route.ts +5 -5
- package/src/http.ts +13 -2
- package/src/openai/fanout.ts +6 -6
- package/src/openai/response.ts +2 -2
- package/src/openai/route.ts +10 -4
- package/src/responses/route.ts +2 -2
package/README.md
CHANGED
|
@@ -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.16.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.16.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.",
|
|
@@ -37,10 +37,14 @@
|
|
|
37
37
|
"@ai-sdk/anthropic": "4.0.78",
|
|
38
38
|
"@ai-sdk/google": "4.0.93",
|
|
39
39
|
"@ai-sdk/openai": "4.0.91",
|
|
40
|
+
"@ai-sdk/openai-compatible": "3.0.67",
|
|
40
41
|
"@anthropic-ai/sdk": "0.133.0",
|
|
41
42
|
"@commitlint/cli": "21.2.3",
|
|
42
43
|
"@commitlint/config-conventional": "21.2.3",
|
|
43
44
|
"@google/genai": "2.28.0",
|
|
45
|
+
"@langchain/anthropic": "1.5.12",
|
|
46
|
+
"@langchain/google-genai": "2.3.2",
|
|
47
|
+
"@langchain/openai": "1.6.2",
|
|
44
48
|
"@opencode/plugin": "2.0.26",
|
|
45
49
|
"@types/bun": "1.4.2",
|
|
46
50
|
"@types/escape-html": "^1.0.4",
|
package/src/anthropic/route.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
checkJsonContentType,
|
|
22
22
|
checkOrigin,
|
|
23
23
|
readJsonBody,
|
|
24
|
+
sendError,
|
|
24
25
|
sendJson,
|
|
25
26
|
sseWriter,
|
|
26
27
|
type RequestScope,
|
|
@@ -65,7 +66,7 @@ const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
|
|
|
65
66
|
|
|
66
67
|
export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
|
|
67
68
|
const { path, request, response, scope, info } = call
|
|
68
|
-
const fail = (error: PlugError, close = false) =>
|
|
69
|
+
const fail = (error: PlugError, close = false) => sendError(response, error, encodeError(error), { close })
|
|
69
70
|
if (request.method === "POST") info.route = `POST ${path}`
|
|
70
71
|
response.setHeader("request-id", `req_${crypto.randomUUID().replaceAll("-", "")}`)
|
|
71
72
|
|
|
@@ -84,13 +85,14 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
|
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
async function serve(): Promise<void> {
|
|
87
|
-
if (request.method !== "POST")
|
|
88
|
-
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
89
88
|
const rejected = checkOrigin(request, host.options)
|
|
90
89
|
if (rejected) return fail(rejected)
|
|
90
|
+
// Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
|
|
91
91
|
const auth = authenticateRequest(host, request)
|
|
92
92
|
if (isPlugError(auth)) return fail(auth)
|
|
93
93
|
info.app = auth.app
|
|
94
|
+
if (request.method !== "POST")
|
|
95
|
+
return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}; use POST` })
|
|
94
96
|
if (path === COUNT_TOKENS)
|
|
95
97
|
return fail({ status: 404, kind: "not_found", code: "not_found", message: "count_tokens is not supported by plugboard" })
|
|
96
98
|
const unsupported = checkJsonContentType(request)
|
|
@@ -44,7 +44,7 @@ const encodeUsage = (usage: PlugUsage) => {
|
|
|
44
44
|
const prefixOf = (exchange: CompletionsExchange, index: number) => exchange.echo?.[Math.floor(index / exchange.perPrompt)] ?? ""
|
|
45
45
|
|
|
46
46
|
/** One choice as a complete body: `choices[0]` carries the text, `usage` the totals. */
|
|
47
|
-
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object }> {
|
|
47
|
+
async function collectText(events: AsyncIterable<PlugEvent>, exchange: CompletionsExchange, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
|
|
48
48
|
let text = ""
|
|
49
49
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
50
50
|
let usage: PlugUsage | undefined
|
|
@@ -63,7 +63,7 @@ async function collectText(events: AsyncIterable<PlugEvent>, exchange: Completio
|
|
|
63
63
|
if (!failure && !finish) failure = noFinish()
|
|
64
64
|
if (failure || !finish) {
|
|
65
65
|
const error = failure ?? noFinish()
|
|
66
|
-
return { status: error.status, body: encodeError(error) }
|
|
66
|
+
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
67
67
|
}
|
|
68
68
|
return {
|
|
69
69
|
status: 200,
|
package/src/completions/route.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import type { ServerResponse } from "node:http"
|
|
13
13
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
14
14
|
import type { ServedInfo } from "../fallback.js"
|
|
15
|
-
import {
|
|
15
|
+
import { sendError, type RequestScope } from "../http.js"
|
|
16
16
|
import { resolveModel } from "../models.js"
|
|
17
17
|
import { serveChoices } from "../openai/fanout.js"
|
|
18
18
|
import { encodeError } from "../openai/response.js"
|
|
@@ -33,7 +33,7 @@ export interface CompletionsCall {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function serveCompletions(host: RouteHost, { app, body, response, scope, info }: CompletionsCall): Promise<void> {
|
|
36
|
-
const fail = (error: PlugError) =>
|
|
36
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
37
37
|
|
|
38
38
|
const parsed = parseCompletionsRequest(body, host.options)
|
|
39
39
|
if (isPlugError(parsed)) return fail(parsed)
|
package/src/contract.ts
CHANGED
|
@@ -153,7 +153,7 @@ export interface PlugError {
|
|
|
153
153
|
readonly message: string
|
|
154
154
|
readonly code?: string
|
|
155
155
|
readonly param?: string
|
|
156
|
-
/** Seconds to wait before a retry,
|
|
156
|
+
/** Seconds to wait before a retry, sent as the `Retry-After` header (`sendError` in `src/http.ts`). Set by the limiter (`src/limits.ts`). */
|
|
157
157
|
readonly retryAfterSeconds?: number
|
|
158
158
|
}
|
|
159
159
|
|
package/src/discovery.ts
CHANGED
|
@@ -58,18 +58,30 @@ export interface Endpoint {
|
|
|
58
58
|
readonly description: string
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Every endpoint plugboard serves, in the order the document lists them. Dispatch lives in
|
|
63
|
+
* `src/openai/route.ts` and the front-end routes; `test/http/discovery.test.ts` probes each served
|
|
64
|
+
* route and fails when this list omits one (#136). `/metrics` is added only when option `metrics` is on.
|
|
65
|
+
*/
|
|
66
|
+
export const ENDPOINTS: ReadonlyArray<Endpoint> = [
|
|
67
|
+
{ method: "GET", path: "/", auth: false, description: "This page." },
|
|
68
|
+
{ method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
|
|
69
|
+
{ method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
|
|
70
|
+
{ method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
|
|
71
|
+
{ method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
|
|
72
|
+
{ method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
|
|
73
|
+
{ method: "POST", path: "/v1/completions", auth: true, description: "Legacy OpenAI completions." },
|
|
74
|
+
{ method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
|
|
75
|
+
{ method: "POST", path: "/v1/messages", auth: true, description: "Anthropic Messages (x-api-key or Bearer)." },
|
|
76
|
+
{ method: "GET", path: "/v1beta/models", auth: true, description: "Gemini model list." },
|
|
77
|
+
{ method: "GET", path: "/v1beta/models/{model}", auth: true, description: "One Gemini model." },
|
|
78
|
+
{ method: "POST", path: "/v1beta/models/{model}:generateContent", auth: true, description: "Gemini generateContent." },
|
|
79
|
+
{ method: "POST", path: "/v1beta/models/{model}:streamGenerateContent", auth: true, description: "Gemini streamGenerateContent, with ?alt=sse." },
|
|
80
|
+
{ method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
|
|
84
|
+
return options.metrics ? [...ENDPOINTS, { method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : ENDPOINTS
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
|
package/src/gemini/route.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
18
18
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
19
19
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
20
|
-
import { boundEvents, checkJsonContentType, readJsonBody, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
20
|
+
import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
21
21
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
22
22
|
import { reportCache } from "../cache.js"
|
|
23
23
|
import { reportPolicy } from "../policy.js"
|
|
@@ -64,10 +64,10 @@ export function matchGeminiRoute(method: string | undefined, path: string): Gemi
|
|
|
64
64
|
const verb = colon > 0 ? rest.slice(colon + 1) : ""
|
|
65
65
|
const model = decode(rest.slice(0, colon))
|
|
66
66
|
if (!/^[A-Za-z]+$/.test(verb) || model === undefined) return undefined
|
|
67
|
-
const label = `POST ${prefix.slice(0, -1)}/{model}:${verb}`
|
|
68
67
|
if (verb === "generateContent" || verb === "streamGenerateContent")
|
|
69
|
-
return { op: "generate", route:
|
|
70
|
-
|
|
68
|
+
return { op: "generate", route: `POST ${prefix.slice(0, -1)}/{model}:${verb}`, model: model.startsWith("models/") ? model.slice(7) : model, stream: verb === "streamGenerateContent" }
|
|
69
|
+
// The client's verb never becomes a metric or log label: every other verb is `unknown` (#135).
|
|
70
|
+
return { op: "unsupported", route: "unknown", verb }
|
|
71
71
|
}
|
|
72
72
|
return undefined
|
|
73
73
|
}
|
|
@@ -95,7 +95,7 @@ export interface GeminiCall {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
export async function serveGemini(host: RouteHost, { match, request, url, app, response, scope, info }: GeminiCall): Promise<void> {
|
|
98
|
-
const fail = (error: PlugError) =>
|
|
98
|
+
const fail = (error: PlugError) => sendError(response, error, encodeGeminiError(error))
|
|
99
99
|
|
|
100
100
|
if (match.op === "unsupported")
|
|
101
101
|
return fail({ status: 404, kind: "not_found", code: "method_not_supported", message: `Method ${match.verb} is not supported; plugboard serves generateContent and streamGenerateContent` })
|
package/src/http.ts
CHANGED
|
@@ -165,18 +165,29 @@ export function finishRequest(request: IncomingMessage, response: ServerResponse
|
|
|
165
165
|
response.once("close", () => clearTimeout(timer))
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
/**
|
|
169
|
-
|
|
168
|
+
/**
|
|
169
|
+
* `close`: also tell the client to drop the connection (after an unread or abandoned body).
|
|
170
|
+
* `retryAfterSeconds`: sent as `Retry-After` (a rate-limit refusal, see `PlugError.retryAfterSeconds`).
|
|
171
|
+
*/
|
|
172
|
+
export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean; retryAfterSeconds?: number }) => {
|
|
170
173
|
if (response.headersSent || response.destroyed) return
|
|
171
174
|
const body = JSON.stringify(payload)
|
|
172
175
|
response.writeHead(status, {
|
|
173
176
|
"content-type": "application/json",
|
|
174
177
|
"content-length": Buffer.byteLength(body),
|
|
175
178
|
...(options?.close ? { connection: "close" } : {}),
|
|
179
|
+
...(options?.retryAfterSeconds !== undefined ? { "retry-after": String(options.retryAfterSeconds) } : {}),
|
|
176
180
|
})
|
|
177
181
|
response.end(body)
|
|
178
182
|
}
|
|
179
183
|
|
|
184
|
+
/**
|
|
185
|
+
* A `PlugError` answered in a front-end's own wire body, before any output. Every route's `fail` goes
|
|
186
|
+
* through here, so a refused call (`PlugError.retryAfterSeconds`) always carries `Retry-After`.
|
|
187
|
+
*/
|
|
188
|
+
export const sendError = (response: ServerResponse, error: PlugError, body: unknown, options?: { close?: boolean }) =>
|
|
189
|
+
sendJson(response, error.status, body, { ...options, retryAfterSeconds: error.retryAfterSeconds })
|
|
190
|
+
|
|
180
191
|
/**
|
|
181
192
|
* SSE response writer. Headers go out with the first chunk, so nothing is committed until there
|
|
182
193
|
* is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
|
package/src/openai/fanout.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type { PlugError, PlugEvent, PlugRequest, PlugUsage } from "../contract.j
|
|
|
25
25
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
26
26
|
import { reportCache } from "../cache.js"
|
|
27
27
|
import { reportPolicy } from "../policy.js"
|
|
28
|
-
import { RequestAborted, boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
28
|
+
import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
29
29
|
import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
|
|
30
30
|
import type { RouteHost } from "./route.js"
|
|
31
31
|
|
|
@@ -38,7 +38,7 @@ export interface ChoiceCodec {
|
|
|
38
38
|
/** Prefix of the shared stream id (`chatcmpl-`, `cmpl-`). */
|
|
39
39
|
readonly idPrefix: string
|
|
40
40
|
/** One choice as a complete JSON body with `choices[0]` and `usage`. */
|
|
41
|
-
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object }>
|
|
41
|
+
collect(events: AsyncIterable<PlugEvent>, index: number): Promise<{ status: number; body: object; retryAfterSeconds?: number }>
|
|
42
42
|
/** One choice of the shared stream: this choice's chunks only (see `StreamChoice`). */
|
|
43
43
|
streamChoice(events: AsyncIterable<PlugEvent>, write: (chunk: string) => void, choice: StreamChoice): Promise<StreamResult>
|
|
44
44
|
/** The final chunk with the summed usage (`include_usage`). */
|
|
@@ -155,7 +155,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
|
|
158
|
-
const fail = (error: PlugError) =>
|
|
158
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
159
159
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
160
160
|
if (scope.signal.aborted) throw scope.signal.reason
|
|
161
161
|
|
|
@@ -190,8 +190,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
190
190
|
)
|
|
191
191
|
|
|
192
192
|
if (!codec.stream) {
|
|
193
|
-
const results = await new Promise<Array<{ status: number; body: object }>>((resolve, reject) => {
|
|
194
|
-
const done: Array<{ status: number; body: object }> = []
|
|
193
|
+
const results = await new Promise<Array<{ status: number; body: object; retryAfterSeconds?: number }>>((resolve, reject) => {
|
|
194
|
+
const done: Array<{ status: number; body: object; retryAfterSeconds?: number }> = []
|
|
195
195
|
let left = calls.length
|
|
196
196
|
calls.forEach((_, index) => {
|
|
197
197
|
codec.collect(open(index), index).then(
|
|
@@ -211,7 +211,7 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
|
|
|
211
211
|
})
|
|
212
212
|
})
|
|
213
213
|
const first = results[0]!
|
|
214
|
-
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)))
|
|
214
|
+
return sendJson(response, first.status, results.length === 1 ? first.body : mergeBodies(results.map((result) => result.body)), { retryAfterSeconds: first.retryAfterSeconds })
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
const sse = sseWriter(response, scope.signal)
|
package/src/openai/response.ts
CHANGED
|
@@ -195,7 +195,7 @@ const now = () => Math.floor(Date.now() / 1000)
|
|
|
195
195
|
export async function collectCompletion(
|
|
196
196
|
events: AsyncIterable<PlugEvent>,
|
|
197
197
|
exchange: OpenAiExchange,
|
|
198
|
-
): Promise<{ status: number; body: object }> {
|
|
198
|
+
): Promise<{ status: number; body: object; retryAfterSeconds?: number }> {
|
|
199
199
|
let text = ""
|
|
200
200
|
const calls: ToolCall[] = []
|
|
201
201
|
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
@@ -217,7 +217,7 @@ export async function collectCompletion(
|
|
|
217
217
|
if (!failure && !finish) failure = noFinish()
|
|
218
218
|
if (failure || !finish) {
|
|
219
219
|
const error = failure ?? noFinish()
|
|
220
|
-
return { status: error.status, body: encodeError(error) }
|
|
220
|
+
return { status: error.status, body: encodeError(error), retryAfterSeconds: error.retryAfterSeconds }
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
const answer = calls.find((call) => structured.isAnswer(exchange, call))
|
package/src/openai/route.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
checkOrigin,
|
|
33
33
|
finishRequest,
|
|
34
34
|
readJsonBody,
|
|
35
|
+
sendError,
|
|
35
36
|
sendJson,
|
|
36
37
|
sseWriter,
|
|
37
38
|
type RequestScope,
|
|
@@ -41,7 +42,7 @@ import { encodeGeminiError } from "../gemini/response.js"
|
|
|
41
42
|
import { geminiAuthorization, matchGeminiRoute, serveGemini } from "../gemini/route.js"
|
|
42
43
|
import { errorMessage, log, logError, withRequestId } from "../log.js"
|
|
43
44
|
import { NO_APP, outcomeLabel, type Metrics } from "../metrics.js"
|
|
44
|
-
import { listAvailableModels, resolveModel } from "../models.js"
|
|
45
|
+
import { filterByCapability, listAvailableModels, parseCapability, resolveModel } from "../models.js"
|
|
45
46
|
import { reportCache } from "../cache.js"
|
|
46
47
|
import { reportPolicy } from "../policy.js"
|
|
47
48
|
import { isMessagesPath, serveMessages } from "../anthropic/route.js"
|
|
@@ -119,7 +120,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
119
120
|
/** Responses to Gemini routes: errors there use Google's body. */
|
|
120
121
|
const googleErrors = new WeakSet<ServerResponse>()
|
|
121
122
|
const fail = (response: ServerResponse, error: PlugError, close = false) =>
|
|
122
|
-
|
|
123
|
+
sendError(response, error, googleErrors.has(response) ? encodeGeminiError(error) : encodeError(error), { close })
|
|
123
124
|
|
|
124
125
|
/** The catalog for a page that can do without it: a failure is logged and reported as absent; an abort still ends the request. */
|
|
125
126
|
async function optionalCatalog(scope: RequestScope): Promise<ReadonlyArray<CatalogModel> | undefined> {
|
|
@@ -198,8 +199,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
if (route === "GET /v1/models" || modelId !== undefined) {
|
|
202
|
+
// `?capability=vision|tools` filters the list (#61). Any other value is refused before the catalog loads.
|
|
203
|
+
const requested = modelId === undefined ? url.searchParams.get("capability") : null
|
|
204
|
+
const capability = requested === null ? undefined : parseCapability(requested)
|
|
205
|
+
if (requested !== null && capability === undefined)
|
|
206
|
+
return fail(response, { status: 400, kind: "invalid_request", code: "invalid_capability", param: "capability", message: "capability must be vision or tools" })
|
|
201
207
|
const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()), auth.app)
|
|
202
|
-
if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
|
|
208
|
+
if (modelId === undefined) return sendJson(response, 200, encodeModelList(capability ? filterByCapability(listed, capability) : listed))
|
|
203
209
|
const item = listed.find((m) => m.id === modelId)
|
|
204
210
|
if (!item) {
|
|
205
211
|
return fail(response, {
|
|
@@ -277,7 +283,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
|
|
|
277
283
|
}
|
|
278
284
|
|
|
279
285
|
const result = await collectCompletion(events, exchange)
|
|
280
|
-
sendJson(response, result.status, result.body)
|
|
286
|
+
sendJson(response, result.status, result.body, { retryAfterSeconds: result.retryAfterSeconds })
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
/** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
|
package/src/responses/route.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import type { ServerResponse } from "node:http"
|
|
12
12
|
import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
|
|
13
13
|
import { reportServed, type ServedInfo } from "../fallback.js"
|
|
14
|
-
import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
14
|
+
import { boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
|
|
15
15
|
import { resolveModel } from "../models.js"
|
|
16
16
|
import { reportCache } from "../cache.js"
|
|
17
17
|
import { reportPolicy } from "../policy.js"
|
|
@@ -32,7 +32,7 @@ export interface ResponsesCall {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
|
|
35
|
-
const fail = (error: PlugError) =>
|
|
35
|
+
const fail = (error: PlugError) => sendError(response, error, encodeError(error))
|
|
36
36
|
|
|
37
37
|
const parsed = parseResponsesRequest(body, host.options)
|
|
38
38
|
if (isPlugError(parsed)) return fail(parsed)
|