Add model-controlled image invocation and read-only runtime assignments. Route image tools through Main so accepted operations retain their originating conversation when chat runtimes change, with saved references, cancellation, and manual recovery. Keep Node hashing outside browser-shared contracts to restore production builds. Validation includes the production build, typecheck, lint, focused regressions, real Host transport checks, and two successful real image requests. Full UI-driven runtime switching acceptance remains documented as pending. Release note: 开启图片模型的会话调用后,聊天助手可调用图片生成与编辑工具,Runtime 分配自动管理。图片成果保存在原会话,可继续修改,并独立显示生成状态和取消入口。
1075 lines
30 KiB
TypeScript
1075 lines
30 KiB
TypeScript
import { randomBytes, randomUUID } from 'node:crypto'
|
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
import { mkdtemp, writeFile, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
|
|
import {
|
|
createServer,
|
|
type IncomingHttpHeaders,
|
|
type IncomingMessage,
|
|
type Server,
|
|
type ServerResponse
|
|
} from 'node:http'
|
|
import type { Socket } from 'node:net'
|
|
import { isAbsolute, resolve, join } from 'node:path'
|
|
import type {
|
|
ModelBridgeModelProtocol
|
|
} from '../shared/model-bridge-contracts'
|
|
import {
|
|
REMOTE_MODEL_GATEWAY_LIMITS,
|
|
remoteModelApiPathSchema,
|
|
remoteModelGatewayRequestSchema,
|
|
remoteModelGatewayResponseSchema,
|
|
type RemoteModelGatewayRequest
|
|
} from '../shared/remote-model-gateway-contracts'
|
|
import {
|
|
createUnixModelBridgeExchange,
|
|
ModelBridgeBrokerError,
|
|
type ModelBridgeExchange,
|
|
type ModelBridgeExchangeResult
|
|
} from './model-bridge-broker'
|
|
import {
|
|
boundedInteger,
|
|
closeServer,
|
|
raceWithAbort,
|
|
settleWithin
|
|
} from './async-utils'
|
|
|
|
export type ModelBridgeProtocol = ModelBridgeModelProtocol
|
|
|
|
// The Anthropic and OpenAI SDK adapters refuse to issue a request without an
|
|
// apiKey option. This fixed value is only a local compatibility marker. The
|
|
// loopback proxy verifies and removes its header before creating the wire
|
|
// request. A separate per-helper random route capability authenticates local
|
|
// requests, so this marker is neither a credential nor visible upstream.
|
|
export const MODEL_BRIDGE_SDK_AUTH_SENTINEL =
|
|
'goodbuddy-local-model-bridge-v1'
|
|
|
|
export type OpenCodeModelBridgeProviderConfig = {
|
|
model: string
|
|
snapshot: false
|
|
permission: 'ask' | 'allow'
|
|
agent: {
|
|
title: {
|
|
disable: true
|
|
}
|
|
build: {
|
|
permission: 'ask' | 'allow'
|
|
}
|
|
}
|
|
provider: Record<
|
|
string,
|
|
{
|
|
name: string
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
options: {
|
|
baseURL: string
|
|
apiKey?: typeof MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
}
|
|
models: Record<
|
|
string,
|
|
{
|
|
name: string
|
|
attachment: boolean
|
|
modalities: {
|
|
input: Array<'text' | 'image'>
|
|
output: ['text']
|
|
}
|
|
provider: {
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
}
|
|
}
|
|
>
|
|
}
|
|
>
|
|
}
|
|
|
|
export type ModelBridgeHelperChild = {
|
|
once(event: 'error', listener: (error: Error) => void): unknown
|
|
once(
|
|
event: 'close',
|
|
listener: (code: number | null, signal: NodeJS.Signals | null) => void
|
|
): unknown
|
|
}
|
|
|
|
export type ModelBridgeHelperSpawn = (
|
|
executable: string,
|
|
args: readonly string[],
|
|
options: {
|
|
shell: false
|
|
stdio: 'inherit'
|
|
env: Readonly<NodeJS.ProcessEnv>
|
|
}
|
|
) => ModelBridgeHelperChild
|
|
|
|
const LOOPBACK_HOST = '127.0.0.1'
|
|
const DEFAULT_MAXIMUM_CONNECTIONS = 8
|
|
const DEFAULT_REQUEST_TIMEOUT_MS = 0
|
|
const RESPONSE_CLOSE_TIMEOUT_MS = 2_000
|
|
const MAXIMUM_HTTP_HEADER_BYTES = 16 * 1024
|
|
const MAXIMUM_HTTP_HEADER_COUNT = 32
|
|
const MODEL_BRIDGE_ROUTE_TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/u
|
|
const CREDENTIAL_INBOUND_HEADER_NAMES = new Set([
|
|
'api-key',
|
|
'authorization',
|
|
'cookie',
|
|
'proxy-authorization',
|
|
'set-cookie',
|
|
'x-api-key',
|
|
'x-goog-api-key'
|
|
])
|
|
const FORWARDED_HEADER_NAMES = [
|
|
'accept',
|
|
'content-type'
|
|
] as const
|
|
|
|
export class ModelBridgeLoopbackProxy {
|
|
readonly #exchange: ModelBridgeExchange
|
|
readonly #sharedSessions: boolean
|
|
readonly #sessionRoutes = new Map<string, { operationId: string; socketPath: string; workMode: 'ask' | 'execute'; imageToolName?: string }>()
|
|
readonly #routeToken: string
|
|
readonly #maximumConnections: number
|
|
readonly #requestTimeoutMs: number
|
|
readonly #sockets = new Set<Socket>()
|
|
readonly #dispatches = new Map<string, DispatchWaiter[]>()
|
|
#server?: Server
|
|
#origin?: string
|
|
#closing = false
|
|
#closePromise?: Promise<void>
|
|
|
|
constructor(options: {
|
|
exchange: ModelBridgeExchange
|
|
sharedSessions?: boolean
|
|
routeToken?: string
|
|
maximumConnections?: number
|
|
requestTimeoutMs?: number
|
|
}) {
|
|
this.#exchange = options.exchange
|
|
this.#sharedSessions = options.sharedSessions === true
|
|
this.#routeToken = parseModelBridgeRouteToken(
|
|
options.routeToken ??
|
|
randomBytes(32).toString('base64url')
|
|
)
|
|
this.#maximumConnections = boundedInteger(
|
|
options.maximumConnections ?? DEFAULT_MAXIMUM_CONNECTIONS,
|
|
1,
|
|
64,
|
|
'Model bridge loopback connection limit'
|
|
)
|
|
this.#requestTimeoutMs = boundedInteger(
|
|
options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
0,
|
|
0x7fff_ffff,
|
|
'Model bridge loopback request timeout'
|
|
)
|
|
}
|
|
|
|
get origin(): string {
|
|
if (this.#origin === undefined) {
|
|
throw new Error('Model bridge loopback proxy is not listening')
|
|
}
|
|
return this.#origin
|
|
}
|
|
|
|
async listen(): Promise<string> {
|
|
if (this.#server !== undefined || this.#closing) {
|
|
throw new Error('Model bridge loopback proxy is already active')
|
|
}
|
|
const server = createServer(
|
|
{
|
|
maxHeaderSize: MAXIMUM_HTTP_HEADER_BYTES,
|
|
requireHostHeader: true
|
|
},
|
|
(request, response) => {
|
|
void this.#handleRequest(request, response)
|
|
}
|
|
)
|
|
server.maxConnections = this.#maximumConnections
|
|
server.maxHeadersCount = MAXIMUM_HTTP_HEADER_COUNT
|
|
server.maxRequestsPerSocket = 1
|
|
server.requestTimeout = this.#requestTimeoutMs
|
|
server.headersTimeout = Math.min(
|
|
this.#requestTimeoutMs,
|
|
30_000
|
|
)
|
|
server.keepAliveTimeout = 1_000
|
|
server.on('connection', (socket) => {
|
|
if (
|
|
this.#closing ||
|
|
this.#sockets.size >= this.#maximumConnections
|
|
) {
|
|
socket.destroy()
|
|
return
|
|
}
|
|
this.#sockets.add(socket)
|
|
socket.once('close', () => this.#sockets.delete(socket))
|
|
})
|
|
this.#server = server
|
|
try {
|
|
await listenOnLoopback(server)
|
|
const address = server.address()
|
|
if (address === null || typeof address === 'string') {
|
|
throw new Error(
|
|
'Model bridge loopback address is unavailable'
|
|
)
|
|
}
|
|
this.#origin =
|
|
`http://${LOOPBACK_HOST}:${address.port}/${this.#routeToken}`
|
|
return this.#origin
|
|
} catch (error) {
|
|
await this.close()
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
if (this.#closePromise !== undefined) {
|
|
await this.#closePromise
|
|
return
|
|
}
|
|
const closePromise = this.#close()
|
|
this.#closePromise = closePromise
|
|
try {
|
|
await closePromise
|
|
} finally {
|
|
if (this.#closePromise === closePromise) {
|
|
this.#closePromise = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
async #close(): Promise<void> {
|
|
const server = this.#server
|
|
this.#server = undefined
|
|
this.#origin = undefined
|
|
this.#closing = true
|
|
try {
|
|
for (const socket of this.#sockets) {
|
|
socket.destroy()
|
|
}
|
|
await settleWithin(
|
|
server === undefined
|
|
? Promise.resolve()
|
|
: closeServer(server),
|
|
RESPONSE_CLOSE_TIMEOUT_MS
|
|
)
|
|
} finally {
|
|
this.#sockets.clear()
|
|
this.#sessionRoutes.clear()
|
|
this.#closing = false
|
|
}
|
|
}
|
|
|
|
async #handleRequest(
|
|
incoming: IncomingMessage,
|
|
outgoing: ServerResponse
|
|
): Promise<void> {
|
|
outgoing.setHeader('connection', 'close')
|
|
outgoing.setHeader('cache-control', 'no-store')
|
|
outgoing.setHeader('x-content-type-options', 'nosniff')
|
|
if (this.#closing) {
|
|
incoming.resume()
|
|
sendError(outgoing, 503, 'bridge-closing')
|
|
return
|
|
}
|
|
const cancellation = new AbortController()
|
|
let dispatched: ModelBridgeExchangeResult | undefined
|
|
let responseFlushed = false
|
|
let deliveryAcknowledged = false
|
|
let dispatchHeld = false
|
|
let dispatchKey = ''
|
|
let sessionId: string | undefined
|
|
let sessionRoute: { operationId: string; socketPath: string } | undefined
|
|
const onAborted = (): void => {
|
|
cancellation.abort(
|
|
new ModelBridgeBrokerError('request-cancelled')
|
|
)
|
|
}
|
|
const onResponseClose = (): void => {
|
|
if (!responseFlushed && !outgoing.writableFinished) {
|
|
onAborted()
|
|
void dispatched?.failDelivery(
|
|
new ModelBridgeBrokerError('transport-failed')
|
|
)
|
|
}
|
|
}
|
|
incoming.once('aborted', onAborted)
|
|
outgoing.once('close', onResponseClose)
|
|
const timeout = this.#requestTimeoutMs === 0 ? undefined : setTimeout(() => {
|
|
cancellation.abort(
|
|
new ModelBridgeBrokerError('request-timeout')
|
|
)
|
|
}, this.#requestTimeoutMs)
|
|
timeout?.unref?.()
|
|
|
|
try {
|
|
if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) {
|
|
const url = new URL(incoming.url, 'http://localhost')
|
|
if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed')
|
|
if (incoming.method === 'GET') {
|
|
const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '')
|
|
if (!route) throw new HttpRequestError(409, 'session-inactive')
|
|
outgoing.setHeader('content-type', 'application/json')
|
|
outgoing.end(JSON.stringify({ operationId: route.operationId, workMode: route.workMode, imageToolName: route.imageToolName }))
|
|
return
|
|
}
|
|
if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed')
|
|
const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as {
|
|
sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; workMode?: 'ask' | 'execute'; imageToolName?: string
|
|
}
|
|
if (
|
|
typeof value.sessionId !== 'string' || value.sessionId.length > 128 ||
|
|
typeof value.operationId !== 'string' || value.operationId.length > 128
|
|
) throw new HttpRequestError(400, 'request-invalid')
|
|
if (value.release) {
|
|
if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) {
|
|
this.#sessionRoutes.delete(value.sessionId)
|
|
}
|
|
} else {
|
|
if (value.workMode !== 'ask' && value.workMode !== 'execute') throw new HttpRequestError(400, 'request-invalid')
|
|
const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket')
|
|
if (value.imageToolName !== undefined && !/^goodbuddy_image_[a-f0-9]{24}$/.test(value.imageToolName)) throw new HttpRequestError(400, 'request-invalid')
|
|
this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode, imageToolName: value.imageToolName })
|
|
}
|
|
outgoing.end('{}')
|
|
return
|
|
}
|
|
const request = await parseHttpRequest(
|
|
incoming,
|
|
this.#routeToken
|
|
)
|
|
let exchange = this.#exchange
|
|
if (this.#sharedSessions) {
|
|
const sessionHeader = incoming.headers['x-goodbuddy-session']
|
|
const operationId = incoming.headers['x-goodbuddy-operation']
|
|
sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined
|
|
const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined
|
|
if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive')
|
|
sessionRoute = route
|
|
dispatchKey = `${sessionId}\0${operationId}`
|
|
exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath })
|
|
}
|
|
await this.#acquireDispatch(dispatchKey, cancellation.signal)
|
|
dispatchHeld = true
|
|
if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) {
|
|
throw new HttpRequestError(409, 'session-inactive')
|
|
}
|
|
dispatched = normalizeExchangeResult(
|
|
await raceWithAbort(exchange(request, {
|
|
requestId: `model-${randomUUID()}`,
|
|
signal: cancellation.signal
|
|
}), cancellation.signal)
|
|
)
|
|
const parsedResponse =
|
|
remoteModelGatewayResponseSchema.parse(dispatched.response)
|
|
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
outgoing.statusCode = parsedResponse.status
|
|
for (const [name, value] of Object.entries(
|
|
parsedResponse.headers
|
|
)) {
|
|
outgoing.setHeader(name, value)
|
|
}
|
|
const body = Buffer.from(
|
|
parsedResponse.bodyBase64,
|
|
'base64'
|
|
)
|
|
outgoing.setHeader('content-length', body.byteLength)
|
|
await endResponseFlushed(outgoing, body, () => {
|
|
responseFlushed = true
|
|
})
|
|
cancellation.signal.throwIfAborted()
|
|
await dispatched.acknowledgeDelivery()
|
|
deliveryAcknowledged = true
|
|
}
|
|
} catch (error) {
|
|
if (dispatched !== undefined && !deliveryAcknowledged) {
|
|
await Promise.resolve(
|
|
dispatched.failDelivery(error)
|
|
).catch(() => undefined)
|
|
}
|
|
if (!outgoing.headersSent && !outgoing.destroyed) {
|
|
const failure = httpFailure(error, cancellation.signal)
|
|
sendError(outgoing, failure.status, failure.code)
|
|
}
|
|
} finally {
|
|
clearTimeout(timeout)
|
|
incoming.off('aborted', onAborted)
|
|
outgoing.off('close', onResponseClose)
|
|
if (dispatchHeld) {
|
|
this.#releaseDispatch(dispatchKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
async #acquireDispatch(key: string, signal: AbortSignal): Promise<void> {
|
|
signal.throwIfAborted()
|
|
const queue = this.#dispatches.get(key)
|
|
if (!queue) {
|
|
this.#dispatches.set(key, [])
|
|
return
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
const waiter: DispatchWaiter = { resolve, reject, signal }
|
|
waiter.abort = (): void => {
|
|
const index = queue.indexOf(waiter)
|
|
if (index >= 0) {
|
|
queue.splice(index, 1)
|
|
}
|
|
reject(signal.reason)
|
|
}
|
|
signal.addEventListener('abort', waiter.abort, { once: true })
|
|
queue.push(waiter)
|
|
})
|
|
}
|
|
|
|
#releaseDispatch(key: string): void {
|
|
const queue = this.#dispatches.get(key)
|
|
while (queue && queue.length > 0) {
|
|
const waiter = queue.shift()!
|
|
if (waiter.abort !== undefined) {
|
|
waiter.signal.removeEventListener('abort', waiter.abort)
|
|
}
|
|
if (waiter.signal.aborted) {
|
|
waiter.reject(waiter.signal.reason)
|
|
continue
|
|
}
|
|
waiter.resolve()
|
|
return
|
|
}
|
|
this.#dispatches.delete(key)
|
|
}
|
|
}
|
|
|
|
export async function runOpenCodeModelBridgeHelper(options: {
|
|
socketPath: string
|
|
protocol: ModelBridgeProtocol
|
|
model: string
|
|
supportsImageInput: boolean
|
|
workMode: 'ask' | 'execute'
|
|
sharedSessions?: boolean
|
|
opencodeEntrypoint: string
|
|
environment?: Readonly<NodeJS.ProcessEnv>
|
|
spawn?: ModelBridgeHelperSpawn
|
|
}): Promise<number> {
|
|
const socketPath = normalizedAbsolutePath(
|
|
options.socketPath,
|
|
'Model bridge socket'
|
|
)
|
|
const opencodeEntrypoint = normalizedAbsolutePath(
|
|
options.opencodeEntrypoint,
|
|
'OpenCode entrypoint'
|
|
)
|
|
const exchange = createUnixModelBridgeExchange({ socketPath })
|
|
const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions })
|
|
const origin = await proxy.listen()
|
|
let pluginDirectory: string | undefined
|
|
try {
|
|
pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-'))
|
|
const pluginPath = join(pluginDirectory, 'subagent.mjs')
|
|
await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8')
|
|
const config = createOpenCodeModelBridgeProviderConfig({
|
|
protocol: options.protocol,
|
|
model: options.model,
|
|
loopbackOrigin: origin,
|
|
supportsImageInput: options.supportsImageInput,
|
|
workMode: options.sharedSessions ? 'ask' : options.workMode
|
|
})
|
|
const environment = credentialFreeHelperEnvironment(
|
|
options.environment ?? process.env,
|
|
JSON.stringify({
|
|
...config,
|
|
plugin: [pathToFileURL(pluginPath).href]
|
|
})
|
|
)
|
|
if (options.sharedSessions) {
|
|
process.stdout.write(JSON.stringify({
|
|
jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin }
|
|
}) + '\n')
|
|
}
|
|
const child = (options.spawn ?? defaultHelperSpawn)(
|
|
opencodeEntrypoint,
|
|
['acp'],
|
|
{
|
|
shell: false,
|
|
stdio: 'inherit',
|
|
env: environment
|
|
}
|
|
)
|
|
return await new Promise<number>((resolveExit, reject) => {
|
|
child.once('error', reject)
|
|
child.once('close', (code, signal) => {
|
|
if (signal !== null) {
|
|
resolveExit(128)
|
|
return
|
|
}
|
|
resolveExit(code ?? 1)
|
|
})
|
|
})
|
|
} finally {
|
|
await proxy.close()
|
|
if (pluginDirectory) {
|
|
await rm(pluginDirectory, { recursive: true, force: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
function endResponseFlushed(
|
|
response: ServerResponse,
|
|
body: Uint8Array,
|
|
onFlushed: () => void
|
|
): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onFinish = (): void => {
|
|
cleanup()
|
|
onFlushed()
|
|
resolve()
|
|
}
|
|
const onClose = (): void => {
|
|
if (response.writableFinished) {
|
|
onFinish()
|
|
return
|
|
}
|
|
cleanup()
|
|
reject(new ModelBridgeBrokerError('transport-failed'))
|
|
}
|
|
const onError = (): void => {
|
|
cleanup()
|
|
reject(new ModelBridgeBrokerError('transport-failed'))
|
|
}
|
|
const cleanup = (): void => {
|
|
response.off('finish', onFinish)
|
|
response.off('close', onClose)
|
|
response.off('error', onError)
|
|
}
|
|
response.once('finish', onFinish)
|
|
response.once('close', onClose)
|
|
response.once('error', onError)
|
|
response.end(body)
|
|
})
|
|
}
|
|
|
|
type DispatchWaiter = {
|
|
resolve: () => void
|
|
reject: (error: unknown) => void
|
|
signal: AbortSignal
|
|
abort?: () => void
|
|
}
|
|
|
|
export function createOpenCodeModelBridgeProviderConfig(input: {
|
|
protocol: ModelBridgeProtocol
|
|
model: string
|
|
loopbackOrigin: string
|
|
name?: string
|
|
supportsImageInput?: boolean
|
|
workMode?: 'ask' | 'execute'
|
|
}): OpenCodeModelBridgeProviderConfig {
|
|
const model = boundedMetadataText(input.model, 'Model name')
|
|
const name = boundedMetadataText(
|
|
input.name ?? 'GoodBuddy Model Bridge',
|
|
'Provider name'
|
|
)
|
|
const origin = parseLoopbackOrigin(input.loopbackOrigin)
|
|
const descriptor = providerDescriptor(input.protocol)
|
|
const npm = descriptor.npm
|
|
const providerId = descriptor.id
|
|
const baseURL = `${origin}/v1`
|
|
return {
|
|
model: openCodeModelBridgeModelId(input.protocol, model),
|
|
snapshot: false,
|
|
permission: input.workMode === 'ask' ? 'ask' : 'allow',
|
|
// GoodBuddy owns conversation titles. A first-prompt title request can
|
|
// race the real ACP prompt, abandon its Provider response, and correctly
|
|
// poison the no-replay ledger before the user request can run.
|
|
agent: {
|
|
title: {
|
|
disable: true
|
|
},
|
|
build: {
|
|
permission: input.workMode === 'ask' ? 'ask' : 'allow'
|
|
}
|
|
},
|
|
provider: {
|
|
[providerId]: {
|
|
name,
|
|
npm,
|
|
options: {
|
|
baseURL,
|
|
...(descriptor.requiresSdkAuthSentinel
|
|
? { apiKey: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
|
|
: {})
|
|
},
|
|
models: {
|
|
[model]: {
|
|
name,
|
|
attachment: input.supportsImageInput === true,
|
|
modalities: {
|
|
input:
|
|
input.supportsImageInput === true
|
|
? ['text', 'image']
|
|
: ['text'],
|
|
output: ['text']
|
|
},
|
|
provider: { npm }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function openCodeModelBridgeModelId(
|
|
protocol: ModelBridgeProtocol,
|
|
modelInput: string
|
|
): string {
|
|
const model = boundedMetadataText(modelInput, 'Model name')
|
|
return `${providerDescriptor(protocol).id}/${model}`
|
|
}
|
|
|
|
async function parseHttpRequest(
|
|
incoming: IncomingMessage,
|
|
routeToken: string
|
|
): Promise<RemoteModelGatewayRequest> {
|
|
if (incoming.method !== 'POST') {
|
|
throw new HttpRequestError(405, 'method-not-allowed')
|
|
}
|
|
const path = remoteModelApiPathSchema.safeParse(
|
|
authenticatedApiPath(incoming.url, routeToken)
|
|
)
|
|
if (!path.success) {
|
|
throw new HttpRequestError(404, 'path-not-allowed')
|
|
}
|
|
validateInboundHeaders(
|
|
incoming.headers,
|
|
incoming.rawHeaders,
|
|
path.data
|
|
)
|
|
const declaredLength = parseContentLength(
|
|
incoming.headers['content-length']
|
|
)
|
|
if (declaredLength === undefined) {
|
|
throw new HttpRequestError(411, 'content-length-required')
|
|
}
|
|
if (
|
|
declaredLength !== undefined &&
|
|
declaredLength >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
|
) {
|
|
incoming.resume()
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
const body = await readBoundedBody(incoming)
|
|
if (
|
|
declaredLength !== undefined &&
|
|
body.byteLength !== declaredLength
|
|
) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
const headers: Record<string, string> = {}
|
|
for (const name of FORWARDED_HEADER_NAMES) {
|
|
const value = incoming.headers[name]
|
|
if (typeof value === 'string') {
|
|
headers[name] = value
|
|
}
|
|
}
|
|
const parsed = remoteModelGatewayRequestSchema.safeParse({
|
|
method: 'POST',
|
|
path: path.data,
|
|
headers,
|
|
bodyBase64: body.toString('base64')
|
|
})
|
|
if (!parsed.success) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
return parsed.data
|
|
}
|
|
|
|
function validateInboundHeaders(
|
|
headers: IncomingHttpHeaders,
|
|
rawHeaders: readonly string[],
|
|
path: RemoteModelGatewayRequest['path']
|
|
): void {
|
|
const entries = Object.entries(headers)
|
|
if (entries.length > MAXIMUM_HTTP_HEADER_COUNT) {
|
|
throw new HttpRequestError(431, 'headers-rejected')
|
|
}
|
|
const securityCriticalNames = new Set([
|
|
'accept',
|
|
'connection',
|
|
'content-length',
|
|
'content-type',
|
|
'host',
|
|
'transfer-encoding',
|
|
...CREDENTIAL_INBOUND_HEADER_NAMES
|
|
])
|
|
const seen = new Set<string>()
|
|
for (let index = 0; index < rawHeaders.length; index += 2) {
|
|
const name = rawHeaders[index]?.toLowerCase()
|
|
if (
|
|
name !== undefined &&
|
|
securityCriticalNames.has(name) &&
|
|
seen.has(name)
|
|
) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
if (name !== undefined) {
|
|
seen.add(name)
|
|
}
|
|
}
|
|
for (const [name, value] of entries) {
|
|
if (
|
|
!/^[a-z0-9!#$%&'*+.^_`|~-]+$/u.test(name) ||
|
|
Array.isArray(value) ||
|
|
(CREDENTIAL_INBOUND_HEADER_NAMES.has(name) &&
|
|
!isExpectedSdkAuthenticationHeader(path, name, value))
|
|
) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
if (
|
|
value !== undefined &&
|
|
(value.length >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumHeaderValueBytes ||
|
|
/[\r\n]/u.test(value))
|
|
) {
|
|
throw new HttpRequestError(431, 'headers-rejected')
|
|
}
|
|
if (name === 'transfer-encoding' && value !== undefined) {
|
|
throw new HttpRequestError(400, 'headers-rejected')
|
|
}
|
|
}
|
|
}
|
|
|
|
function isExpectedSdkAuthenticationHeader(
|
|
path: RemoteModelGatewayRequest['path'],
|
|
name: string,
|
|
value: string | undefined
|
|
): boolean {
|
|
if (path === '/v1/messages') {
|
|
return (
|
|
name === 'x-api-key' &&
|
|
value === MODEL_BRIDGE_SDK_AUTH_SENTINEL
|
|
)
|
|
}
|
|
if (path === '/responses' || path === '/v1/responses') {
|
|
return (
|
|
name === 'authorization' &&
|
|
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
)
|
|
}
|
|
if (
|
|
path === '/chat/completions' ||
|
|
path === '/v1/chat/completions'
|
|
) {
|
|
return (
|
|
name === 'authorization' &&
|
|
value === `Bearer ${MODEL_BRIDGE_SDK_AUTH_SENTINEL}`
|
|
)
|
|
}
|
|
return false
|
|
}
|
|
|
|
function parseContentLength(
|
|
value: string | undefined
|
|
): number | undefined {
|
|
if (value === undefined) {
|
|
return undefined
|
|
}
|
|
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) {
|
|
throw new HttpRequestError(400, 'request-invalid')
|
|
}
|
|
const length = Number(value)
|
|
if (!Number.isSafeInteger(length)) {
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
return length
|
|
}
|
|
|
|
async function readBoundedBody(
|
|
incoming: IncomingMessage
|
|
): Promise<Buffer> {
|
|
const chunks: Buffer[] = []
|
|
let total = 0
|
|
for await (const untrustedChunk of incoming) {
|
|
const chunk = Buffer.isBuffer(untrustedChunk)
|
|
? untrustedChunk
|
|
: Buffer.from(untrustedChunk as Uint8Array)
|
|
total += chunk.byteLength
|
|
if (
|
|
total >
|
|
REMOTE_MODEL_GATEWAY_LIMITS.maximumRequestBodyBytes
|
|
) {
|
|
throw new HttpRequestError(413, 'request-too-large')
|
|
}
|
|
chunks.push(chunk)
|
|
}
|
|
return Buffer.concat(chunks, total)
|
|
}
|
|
|
|
function sendError(
|
|
response: ServerResponse,
|
|
status: number,
|
|
code: string
|
|
): void {
|
|
const body = Buffer.from(JSON.stringify({ error: { code } }), 'utf8')
|
|
response.statusCode = status
|
|
response.setHeader('content-type', 'application/json')
|
|
response.setHeader('content-length', body.byteLength)
|
|
response.end(body)
|
|
}
|
|
|
|
function httpFailure(
|
|
error: unknown,
|
|
signal: AbortSignal
|
|
): { status: number; code: string } {
|
|
if (error instanceof HttpRequestError) {
|
|
return { status: error.status, code: error.code }
|
|
}
|
|
const reason = signal.reason
|
|
if (
|
|
(signal.aborted &&
|
|
reason instanceof ModelBridgeBrokerError &&
|
|
reason.code === 'request-timeout') ||
|
|
(error instanceof ModelBridgeBrokerError &&
|
|
error.code === 'request-timeout')
|
|
) {
|
|
return { status: 504, code: 'bridge-timeout' }
|
|
}
|
|
if (
|
|
signal.aborted ||
|
|
(error instanceof ModelBridgeBrokerError &&
|
|
error.code === 'request-cancelled')
|
|
) {
|
|
return { status: 499, code: 'request-cancelled' }
|
|
}
|
|
return { status: 502, code: 'bridge-failed' }
|
|
}
|
|
|
|
function providerDescriptor(protocol: ModelBridgeProtocol): {
|
|
id: string
|
|
npm:
|
|
| '@ai-sdk/anthropic'
|
|
| '@ai-sdk/openai-compatible'
|
|
| '@ai-sdk/openai'
|
|
requiresSdkAuthSentinel: boolean
|
|
} {
|
|
switch (protocol) {
|
|
case 'anthropic-messages':
|
|
return {
|
|
id: 'goodbuddy-anthropic',
|
|
npm: '@ai-sdk/anthropic',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
case 'openai-chat-completions':
|
|
return {
|
|
id: 'goodbuddy-openai-chat',
|
|
npm: '@ai-sdk/openai-compatible',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
case 'openai-responses':
|
|
return {
|
|
id: 'goodbuddy-openai-responses',
|
|
npm: '@ai-sdk/openai',
|
|
requiresSdkAuthSentinel: true
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeExchangeResult(
|
|
value: Awaited<ReturnType<ModelBridgeExchange>>
|
|
): ModelBridgeExchangeResult {
|
|
if (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
'response' in value &&
|
|
typeof value.acknowledgeDelivery === 'function' &&
|
|
typeof value.failDelivery === 'function'
|
|
) {
|
|
return value
|
|
}
|
|
return {
|
|
response: remoteModelGatewayResponseSchema.parse(value),
|
|
acknowledgeDelivery: async () => undefined,
|
|
failDelivery: () => undefined
|
|
}
|
|
}
|
|
|
|
function defaultHelperSpawn(
|
|
executable: string,
|
|
args: readonly string[],
|
|
options: {
|
|
shell: false
|
|
stdio: 'inherit'
|
|
env: Readonly<NodeJS.ProcessEnv>
|
|
}
|
|
): ModelBridgeHelperChild {
|
|
return nodeSpawn(executable, [...args], {
|
|
shell: options.shell,
|
|
stdio: options.stdio,
|
|
env: options.env
|
|
})
|
|
}
|
|
|
|
function credentialFreeHelperEnvironment(
|
|
source: Readonly<NodeJS.ProcessEnv>,
|
|
config: string
|
|
): Readonly<NodeJS.ProcessEnv> {
|
|
const allowed = [
|
|
'HOME',
|
|
'LANG',
|
|
'LC_ALL',
|
|
'PATH',
|
|
'TMPDIR',
|
|
'XDG_CACHE_HOME',
|
|
'XDG_CONFIG_HOME',
|
|
'XDG_DATA_HOME',
|
|
'XDG_STATE_HOME'
|
|
] as const
|
|
const environment: NodeJS.ProcessEnv = {
|
|
OPENCODE_CONFIG_CONTENT: config,
|
|
OPENCODE_ENABLE_QUESTION_TOOL: 'true'
|
|
}
|
|
for (const name of allowed) {
|
|
const value = source[name]
|
|
if (
|
|
value === undefined ||
|
|
value.includes('\0') ||
|
|
/(?:key|secret|token|authorization)/iu.test(name)
|
|
) {
|
|
throw new Error(`Fixed helper environment is missing ${name}`)
|
|
}
|
|
environment[name] = value
|
|
}
|
|
for (const name of [
|
|
'OPENCODE_CONFIG_DIR',
|
|
'OPENCODE_DISABLE_AUTOUPDATE',
|
|
'OPENCODE_DISABLE_CLAUDE_CODE_SKILLS',
|
|
'OPENCODE_DISABLE_EMBEDDED_WEB_UI',
|
|
'OPENCODE_DISABLE_EXTERNAL_SKILLS',
|
|
'OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER',
|
|
'OPENCODE_DISABLE_LSP_DOWNLOAD',
|
|
'OPENCODE_DISABLE_MODELS_FETCH',
|
|
'OPENCODE_DISABLE_PROJECT_CONFIG',
|
|
'OPENCODE_DISABLE_SHARE'
|
|
]) {
|
|
const value = source[name]
|
|
if (value !== undefined) {
|
|
if (value.includes('\0')) {
|
|
throw new Error(`Invalid helper environment value for ${name}`)
|
|
}
|
|
environment[name] = value
|
|
}
|
|
}
|
|
return environment
|
|
}
|
|
|
|
function normalizedAbsolutePath(value: string, label: string): string {
|
|
if (
|
|
value.includes('\0') ||
|
|
!isAbsolute(value) ||
|
|
resolve(value) !== value
|
|
) {
|
|
throw new Error(`${label} must be a normalized absolute path`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function parseLoopbackOrigin(input: string): string {
|
|
let url: URL
|
|
try {
|
|
url = new URL(input)
|
|
} catch {
|
|
throw new Error('Model bridge origin is invalid')
|
|
}
|
|
if (
|
|
url.protocol !== 'http:' ||
|
|
url.hostname !== LOOPBACK_HOST ||
|
|
url.username ||
|
|
url.password ||
|
|
!MODEL_BRIDGE_ROUTE_TOKEN_PATTERN.test(
|
|
url.pathname.slice(1)
|
|
) ||
|
|
url.search ||
|
|
url.hash ||
|
|
url.port === ''
|
|
) {
|
|
throw new Error(
|
|
'Model bridge origin must be authenticated loopback HTTP'
|
|
)
|
|
}
|
|
return `${url.origin}${url.pathname}`
|
|
}
|
|
|
|
function parseModelBridgeRouteToken(value: string): string {
|
|
if (!MODEL_BRIDGE_ROUTE_TOKEN_PATTERN.test(value)) {
|
|
throw new Error('Model bridge route capability is invalid')
|
|
}
|
|
return value
|
|
}
|
|
|
|
function authenticatedApiPath(
|
|
input: string | undefined,
|
|
routeToken: string
|
|
): string | undefined {
|
|
const prefix = `/${routeToken}`
|
|
return input?.startsWith(`${prefix}/`)
|
|
? input.slice(prefix.length)
|
|
: undefined
|
|
}
|
|
|
|
function boundedMetadataText(
|
|
value: string,
|
|
label: string
|
|
): string {
|
|
if (
|
|
value.length < 1 ||
|
|
Buffer.byteLength(value, 'utf8') > 512 ||
|
|
/[\0\r\n]/u.test(value)
|
|
) {
|
|
throw new Error(`${label} is invalid`)
|
|
}
|
|
return value
|
|
}
|
|
|
|
function listenOnLoopback(server: Server): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onError = (error: Error): void => {
|
|
cleanup()
|
|
reject(error)
|
|
}
|
|
const onListening = (): void => {
|
|
cleanup()
|
|
resolve()
|
|
}
|
|
const cleanup = (): void => {
|
|
server.off('error', onError)
|
|
server.off('listening', onListening)
|
|
}
|
|
server.once('error', onError)
|
|
server.once('listening', onListening)
|
|
server.listen({
|
|
host: LOOPBACK_HOST,
|
|
port: 0,
|
|
exclusive: true
|
|
})
|
|
})
|
|
}
|
|
|
|
|
|
class HttpRequestError extends Error {
|
|
readonly status: number
|
|
readonly code: string
|
|
|
|
constructor(status: number, code: string) {
|
|
super('Model bridge HTTP request was rejected')
|
|
this.name = 'HttpRequestError'
|
|
this.status = status
|
|
this.code = code
|
|
}
|
|
}
|