@tduyng/pi-opencode-z 0.0.0-stage → 0.0.1
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/LICENSE +178 -0
- package/README.md +69 -2
- package/package.json +42 -4
- package/src/client.ts +280 -0
- package/src/config.ts +96 -0
- package/src/index.ts +195 -0
- package/src/mcp.ts +315 -0
- package/src/models.ts +86 -0
- package/src/session.ts +191 -0
- package/src/stream.ts +1013 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
currentService,
|
|
5
|
+
fetchZenModels,
|
|
6
|
+
opencodeBin,
|
|
7
|
+
resolveService,
|
|
8
|
+
startEventStream,
|
|
9
|
+
type EventStreamHandle,
|
|
10
|
+
type ZenModel
|
|
11
|
+
} from './client.js'
|
|
12
|
+
import { startMcpServer, type McpServerHandle } from './mcp.js'
|
|
13
|
+
import { isFreeModel, selectModels, toPiModel, type PiModelEntry } from './models.js'
|
|
14
|
+
import { allSessions, disposeAll } from './session.js'
|
|
15
|
+
import { attachMcpServer, createBridgeRuntime, routeEvent, streamOpenCodeBridge, toolHintsFrom } from './stream.js'
|
|
16
|
+
import { bridgeModelsRaw, configPath, projectConfigPath } from './config.js'
|
|
17
|
+
|
|
18
|
+
const PROVIDER_ID = 'opencode-z'
|
|
19
|
+
const API_ID = 'opencode-z'
|
|
20
|
+
|
|
21
|
+
export default async function opencodeBridge(pi: ExtensionAPI): Promise<void> {
|
|
22
|
+
const runtime = createBridgeRuntime()
|
|
23
|
+
runtime.toolHints = toolHintsFrom(() => pi.getAllTools())
|
|
24
|
+
let mcpServer: McpServerHandle | undefined
|
|
25
|
+
let events: EventStreamHandle | undefined
|
|
26
|
+
let catalogue: ZenModel[] = []
|
|
27
|
+
let startupError: string | undefined
|
|
28
|
+
let starting: Promise<void> | undefined
|
|
29
|
+
|
|
30
|
+
async function loadModels(): Promise<PiModelEntry[]> {
|
|
31
|
+
const filter = bridgeModelsRaw()
|
|
32
|
+
catalogue = selectModels(await fetchZenModels(), filter)
|
|
33
|
+
if (filter && catalogue.length === 0) throw new Error(`no configured models matched "${filter}"`)
|
|
34
|
+
return catalogue.map(toPiModel)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function register(models: PiModelEntry[]): void {
|
|
38
|
+
pi.registerProvider(PROVIDER_ID, {
|
|
39
|
+
name: 'OpenCode Bridge',
|
|
40
|
+
baseUrl: 'opencode://local',
|
|
41
|
+
apiKey: 'opencode-z-local',
|
|
42
|
+
api: API_ID,
|
|
43
|
+
models,
|
|
44
|
+
streamSimple: (model, context, options) => streamOpenCodeBridge(runtime, model, context, options),
|
|
45
|
+
refreshModels: async () => {
|
|
46
|
+
await ensureStarted()
|
|
47
|
+
return loadModels()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function start(): Promise<void> {
|
|
53
|
+
await resolveService()
|
|
54
|
+
mcpServer = await startMcpServer()
|
|
55
|
+
attachMcpServer(runtime, mcpServer)
|
|
56
|
+
events = startEventStream((event) => routeEvent(runtime, event))
|
|
57
|
+
register(await loadModels())
|
|
58
|
+
startupError = undefined
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureStarted(): Promise<void> {
|
|
62
|
+
if (mcpServer) return Promise.resolve()
|
|
63
|
+
starting ??= startBridge()
|
|
64
|
+
return starting
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function startBridge(): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
await start()
|
|
70
|
+
} catch (error) {
|
|
71
|
+
startupError = error instanceof Error ? error.message : String(error)
|
|
72
|
+
starting = undefined
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function modelLines(): string[] {
|
|
78
|
+
if (catalogue.length === 0) return ['No Zen models available.']
|
|
79
|
+
const free = catalogue.filter(isFreeModel)
|
|
80
|
+
const paid = catalogue.filter((model) => !isFreeModel(model))
|
|
81
|
+
return [
|
|
82
|
+
`${catalogue.length} model(s), ${free.length} free:`,
|
|
83
|
+
...free.map((model) => ` ${PROVIDER_ID}/${model.id} (free)`),
|
|
84
|
+
...paid.map((model) => ` ${PROVIDER_ID}/${model.id}`)
|
|
85
|
+
]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function statusLines(): string[] {
|
|
89
|
+
const service = currentService()
|
|
90
|
+
const lines = [
|
|
91
|
+
`opencode server: ${service ? service.url : 'not connected'}`,
|
|
92
|
+
`opencode binary: ${opencodeBin()}`,
|
|
93
|
+
`config: ${configPath()}`,
|
|
94
|
+
`project config: ${projectConfigPath()}`,
|
|
95
|
+
`models filter: ${bridgeModelsRaw() ?? 'all'}`,
|
|
96
|
+
`MCP endpoint: ${mcpServer ? `http://127.0.0.1:${mcpServer.port}/mcp` : 'not running'}`,
|
|
97
|
+
`live bridge sessions: ${allSessions(runtime.sessions).length}`
|
|
98
|
+
]
|
|
99
|
+
if (startupError) lines.push(`startup problem: ${startupError}`)
|
|
100
|
+
return [...lines, '', ...modelLines()]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
register([])
|
|
104
|
+
|
|
105
|
+
// Pi waits for async factories, so the Zen models are listed before --model is resolved.
|
|
106
|
+
await ensureStarted().catch(() => undefined)
|
|
107
|
+
|
|
108
|
+
pi.on('session_start', async (_event, ctx: ExtensionContext) => {
|
|
109
|
+
try {
|
|
110
|
+
await ensureStarted()
|
|
111
|
+
} catch {
|
|
112
|
+
ctx.ui.notify(
|
|
113
|
+
`opencode-z is off: ${startupError}. Check that ${opencodeBin()} service status works.`,
|
|
114
|
+
'warning'
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
pi.on('session_shutdown', async () => {
|
|
120
|
+
if (starting) await starting.catch(() => undefined)
|
|
121
|
+
events?.stop()
|
|
122
|
+
events = undefined
|
|
123
|
+
const server = mcpServer
|
|
124
|
+
mcpServer = undefined
|
|
125
|
+
runtime.mcp = undefined
|
|
126
|
+
starting = undefined
|
|
127
|
+
startupError = undefined
|
|
128
|
+
if (!server) return
|
|
129
|
+
await disposeAll(runtime.sessions, server.unregister)
|
|
130
|
+
await server.close()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
const show = (text: string) =>
|
|
134
|
+
pi.sendMessage({ customType: 'opencode-z', display: true, content: text }, { triggerTurn: false })
|
|
135
|
+
|
|
136
|
+
pi.registerCommand('opencode-z', {
|
|
137
|
+
description: 'Status and model list for the opencode bridge',
|
|
138
|
+
getArgumentCompletions: (prefix: string) =>
|
|
139
|
+
[
|
|
140
|
+
{ value: 'status', label: 'status', description: 'server, config, MCP endpoint and live sessions' },
|
|
141
|
+
{ value: 'models', label: 'models', description: 'every Zen model the bridge exposes' },
|
|
142
|
+
{ value: 'refresh', label: 'refresh', description: 're-read the config file and model catalogue' },
|
|
143
|
+
{ value: 'config', label: 'config', description: 'resolved settings and where they come from' },
|
|
144
|
+
{ value: 'help', label: 'help', description: 'usage and settings' }
|
|
145
|
+
].filter((item) => item.value.startsWith(prefix.trim())),
|
|
146
|
+
handler: async (args: string) => {
|
|
147
|
+
const sub = args.trim().split(/\s+/).filter(Boolean)[0] ?? 'status'
|
|
148
|
+
|
|
149
|
+
if (sub === 'refresh') {
|
|
150
|
+
try {
|
|
151
|
+
await ensureStarted()
|
|
152
|
+
await resolveService(true)
|
|
153
|
+
register(await loadModels())
|
|
154
|
+
startupError = undefined
|
|
155
|
+
await show(`opencode-z: refreshed, ${catalogue.length} model(s) available.`)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
await show(`opencode-z refresh failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
158
|
+
}
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (sub === 'help') {
|
|
163
|
+
await show(
|
|
164
|
+
[
|
|
165
|
+
'Usage: /opencode-z [status|models|refresh|config|help]',
|
|
166
|
+
'',
|
|
167
|
+
`Settings live in ${configPath()} and ${projectConfigPath()}.`,
|
|
168
|
+
'OPENCODE_Z_BIN and OPENCODE_Z_MODELS override the file.',
|
|
169
|
+
'See the README for an example file.'
|
|
170
|
+
].join('\n')
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (sub === 'config') {
|
|
176
|
+
await show(
|
|
177
|
+
[
|
|
178
|
+
`file: ${configPath()}`,
|
|
179
|
+
`project file: ${projectConfigPath()}`,
|
|
180
|
+
`opencode binary: ${opencodeBin()}`,
|
|
181
|
+
`models filter: ${bridgeModelsRaw() ?? 'all (every Zen model the account can reach)'}`
|
|
182
|
+
].join('\n')
|
|
183
|
+
)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (sub === 'models') {
|
|
188
|
+
await show(modelLines().join('\n'))
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await show(statusLines().join('\n'))
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
2
|
+
import { Validator } from 'ata-validator'
|
|
3
|
+
import { t } from 'ata-validator/t'
|
|
4
|
+
|
|
5
|
+
const isStringOrNumber = new Validator(t.union([t.string(), t.number()])).isValidObject
|
|
6
|
+
const isString = new Validator(t.string()).isValidObject
|
|
7
|
+
|
|
8
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject
|
|
9
|
+
export type JsonObject = { [key: string]: JsonValue }
|
|
10
|
+
|
|
11
|
+
export type McpToolAnnotations = {
|
|
12
|
+
readOnlyHint?: boolean
|
|
13
|
+
destructiveHint?: boolean
|
|
14
|
+
idempotentHint?: boolean
|
|
15
|
+
openWorldHint?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type McpToolDef = {
|
|
19
|
+
name: string
|
|
20
|
+
description: string
|
|
21
|
+
inputSchema: JsonObject
|
|
22
|
+
annotations?: McpToolAnnotations
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
|
|
26
|
+
|
|
27
|
+
export type McpResult = {
|
|
28
|
+
content: McpContent[]
|
|
29
|
+
isError?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type McpHandlers = {
|
|
33
|
+
listTools(): McpToolDef[]
|
|
34
|
+
callTool(name: string, args: JsonObject, meta: JsonObject): Promise<McpResult>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type McpServerHandle = {
|
|
38
|
+
port: number
|
|
39
|
+
urlFor(token: string): string
|
|
40
|
+
register(token: string, handlers: McpHandlers): void
|
|
41
|
+
unregister(token: string): void
|
|
42
|
+
notifyToolsChanged(token: string): Promise<boolean>
|
|
43
|
+
close(): Promise<void>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SERVER_INFO = { name: 'pi-opencode-z', version: '1.0.0' }
|
|
47
|
+
const DEFAULT_PROTOCOL = '2025-11-25'
|
|
48
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
|
49
|
+
const LIST_WAIT_MS = 2_000
|
|
50
|
+
|
|
51
|
+
type RpcId = string | number
|
|
52
|
+
|
|
53
|
+
type McpRequest =
|
|
54
|
+
| { kind: 'malformed' }
|
|
55
|
+
| { kind: 'notification' }
|
|
56
|
+
| { kind: 'initialize'; id: RpcId; protocolVersion: string }
|
|
57
|
+
| { kind: 'ping'; id: RpcId }
|
|
58
|
+
| { kind: 'listTools'; id: RpcId }
|
|
59
|
+
| { kind: 'callTool'; id: RpcId; name: string; args: JsonObject; meta: JsonObject }
|
|
60
|
+
| { kind: 'unsupported'; id: RpcId; method: string }
|
|
61
|
+
|
|
62
|
+
const RecordSchema = t.object({}, { additionalProperties: true })
|
|
63
|
+
const isRecordSchema = new Validator(RecordSchema).isValidObject
|
|
64
|
+
|
|
65
|
+
function isJsonRecord(value: JsonValue | undefined): value is JsonObject {
|
|
66
|
+
return isRecordSchema(value) && !Array.isArray(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asObject(value: JsonValue | undefined): JsonObject {
|
|
70
|
+
if (!isJsonRecord(value)) return {}
|
|
71
|
+
return value
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseRequest(body: string): McpRequest {
|
|
75
|
+
let frame: JsonValue
|
|
76
|
+
try {
|
|
77
|
+
frame = JSON.parse(body) as JsonValue
|
|
78
|
+
} catch {
|
|
79
|
+
return { kind: 'malformed' }
|
|
80
|
+
}
|
|
81
|
+
const message = asObject(frame)
|
|
82
|
+
const id = message['id']
|
|
83
|
+
if (!isStringOrNumber(id)) return { kind: 'notification' }
|
|
84
|
+
|
|
85
|
+
const method = message['method']
|
|
86
|
+
if (!isString(method)) return { kind: 'malformed' }
|
|
87
|
+
const params = asObject(message['params'])
|
|
88
|
+
|
|
89
|
+
switch (method) {
|
|
90
|
+
case 'initialize': {
|
|
91
|
+
const requested = params['protocolVersion']
|
|
92
|
+
return {
|
|
93
|
+
kind: 'initialize',
|
|
94
|
+
id,
|
|
95
|
+
protocolVersion: isString(requested) ? requested : DEFAULT_PROTOCOL
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
case 'ping':
|
|
99
|
+
return { kind: 'ping', id }
|
|
100
|
+
case 'tools/list':
|
|
101
|
+
return { kind: 'listTools', id }
|
|
102
|
+
case 'tools/call': {
|
|
103
|
+
const name = params['name']
|
|
104
|
+
if (!isString(name)) return { kind: 'malformed' }
|
|
105
|
+
return { kind: 'callTool', id, name, args: asObject(params['arguments']), meta: asObject(params['_meta']) }
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
return { kind: 'unsupported', id, method }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class RequestBodyError extends Error {
|
|
113
|
+
readonly status = 413
|
|
114
|
+
|
|
115
|
+
constructor() {
|
|
116
|
+
super(`MCP request body exceeds ${MAX_BODY_BYTES} bytes`)
|
|
117
|
+
this.name = 'RequestBodyError'
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
let body = ''
|
|
124
|
+
let bytes = 0
|
|
125
|
+
let rejected = false
|
|
126
|
+
req.setEncoding('utf8')
|
|
127
|
+
req.on('data', (chunk: string) => {
|
|
128
|
+
if (rejected) return
|
|
129
|
+
bytes += Buffer.byteLength(chunk, 'utf8')
|
|
130
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
131
|
+
rejected = true
|
|
132
|
+
reject(new RequestBodyError())
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
body += chunk
|
|
136
|
+
})
|
|
137
|
+
req.on('end', () => {
|
|
138
|
+
if (!rejected) resolve(body)
|
|
139
|
+
})
|
|
140
|
+
req.on('error', reject)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function send(res: ServerResponse, status: number, payload?: RpcPayload): void {
|
|
145
|
+
if (payload === undefined) {
|
|
146
|
+
res.writeHead(status).end()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const body = JSON.stringify(payload)
|
|
150
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }).end(body)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type RpcResult =
|
|
154
|
+
| {
|
|
155
|
+
protocolVersion: string
|
|
156
|
+
capabilities: { tools: { listChanged?: boolean } }
|
|
157
|
+
serverInfo: { name: string; version: string }
|
|
158
|
+
}
|
|
159
|
+
| { alive?: true }
|
|
160
|
+
| { tools: McpToolDef[] }
|
|
161
|
+
| McpResult
|
|
162
|
+
|
|
163
|
+
type RpcPayload =
|
|
164
|
+
| { jsonrpc: '2.0'; id: RpcId; result: RpcResult }
|
|
165
|
+
| { jsonrpc: '2.0'; id: RpcId; error: { code: number; message: string } }
|
|
166
|
+
|
|
167
|
+
type RpcReply = { status: number; body?: RpcPayload }
|
|
168
|
+
|
|
169
|
+
async function respond(request: McpRequest, handlers: McpHandlers): Promise<RpcReply> {
|
|
170
|
+
switch (request.kind) {
|
|
171
|
+
case 'malformed':
|
|
172
|
+
return { status: 400 }
|
|
173
|
+
case 'notification':
|
|
174
|
+
return { status: 202 }
|
|
175
|
+
case 'initialize':
|
|
176
|
+
return {
|
|
177
|
+
status: 200,
|
|
178
|
+
body: ok(request.id, {
|
|
179
|
+
protocolVersion: request.protocolVersion,
|
|
180
|
+
capabilities: { tools: { listChanged: true } },
|
|
181
|
+
serverInfo: SERVER_INFO
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
case 'ping':
|
|
185
|
+
return { status: 200, body: ok(request.id, {}) }
|
|
186
|
+
case 'listTools':
|
|
187
|
+
return { status: 200, body: ok(request.id, { tools: handlers.listTools() }) }
|
|
188
|
+
case 'callTool': {
|
|
189
|
+
let result: McpResult
|
|
190
|
+
try {
|
|
191
|
+
result = await handlers.callTool(request.name, request.args, request.meta)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
result = {
|
|
194
|
+
content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }],
|
|
195
|
+
isError: true
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { status: 200, body: ok(request.id, result) }
|
|
199
|
+
}
|
|
200
|
+
case 'unsupported':
|
|
201
|
+
return {
|
|
202
|
+
status: 200,
|
|
203
|
+
body: {
|
|
204
|
+
jsonrpc: '2.0',
|
|
205
|
+
id: request.id,
|
|
206
|
+
error: { code: -32601, message: `unsupported method: ${request.method}` }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ok(id: RpcId, result: RpcResult): RpcPayload {
|
|
213
|
+
return { jsonrpc: '2.0', id, result }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function startMcpServer(): Promise<McpServerHandle> {
|
|
217
|
+
const registry = new Map<string, McpHandlers>()
|
|
218
|
+
const streams = new Map<string, Set<ServerResponse>>()
|
|
219
|
+
const listWaiters = new Map<string, Set<() => void>>()
|
|
220
|
+
|
|
221
|
+
const server: Server = createServer((req, res) => {
|
|
222
|
+
void (async () => {
|
|
223
|
+
const token = /^\/mcp\/([A-Za-z0-9_-]+)\/?$/.exec(req.url ?? '')?.[1]
|
|
224
|
+
const handlers = token ? registry.get(token) : undefined
|
|
225
|
+
if (!token || !handlers) {
|
|
226
|
+
send(res, 404)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (req.method === 'GET') {
|
|
230
|
+
res.writeHead(200, {
|
|
231
|
+
'content-type': 'text/event-stream',
|
|
232
|
+
'cache-control': 'no-cache'
|
|
233
|
+
})
|
|
234
|
+
res.write(': connected\n\n')
|
|
235
|
+
const open = streams.get(token) ?? new Set<ServerResponse>()
|
|
236
|
+
open.add(res)
|
|
237
|
+
streams.set(token, open)
|
|
238
|
+
res.on('close', () => open.delete(res))
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
if (req.method !== 'POST') {
|
|
242
|
+
send(res, 405)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const request = parseRequest(await readBody(req))
|
|
247
|
+
const reply = await respond(request, handlers)
|
|
248
|
+
send(res, reply.status, reply.body)
|
|
249
|
+
if (request.kind === 'listTools') {
|
|
250
|
+
for (const wake of listWaiters.get(token) ?? []) wake()
|
|
251
|
+
}
|
|
252
|
+
})().catch((error) => {
|
|
253
|
+
if (!res.headersSent) send(res, error instanceof RequestBodyError ? error.status : 500)
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
return new Promise((resolve, reject) => {
|
|
258
|
+
server.on('error', reject)
|
|
259
|
+
server.listen(0, '127.0.0.1', () => {
|
|
260
|
+
const address = server.address()
|
|
261
|
+
if (address === null || isString(address)) {
|
|
262
|
+
reject(new Error('MCP server did not bind to a TCP port'))
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
server.unref()
|
|
266
|
+
resolve({
|
|
267
|
+
port: address.port,
|
|
268
|
+
urlFor: (token) => `http://127.0.0.1:${address.port}/mcp/${token}`,
|
|
269
|
+
register: (token, handlers) => {
|
|
270
|
+
registry.set(token, handlers)
|
|
271
|
+
},
|
|
272
|
+
unregister: (token) => {
|
|
273
|
+
registry.delete(token)
|
|
274
|
+
for (const res of streams.get(token) ?? []) res.end()
|
|
275
|
+
streams.delete(token)
|
|
276
|
+
listWaiters.delete(token)
|
|
277
|
+
},
|
|
278
|
+
notifyToolsChanged: (token) => {
|
|
279
|
+
const open = streams.get(token)
|
|
280
|
+
if (!open || open.size === 0) return Promise.resolve(false)
|
|
281
|
+
const frame = `event: message\ndata: ${JSON.stringify({
|
|
282
|
+
jsonrpc: '2.0',
|
|
283
|
+
method: 'notifications/tools/list_changed'
|
|
284
|
+
})}\n\n`
|
|
285
|
+
for (const res of open) res.write(frame)
|
|
286
|
+
return new Promise<boolean>((done) => {
|
|
287
|
+
const finish = (listed: boolean) => {
|
|
288
|
+
clearTimeout(timer)
|
|
289
|
+
listWaiters.get(token)?.delete(waiter)
|
|
290
|
+
done(listed)
|
|
291
|
+
}
|
|
292
|
+
const waiter = () => finish(true)
|
|
293
|
+
const timer = setTimeout(() => finish(false), LIST_WAIT_MS)
|
|
294
|
+
const waiters = listWaiters.get(token) ?? new Set<() => void>()
|
|
295
|
+
waiters.add(waiter)
|
|
296
|
+
listWaiters.set(token, waiters)
|
|
297
|
+
})
|
|
298
|
+
},
|
|
299
|
+
close: () =>
|
|
300
|
+
new Promise((done) => {
|
|
301
|
+
registry.clear()
|
|
302
|
+
listWaiters.clear()
|
|
303
|
+
for (const open of streams.values()) for (const res of open) res.end()
|
|
304
|
+
streams.clear()
|
|
305
|
+
server.closeAllConnections()
|
|
306
|
+
server.close(() => done())
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function handleRpcFrame(body: string, handlers: McpHandlers): Promise<RpcReply> {
|
|
314
|
+
return respond(parseRequest(body), handlers)
|
|
315
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ModelThinkingLevel, ThinkingLevelMap } from '@earendil-works/pi-ai'
|
|
2
|
+
|
|
3
|
+
import type { ZenModel } from './client.js'
|
|
4
|
+
import { bridgeModelsRaw } from './config.js'
|
|
5
|
+
|
|
6
|
+
export type PiModelEntry = {
|
|
7
|
+
id: string
|
|
8
|
+
name: string
|
|
9
|
+
reasoning: boolean
|
|
10
|
+
thinkingLevelMap?: ThinkingLevelMap
|
|
11
|
+
input: ('text' | 'image')[]
|
|
12
|
+
cost: { input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
13
|
+
contextWindow: number
|
|
14
|
+
maxTokens: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_CONTEXT_WINDOW = 128_000
|
|
18
|
+
const DEFAULT_MAX_TOKENS = 16_384
|
|
19
|
+
|
|
20
|
+
const THINKING_LEVELS: ModelThinkingLevel[] = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
|
|
21
|
+
|
|
22
|
+
function variantName(level: ModelThinkingLevel, offered: Set<string>): string | null {
|
|
23
|
+
if (level === 'off') return offered.has('off') ? 'off' : offered.has('none') ? 'none' : null
|
|
24
|
+
if (offered.has(level)) return level
|
|
25
|
+
if (level === 'max' && offered.has('xhigh')) return 'xhigh'
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function thinkingLevelMap(model: ZenModel): ThinkingLevelMap | undefined {
|
|
30
|
+
const offered = new Set((model.variants ?? []).map((variant) => variant.id))
|
|
31
|
+
if (offered.size === 0) return undefined
|
|
32
|
+
|
|
33
|
+
const map: ThinkingLevelMap = {}
|
|
34
|
+
for (const level of THINKING_LEVELS) map[level] = variantName(level, offered)
|
|
35
|
+
return map
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function variantFor(
|
|
39
|
+
map: ThinkingLevelMap | undefined,
|
|
40
|
+
level: ModelThinkingLevel | undefined
|
|
41
|
+
): string | undefined {
|
|
42
|
+
if (!map || !level) return undefined
|
|
43
|
+
const variant = map[level]
|
|
44
|
+
return variant ?? undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isFreeModel(model: ZenModel): boolean {
|
|
48
|
+
const cost = model.cost?.[0]
|
|
49
|
+
return (cost?.input ?? 0) === 0 && (cost?.output ?? 0) === 0
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function toPiModel(model: ZenModel): PiModelEntry {
|
|
53
|
+
const cost = model.cost?.[0]
|
|
54
|
+
const map = thinkingLevelMap(model)
|
|
55
|
+
const accepts = model.capabilities?.input ?? ['text']
|
|
56
|
+
const entry: PiModelEntry = {
|
|
57
|
+
id: model.id,
|
|
58
|
+
name: `${model.name?.trim() || model.id}${isFreeModel(model) ? ' (free)' : ''}`,
|
|
59
|
+
reasoning: map !== undefined,
|
|
60
|
+
input: accepts.includes('image') ? ['text', 'image'] : ['text'],
|
|
61
|
+
cost: {
|
|
62
|
+
input: cost?.input ?? 0,
|
|
63
|
+
output: cost?.output ?? 0,
|
|
64
|
+
cacheRead: cost?.cache?.read ?? 0,
|
|
65
|
+
cacheWrite: cost?.cache?.write ?? 0
|
|
66
|
+
},
|
|
67
|
+
contextWindow: model.limit?.context || DEFAULT_CONTEXT_WINDOW,
|
|
68
|
+
maxTokens: model.limit?.output || DEFAULT_MAX_TOKENS
|
|
69
|
+
}
|
|
70
|
+
if (map) entry.thinkingLevelMap = map
|
|
71
|
+
return entry
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function selectModels(models: ZenModel[], rawOverride?: string): ZenModel[] {
|
|
75
|
+
const raw = rawOverride ?? bridgeModelsRaw()
|
|
76
|
+
if (!raw) return models
|
|
77
|
+
if (raw === 'free') return models.filter(isFreeModel)
|
|
78
|
+
|
|
79
|
+
const wanted = new Set(
|
|
80
|
+
raw
|
|
81
|
+
.split(/[\s,]+/)
|
|
82
|
+
.map((part) => part.trim().replace(/^opencode\//, ''))
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
)
|
|
85
|
+
return models.filter((model) => wanted.has(model.id))
|
|
86
|
+
}
|