@chatpanel/bridge 0.10.26 → 0.10.27
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 +56 -0
- package/package.json +1 -1
- package/scripts/test-codex-httpie.sh +35 -0
- package/src/api-compat.js +292 -0
- package/src/server.js +217 -3
package/README.md
CHANGED
|
@@ -87,6 +87,11 @@ npm start # → http://127.0.0.1:4319
|
|
|
87
87
|
|--------|------|---------|
|
|
88
88
|
| `GET` | `/health` | `{ ok, version, agents:[{id,label,available,reason}] }` |
|
|
89
89
|
| `POST` | `/chat` | SSE stream — body `{ agent, system, options, messages }` |
|
|
90
|
+
| `GET` | `/v1/models` | OpenAI-compatible list of available local agents |
|
|
91
|
+
| `POST` | `/v1/chat/completions` | OpenAI-compatible Chat Completions (streaming and non-streaming) |
|
|
92
|
+
| `POST` | `/v1/completions` | OpenAI-compatible legacy text Completions (streaming and non-streaming) |
|
|
93
|
+
| `POST` | `/v1/responses` | OpenAI-compatible Responses API (streaming and non-streaming) |
|
|
94
|
+
| `POST` | `/v1/messages` | Anthropic-compatible Messages API (streaming and non-streaming) |
|
|
90
95
|
|
|
91
96
|
`/chat` streams Server-Sent Events: `{type:'delta',text}` as the answer is
|
|
92
97
|
generated, `{type:'tool',name,summary}` / `{type:'status'}` for activity, and a
|
|
@@ -102,6 +107,57 @@ final `{type:'done'}` (or `{type:'error',error}`).
|
|
|
102
107
|
}
|
|
103
108
|
```
|
|
104
109
|
|
|
110
|
+
### OpenAI and Anthropic SDK compatibility
|
|
111
|
+
|
|
112
|
+
The compatibility routes run a local coding agent; they do not call a provider
|
|
113
|
+
API. Use the per-install token from `~/.chatpanel/bridge-token` as the SDK API
|
|
114
|
+
key. The token protects endpoints that can start local processes, so do not put
|
|
115
|
+
it in browser-delivered code or share it.
|
|
116
|
+
|
|
117
|
+
For OpenAI clients, set the base URL to `http://127.0.0.1:4319/v1` and use an
|
|
118
|
+
installed agent id such as `codex` as the model:
|
|
119
|
+
|
|
120
|
+
```js
|
|
121
|
+
import OpenAI from 'openai';
|
|
122
|
+
import { readFileSync } from 'node:fs';
|
|
123
|
+
|
|
124
|
+
const client = new OpenAI({
|
|
125
|
+
apiKey: readFileSync(`${process.env.HOME}/.chatpanel/bridge-token`, 'utf8').trim(),
|
|
126
|
+
baseURL: 'http://127.0.0.1:4319/v1',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await client.responses.create({
|
|
130
|
+
model: 'codex',
|
|
131
|
+
input: 'Explain this project in three bullets.',
|
|
132
|
+
});
|
|
133
|
+
console.log(result.output_text);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`client.chat.completions.create(...)` and legacy `client.completions.create(...)`
|
|
137
|
+
work through the same base URL. For the Anthropic SDK, use
|
|
138
|
+
`http://127.0.0.1:4319` as its base URL and the same token as its API key;
|
|
139
|
+
requests to `/v1/messages` can also use `model: "codex"`.
|
|
140
|
+
|
|
141
|
+
The model field selects the local agent. `codex/gpt-5.5`, for example, selects
|
|
142
|
+
the Codex agent and passes `gpt-5.5` as its CLI model override. Advanced bridge
|
|
143
|
+
settings can be supplied through an extra `chatpanel` request object:
|
|
144
|
+
|
|
145
|
+
```jsonc
|
|
146
|
+
{
|
|
147
|
+
"model": "codex",
|
|
148
|
+
"messages": [{ "role": "user", "content": "Fix the failing tests." }],
|
|
149
|
+
"chatpanel": {
|
|
150
|
+
"working_dir": "/absolute/path/to/project",
|
|
151
|
+
"permission_mode": "acceptEdits",
|
|
152
|
+
"use_local_config": true
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The adapter currently covers text conversations. Provider-hosted function tools,
|
|
158
|
+
stored responses, log probabilities, and exact token accounting are not
|
|
159
|
+
available; unsupported tool requests return HTTP 400, and usage counts are zero.
|
|
160
|
+
|
|
105
161
|
## Safety
|
|
106
162
|
|
|
107
163
|
- Binds to `127.0.0.1` only; CORS accepts the extension origin and localhost.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.27",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
|
|
6
6
|
"keywords": [
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
bridge_url="${CHATPANEL_BRIDGE_URL:-http://127.0.0.1:4319}"
|
|
5
|
+
token_file="${CHATPANEL_BRIDGE_TOKEN_FILE:-${HOME}/.chatpanel/bridge-token}"
|
|
6
|
+
prompt="${*:-Reply with exactly: httpie-codex-ok}"
|
|
7
|
+
|
|
8
|
+
if ! command -v http >/dev/null 2>&1; then
|
|
9
|
+
echo 'HTTPie is required. Install it with: brew install httpie' >&2
|
|
10
|
+
exit 1
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
if [[ ! -r "$token_file" ]]; then
|
|
14
|
+
echo "Bridge token is not readable: $token_file" >&2
|
|
15
|
+
exit 1
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
bridge_token="$(tr -d '\r\n' < "$token_file")"
|
|
19
|
+
if [[ -z "$bridge_token" ]]; then
|
|
20
|
+
echo "Bridge token is empty: $token_file" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
|
|
24
|
+
response="$({
|
|
25
|
+
http --check-status --ignore-stdin --body POST "$bridge_url/v1/responses" \
|
|
26
|
+
Authorization:"Bearer $bridge_token" \
|
|
27
|
+
model=codex \
|
|
28
|
+
input="$prompt"
|
|
29
|
+
})"
|
|
30
|
+
|
|
31
|
+
if command -v jq >/dev/null 2>&1; then
|
|
32
|
+
jq -r '.output[]?.content[]? | select(.type == "output_text") | .text' <<< "$response"
|
|
33
|
+
else
|
|
34
|
+
printf '%s\n' "$response"
|
|
35
|
+
fi
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// OpenAI + Anthropic wire-format adapters for ChatPanel's local agent engines.
|
|
2
|
+
//
|
|
3
|
+
// This intentionally implements the text conversation subset shared by coding
|
|
4
|
+
// agents. Provider-hosted features (token accounting, stored responses, remote
|
|
5
|
+
// function tools, logprobs, etc.) cannot be reproduced by a local CLI runner and
|
|
6
|
+
// are rejected when accepting them would produce misleading behavior.
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const AGENT_IDS = new Set(['claude', 'codex', 'antigravity', 'pi', 'opencode', 'kiro', 'copilot', 'deepseek']);
|
|
11
|
+
|
|
12
|
+
export class CompatError extends Error {
|
|
13
|
+
constructor(message, status = 400, param = null) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.param = param;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function textOf(content) {
|
|
21
|
+
if (content == null) return '';
|
|
22
|
+
if (typeof content === 'string') return content;
|
|
23
|
+
if (!Array.isArray(content)) return String(content);
|
|
24
|
+
return content.map((part) => {
|
|
25
|
+
if (typeof part === 'string') return part;
|
|
26
|
+
if (!part || typeof part !== 'object') return '';
|
|
27
|
+
if (typeof part.text === 'string') return part.text;
|
|
28
|
+
if (typeof part.content === 'string') return part.content;
|
|
29
|
+
if (part.type === 'tool_result') return textOf(part.content);
|
|
30
|
+
return '';
|
|
31
|
+
}).filter(Boolean).join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assertTextContent(content, param) {
|
|
35
|
+
if (content == null || typeof content === 'string') return;
|
|
36
|
+
if (!Array.isArray(content)) throw new CompatError(`${param} must contain text`, 400, param);
|
|
37
|
+
for (const part of content) {
|
|
38
|
+
if (typeof part === 'string') continue;
|
|
39
|
+
const type = part?.type;
|
|
40
|
+
if (part && typeof part === 'object' && (!type || ['text', 'input_text', 'output_text'].includes(type)) && typeof part.text === 'string') continue;
|
|
41
|
+
if (type === 'tool_result') {
|
|
42
|
+
assertTextContent(part.content, param);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
throw new CompatError(`${param} contains unsupported non-text content`, 400, param);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeMessages(items = []) {
|
|
50
|
+
return items.map((item) => ({
|
|
51
|
+
role: item?.role === 'assistant' ? 'assistant' : 'user',
|
|
52
|
+
content: textOf(item?.content),
|
|
53
|
+
})).filter((item) => item.content);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveTarget(model, chatpanel = {}) {
|
|
57
|
+
const requested = String(model || 'codex').trim() || 'codex';
|
|
58
|
+
const slash = requested.indexOf('/');
|
|
59
|
+
const prefix = slash < 0 ? requested : requested.slice(0, slash);
|
|
60
|
+
const explicitAgent = String(chatpanel.agent || '').trim();
|
|
61
|
+
const agent = explicitAgent || (AGENT_IDS.has(prefix) ? prefix : 'codex');
|
|
62
|
+
if (!AGENT_IDS.has(agent)) throw new CompatError(`Unknown ChatPanel agent "${agent}"`, 400, 'model');
|
|
63
|
+
|
|
64
|
+
let engineModel = String(chatpanel.model || '').trim();
|
|
65
|
+
if (!engineModel && slash >= 0 && AGENT_IDS.has(prefix)) engineModel = requested.slice(slash + 1);
|
|
66
|
+
if (!engineModel && !AGENT_IDS.has(requested)) engineModel = requested;
|
|
67
|
+
|
|
68
|
+
const permissionMode = chatpanel.permission_mode || chatpanel.permissionMode || 'default';
|
|
69
|
+
if (!['default', 'acceptEdits', 'bypassPermissions'].includes(permissionMode)) {
|
|
70
|
+
throw new CompatError('chatpanel.permission_mode must be default, acceptEdits, or bypassPermissions', 400, 'chatpanel.permission_mode');
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
agent,
|
|
74
|
+
requestedModel: requested,
|
|
75
|
+
options: {
|
|
76
|
+
workingDir: chatpanel.working_dir || chatpanel.workingDir || process.env.CHATPANEL_API_WORKING_DIR || '',
|
|
77
|
+
permissionMode,
|
|
78
|
+
useLocalConfig: chatpanel.use_local_config ?? chatpanel.useLocalConfig ?? true,
|
|
79
|
+
...(engineModel ? { model: engineModel } : {}),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseChatCompletion(body = {}) {
|
|
85
|
+
if (!Array.isArray(body.messages) || !body.messages.length) {
|
|
86
|
+
throw new CompatError('messages must be a non-empty array', 400, 'messages');
|
|
87
|
+
}
|
|
88
|
+
if (body.tools?.length) throw new CompatError('OpenAI tool calling is not supported by the bridge adapter', 400, 'tools');
|
|
89
|
+
for (const message of body.messages) {
|
|
90
|
+
if (!['system', 'developer', 'user', 'assistant'].includes(message?.role)) {
|
|
91
|
+
throw new CompatError(`Unsupported message role "${message?.role || ''}"`, 400, 'messages');
|
|
92
|
+
}
|
|
93
|
+
assertTextContent(message.content, 'messages');
|
|
94
|
+
}
|
|
95
|
+
const system = body.messages
|
|
96
|
+
.filter((m) => m?.role === 'system' || m?.role === 'developer')
|
|
97
|
+
.map((m) => textOf(m.content)).filter(Boolean).join('\n\n');
|
|
98
|
+
const messages = normalizeMessages(body.messages.filter((m) => m?.role !== 'system' && m?.role !== 'developer'));
|
|
99
|
+
if (!messages.length) throw new CompatError('messages must contain user or assistant text', 400, 'messages');
|
|
100
|
+
return { ...resolveTarget(body.model, body.chatpanel), system, messages, stream: body.stream === true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function parseCompletion(body = {}) {
|
|
104
|
+
if (Array.isArray(body.prompt)) {
|
|
105
|
+
for (const prompt of body.prompt) assertTextContent(prompt, 'prompt');
|
|
106
|
+
} else {
|
|
107
|
+
assertTextContent(body.prompt, 'prompt');
|
|
108
|
+
}
|
|
109
|
+
const prompt = Array.isArray(body.prompt) ? body.prompt.map(textOf).join('\n') : textOf(body.prompt);
|
|
110
|
+
if (!prompt) throw new CompatError('prompt must contain text', 400, 'prompt');
|
|
111
|
+
return {
|
|
112
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
113
|
+
system: '',
|
|
114
|
+
messages: [{ role: 'user', content: prompt }],
|
|
115
|
+
stream: body.stream === true,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseResponse(body = {}) {
|
|
120
|
+
if (body.tools?.length) throw new CompatError('Responses API tool calling is not supported by the bridge adapter', 400, 'tools');
|
|
121
|
+
let input = body.input;
|
|
122
|
+
if (typeof input === 'string') input = [{ role: 'user', content: input }];
|
|
123
|
+
if (!Array.isArray(input) || !input.length) throw new CompatError('input must be a string or non-empty array', 400, 'input');
|
|
124
|
+
assertTextContent(body.instructions, 'instructions');
|
|
125
|
+
for (const item of input) {
|
|
126
|
+
if (!['system', 'developer', 'user', 'assistant'].includes(item?.role)) {
|
|
127
|
+
throw new CompatError('input currently supports only text message items', 400, 'input');
|
|
128
|
+
}
|
|
129
|
+
assertTextContent(item.content, 'input');
|
|
130
|
+
}
|
|
131
|
+
const systemParts = [textOf(body.instructions)];
|
|
132
|
+
for (const item of input) {
|
|
133
|
+
if (item?.role === 'system' || item?.role === 'developer') systemParts.push(textOf(item.content));
|
|
134
|
+
}
|
|
135
|
+
const messages = normalizeMessages(input.filter((item) => item?.role !== 'system' && item?.role !== 'developer'));
|
|
136
|
+
if (!messages.length) throw new CompatError('input must contain user or assistant text', 400, 'input');
|
|
137
|
+
return {
|
|
138
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
139
|
+
system: systemParts.filter(Boolean).join('\n\n'),
|
|
140
|
+
messages,
|
|
141
|
+
stream: body.stream === true,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseAnthropicMessage(body = {}) {
|
|
146
|
+
if (!Array.isArray(body.messages) || !body.messages.length) {
|
|
147
|
+
throw new CompatError('messages must be a non-empty array', 400, 'messages');
|
|
148
|
+
}
|
|
149
|
+
if (body.tools?.length) throw new CompatError('Anthropic tool use is not supported by the bridge adapter', 400, 'tools');
|
|
150
|
+
assertTextContent(body.system, 'system');
|
|
151
|
+
for (const message of body.messages) {
|
|
152
|
+
if (!['user', 'assistant'].includes(message?.role)) {
|
|
153
|
+
throw new CompatError(`Unsupported message role "${message?.role || ''}"`, 400, 'messages');
|
|
154
|
+
}
|
|
155
|
+
assertTextContent(message.content, 'messages');
|
|
156
|
+
}
|
|
157
|
+
const messages = normalizeMessages(body.messages);
|
|
158
|
+
if (!messages.length) throw new CompatError('messages must contain text', 400, 'messages');
|
|
159
|
+
return {
|
|
160
|
+
...resolveTarget(body.model, body.chatpanel),
|
|
161
|
+
system: textOf(body.system),
|
|
162
|
+
messages,
|
|
163
|
+
stream: body.stream === true,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function openAIError(error) {
|
|
168
|
+
return {
|
|
169
|
+
error: {
|
|
170
|
+
message: error?.message || String(error),
|
|
171
|
+
type: error instanceof CompatError ? 'invalid_request_error' : 'api_error',
|
|
172
|
+
param: error?.param || null,
|
|
173
|
+
code: null,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function anthropicError(error) {
|
|
179
|
+
return {
|
|
180
|
+
type: 'error',
|
|
181
|
+
error: {
|
|
182
|
+
type: error instanceof CompatError ? 'invalid_request_error' : 'api_error',
|
|
183
|
+
message: error?.message || String(error),
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const zeroUsage = () => ({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
|
189
|
+
const responseUsage = () => ({ input_tokens: 0, input_tokens_details: { cached_tokens: 0 }, output_tokens: 0, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 0 });
|
|
190
|
+
|
|
191
|
+
export function createChatCompletion(model, text, { id = `chatcmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
192
|
+
return {
|
|
193
|
+
id, object: 'chat.completion', created, model,
|
|
194
|
+
choices: [{ index: 0, message: { role: 'assistant', content: text, refusal: null }, logprobs: null, finish_reason: 'stop' }],
|
|
195
|
+
usage: zeroUsage(),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createCompletion(model, text, { id = `cmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
200
|
+
return {
|
|
201
|
+
id, object: 'text_completion', created, model,
|
|
202
|
+
choices: [{ text, index: 0, logprobs: null, finish_reason: 'stop' }],
|
|
203
|
+
usage: zeroUsage(),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function completionStream(model, onWrite, { id = `cmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
208
|
+
const chunk = (text, finish_reason = null) => ({
|
|
209
|
+
id, object: 'text_completion', created, model,
|
|
210
|
+
choices: [{ text, index: 0, logprobs: null, finish_reason }],
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
delta(text) { if (text) onWrite(chunk(text)); },
|
|
214
|
+
done() { onWrite(chunk('', 'stop')); },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function chatCompletionStream(model, onWrite, { id = `chatcmpl-${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
219
|
+
const chunk = (delta, finish_reason = null) => ({
|
|
220
|
+
id, object: 'chat.completion.chunk', created, model,
|
|
221
|
+
choices: [{ index: 0, delta, logprobs: null, finish_reason }],
|
|
222
|
+
});
|
|
223
|
+
let started = false;
|
|
224
|
+
return {
|
|
225
|
+
delta(text) {
|
|
226
|
+
if (!started) { onWrite(chunk({ role: 'assistant', content: '' })); started = true; }
|
|
227
|
+
if (text) onWrite(chunk({ content: text }));
|
|
228
|
+
},
|
|
229
|
+
done() {
|
|
230
|
+
if (!started) onWrite(chunk({ role: 'assistant', content: '' }));
|
|
231
|
+
onWrite(chunk({}, 'stop'));
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function responseObject(model, text, { id, created, status = 'completed' }) {
|
|
237
|
+
const itemId = `msg_${id.slice(-24)}`;
|
|
238
|
+
return {
|
|
239
|
+
id, object: 'response', created_at: created, status, error: null, incomplete_details: null,
|
|
240
|
+
instructions: null, max_output_tokens: null, model,
|
|
241
|
+
output: status === 'completed' ? [{ id: itemId, type: 'message', status: 'completed', role: 'assistant', content: [{ type: 'output_text', text, annotations: [], logprobs: [] }] }] : [],
|
|
242
|
+
parallel_tool_calls: false, previous_response_id: null, reasoning: { effort: null, summary: null }, store: false,
|
|
243
|
+
temperature: null, text: { format: { type: 'text' } }, tool_choice: 'none', tools: [], top_p: null,
|
|
244
|
+
truncation: 'disabled', usage: status === 'completed' ? responseUsage() : null, user: null, metadata: {},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function createResponse(model, text, { id = `resp_${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
249
|
+
return responseObject(model, text, { id, created });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function responseStream(model, onWrite, { id = `resp_${randomUUID()}`, created = Math.floor(Date.now() / 1000) } = {}) {
|
|
253
|
+
const itemId = `msg_${id.slice(-24)}`;
|
|
254
|
+
let sequence = 0;
|
|
255
|
+
let text = '';
|
|
256
|
+
onWrite({ type: 'response.created', sequence_number: sequence++, response: responseObject(model, '', { id, created, status: 'in_progress' }) });
|
|
257
|
+
onWrite({ type: 'response.in_progress', sequence_number: sequence++, response: responseObject(model, '', { id, created, status: 'in_progress' }) });
|
|
258
|
+
onWrite({ type: 'response.output_item.added', sequence_number: sequence++, output_index: 0, item: { id: itemId, type: 'message', status: 'in_progress', role: 'assistant', content: [] } });
|
|
259
|
+
onWrite({ type: 'response.content_part.added', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [], logprobs: [] } });
|
|
260
|
+
return {
|
|
261
|
+
delta(value) {
|
|
262
|
+
if (!value) return;
|
|
263
|
+
text += value;
|
|
264
|
+
onWrite({ type: 'response.output_text.delta', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, delta: value, logprobs: [] });
|
|
265
|
+
},
|
|
266
|
+
done() {
|
|
267
|
+
onWrite({ type: 'response.output_text.done', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, text, logprobs: [] });
|
|
268
|
+
const part = { type: 'output_text', text, annotations: [], logprobs: [] };
|
|
269
|
+
onWrite({ type: 'response.content_part.done', sequence_number: sequence++, item_id: itemId, output_index: 0, content_index: 0, part });
|
|
270
|
+
const item = { id: itemId, type: 'message', status: 'completed', role: 'assistant', content: [part] };
|
|
271
|
+
onWrite({ type: 'response.output_item.done', sequence_number: sequence++, output_index: 0, item });
|
|
272
|
+
onWrite({ type: 'response.completed', sequence_number: sequence++, response: responseObject(model, text, { id, created }) });
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function createAnthropicMessage(model, text, { id = `msg_${randomUUID()}` } = {}) {
|
|
278
|
+
return { id, type: 'message', role: 'assistant', model, content: [{ type: 'text', text }], stop_reason: 'end_turn', stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function anthropicStream(model, onWrite, { id = `msg_${randomUUID()}` } = {}) {
|
|
282
|
+
onWrite('message_start', { type: 'message_start', message: { id, type: 'message', role: 'assistant', model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 0, output_tokens: 0 } } });
|
|
283
|
+
onWrite('content_block_start', { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } });
|
|
284
|
+
return {
|
|
285
|
+
delta(text) { if (text) onWrite('content_block_delta', { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }); },
|
|
286
|
+
done() {
|
|
287
|
+
onWrite('content_block_stop', { type: 'content_block_stop', index: 0 });
|
|
288
|
+
onWrite('message_delta', { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } });
|
|
289
|
+
onWrite('message_stop', { type: 'message_stop' });
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
package/src/server.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
// {type:'status'|'reasoning', text?}
|
|
12
12
|
// {type:'done', text?} (text only if not streamed)
|
|
13
13
|
// {type:'error', error}
|
|
14
|
+
// POST /v1/chat/completions, /v1/completions, /v1/responses
|
|
15
|
+
// → OpenAI-compatible text adapters for the local agents
|
|
16
|
+
// POST /v1/messages → Anthropic-compatible text adapter for the local agents
|
|
14
17
|
//
|
|
15
18
|
// Binds to 127.0.0.1 only. A request guard (see `guard()`) enforces a loopback
|
|
16
19
|
// Host (anti DNS-rebinding) and an allowlisted Origin; the command-spawning
|
|
@@ -36,11 +39,28 @@ import { checkForUpdate, selfUpdate } from './update.js';
|
|
|
36
39
|
import { callLocalMcp } from './mcp-local.js';
|
|
37
40
|
import { assertPublicHttpUrl, assertPublicWebUrl } from './ssrf.js';
|
|
38
41
|
import { startRun, endRun, cancelRun, cancelAll, activeRuns } from './runs.js';
|
|
42
|
+
import {
|
|
43
|
+
CompatError,
|
|
44
|
+
anthropicError,
|
|
45
|
+
anthropicStream,
|
|
46
|
+
chatCompletionStream,
|
|
47
|
+
completionStream,
|
|
48
|
+
createAnthropicMessage,
|
|
49
|
+
createChatCompletion,
|
|
50
|
+
createCompletion,
|
|
51
|
+
createResponse,
|
|
52
|
+
openAIError,
|
|
53
|
+
parseAnthropicMessage,
|
|
54
|
+
parseChatCompletion,
|
|
55
|
+
parseCompletion,
|
|
56
|
+
parseResponse,
|
|
57
|
+
responseStream,
|
|
58
|
+
} from './api-compat.js';
|
|
39
59
|
|
|
40
60
|
// Hardcoded (not read from package.json) so it survives Bun's single-file
|
|
41
61
|
// --compile, where package.json isn't on a readable FS. CI fails the publish if
|
|
42
62
|
// this drifts from package.json, so the two can't silently diverge.
|
|
43
|
-
const VERSION = '0.10.
|
|
63
|
+
const VERSION = '0.10.27';
|
|
44
64
|
const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
|
|
45
65
|
const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
|
|
46
66
|
|
|
@@ -163,7 +183,7 @@ function cors(req, res) {
|
|
|
163
183
|
const allow = originAllowed(origin);
|
|
164
184
|
res.setHeader('Access-Control-Allow-Origin', allow ? origin || '*' : 'null');
|
|
165
185
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
166
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
|
|
186
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Api-Key, Anthropic-Version, X-ChatPanel-Token');
|
|
167
187
|
res.setHeader('Vary', 'Origin');
|
|
168
188
|
}
|
|
169
189
|
|
|
@@ -219,7 +239,9 @@ function ensureToken() {
|
|
|
219
239
|
function tokenOk(req) {
|
|
220
240
|
if (!AUTH_TOKEN) return false;
|
|
221
241
|
const h = String(req.headers['authorization'] || '');
|
|
222
|
-
const provided = (h.startsWith('Bearer ')
|
|
242
|
+
const provided = (h.startsWith('Bearer ')
|
|
243
|
+
? h.slice(7)
|
|
244
|
+
: String(req.headers['x-api-key'] || req.headers['x-chatpanel-token'] || '')).trim();
|
|
223
245
|
if (!provided) return false;
|
|
224
246
|
const a = Buffer.from(provided);
|
|
225
247
|
const b = Buffer.from(AUTH_TOKEN);
|
|
@@ -232,6 +254,10 @@ function tokenOk(req) {
|
|
|
232
254
|
// local coding-agent CLIs connect to them with no Origin header by design.
|
|
233
255
|
const PRIVILEGED_POST = new Set([
|
|
234
256
|
'/chat',
|
|
257
|
+
'/v1/chat/completions',
|
|
258
|
+
'/v1/completions',
|
|
259
|
+
'/v1/responses',
|
|
260
|
+
'/v1/messages',
|
|
235
261
|
'/mcp-local',
|
|
236
262
|
'/mcp-remote',
|
|
237
263
|
'/fetch-title',
|
|
@@ -313,6 +339,30 @@ async function handleHealth(res) {
|
|
|
313
339
|
json(res, 200, { ok: true, version: VERSION, agents, update });
|
|
314
340
|
}
|
|
315
341
|
|
|
342
|
+
async function compatibleModels() {
|
|
343
|
+
const rows = await Promise.all(
|
|
344
|
+
Object.entries(ENGINES)
|
|
345
|
+
.filter(([, entry]) => !entry.hidden)
|
|
346
|
+
.map(async ([id, entry]) => ({
|
|
347
|
+
id,
|
|
348
|
+
object: 'model',
|
|
349
|
+
created: 0,
|
|
350
|
+
owned_by: 'chatpanel',
|
|
351
|
+
available: !!(await entry.engine.available().catch(() => ({ ok: false }))).ok,
|
|
352
|
+
})),
|
|
353
|
+
);
|
|
354
|
+
return rows.filter((row) => row.available).map(({ available, ...row }) => row);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function handleCompatibleModels(res, modelId = '') {
|
|
358
|
+
const models = await compatibleModels();
|
|
359
|
+
if (modelId) {
|
|
360
|
+
const model = models.find((row) => row.id === modelId);
|
|
361
|
+
return model ? json(res, 200, model) : json(res, 404, openAIError(new CompatError(`Model "${modelId}" not found`, 404, 'model')));
|
|
362
|
+
}
|
|
363
|
+
return json(res, 200, { object: 'list', data: models });
|
|
364
|
+
}
|
|
365
|
+
|
|
316
366
|
// POST /update — self-update (compiled-binary installs). Swaps the binary, replies,
|
|
317
367
|
// then restarts the service into the new version. npm installs get instructions.
|
|
318
368
|
async function handleUpdate(res) {
|
|
@@ -404,6 +454,162 @@ async function handleChat(req, res) {
|
|
|
404
454
|
}
|
|
405
455
|
}
|
|
406
456
|
|
|
457
|
+
async function runCompatibleAgent(config, onDelta, res) {
|
|
458
|
+
const target = ENGINES[config.agent];
|
|
459
|
+
if (!target || target.hidden) throw new CompatError(`Unknown ChatPanel agent "${config.agent}"`, 400, 'model');
|
|
460
|
+
const availability = await target.engine.available().catch((e) => ({ ok: false, reason: e?.message || String(e) }));
|
|
461
|
+
if (!availability.ok) throw new CompatError(availability.reason || `${config.agent} is unavailable`, 503, 'model');
|
|
462
|
+
|
|
463
|
+
const runId = `run_${Math.random().toString(36).slice(2, 10)}`;
|
|
464
|
+
const run = startRun(runId);
|
|
465
|
+
const onGone = () => run.cancel('disconnected');
|
|
466
|
+
res.on('close', onGone);
|
|
467
|
+
let output = '';
|
|
468
|
+
try {
|
|
469
|
+
await target.engine.chat(
|
|
470
|
+
{ messages: config.messages, system: config.system, options: config.options, images: [] },
|
|
471
|
+
(event) => {
|
|
472
|
+
if (event?.type === 'delta' && event.text) {
|
|
473
|
+
output += event.text;
|
|
474
|
+
onDelta(event.text);
|
|
475
|
+
} else if (event?.type === 'done' && event.text && !output) {
|
|
476
|
+
output = event.text;
|
|
477
|
+
onDelta(event.text);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{ signal: run.signal },
|
|
481
|
+
);
|
|
482
|
+
return output;
|
|
483
|
+
} finally {
|
|
484
|
+
res.off('close', onGone);
|
|
485
|
+
endRun(runId);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function beginSse(res) {
|
|
490
|
+
res.writeHead(200, {
|
|
491
|
+
'Content-Type': 'text/event-stream',
|
|
492
|
+
'Cache-Control': 'no-cache',
|
|
493
|
+
Connection: 'keep-alive',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function writeData(res, value) {
|
|
498
|
+
if (!res.writableEnded) res.write(`data: ${typeof value === 'string' ? value : JSON.stringify(value)}\n\n`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function writeNamedEvent(res, name, value) {
|
|
502
|
+
if (!res.writableEnded) res.write(`event: ${name}\ndata: ${JSON.stringify(value)}\n\n`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function compatibleBody(req, res, parser, errorShape) {
|
|
506
|
+
try {
|
|
507
|
+
return parser(await readBody(req));
|
|
508
|
+
} catch (error) {
|
|
509
|
+
json(res, error?.status || 400, errorShape(error?.message?.startsWith('Unexpected') ? new CompatError(`Bad JSON: ${error.message}`) : error));
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function handleOpenAIChatCompletions(req, res) {
|
|
515
|
+
const config = await compatibleBody(req, res, parseChatCompletion, openAIError);
|
|
516
|
+
if (!config) return;
|
|
517
|
+
if (!config.stream) {
|
|
518
|
+
try {
|
|
519
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
520
|
+
return json(res, 200, createChatCompletion(config.requestedModel, text));
|
|
521
|
+
} catch (error) {
|
|
522
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
beginSse(res);
|
|
528
|
+
const stream = chatCompletionStream(config.requestedModel, (event) => writeData(res, event));
|
|
529
|
+
try {
|
|
530
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
531
|
+
stream.done();
|
|
532
|
+
} catch (error) {
|
|
533
|
+
writeData(res, openAIError(error));
|
|
534
|
+
}
|
|
535
|
+
writeData(res, '[DONE]');
|
|
536
|
+
res.end();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function handleOpenAICompletions(req, res) {
|
|
540
|
+
const config = await compatibleBody(req, res, parseCompletion, openAIError);
|
|
541
|
+
if (!config) return;
|
|
542
|
+
if (!config.stream) {
|
|
543
|
+
try {
|
|
544
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
545
|
+
return json(res, 200, createCompletion(config.requestedModel, text));
|
|
546
|
+
} catch (error) {
|
|
547
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
beginSse(res);
|
|
553
|
+
const stream = completionStream(config.requestedModel, (event) => writeData(res, event));
|
|
554
|
+
try {
|
|
555
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
556
|
+
stream.done();
|
|
557
|
+
} catch (error) {
|
|
558
|
+
writeData(res, openAIError(error));
|
|
559
|
+
}
|
|
560
|
+
writeData(res, '[DONE]');
|
|
561
|
+
res.end();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function handleOpenAIResponses(req, res) {
|
|
565
|
+
const config = await compatibleBody(req, res, parseResponse, openAIError);
|
|
566
|
+
if (!config) return;
|
|
567
|
+
if (!config.stream) {
|
|
568
|
+
try {
|
|
569
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
570
|
+
return json(res, 200, createResponse(config.requestedModel, text));
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (!res.writableEnded) return json(res, error?.status || 500, openAIError(error));
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
beginSse(res);
|
|
578
|
+
const stream = responseStream(config.requestedModel, (event) => writeNamedEvent(res, event.type, event));
|
|
579
|
+
try {
|
|
580
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
581
|
+
stream.done();
|
|
582
|
+
} catch (error) {
|
|
583
|
+
const event = { type: 'error', sequence_number: 0, ...openAIError(error) };
|
|
584
|
+
writeNamedEvent(res, 'error', event);
|
|
585
|
+
}
|
|
586
|
+
res.end();
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function handleAnthropicMessages(req, res) {
|
|
590
|
+
const config = await compatibleBody(req, res, parseAnthropicMessage, anthropicError);
|
|
591
|
+
if (!config) return;
|
|
592
|
+
if (!config.stream) {
|
|
593
|
+
try {
|
|
594
|
+
const text = await runCompatibleAgent(config, () => {}, res);
|
|
595
|
+
return json(res, 200, createAnthropicMessage(config.requestedModel, text));
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (!res.writableEnded) return json(res, error?.status || 500, anthropicError(error));
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
beginSse(res);
|
|
603
|
+
const stream = anthropicStream(config.requestedModel, (name, event) => writeNamedEvent(res, name, event));
|
|
604
|
+
try {
|
|
605
|
+
await runCompatibleAgent(config, (text) => stream.delta(text), res);
|
|
606
|
+
stream.done();
|
|
607
|
+
} catch (error) {
|
|
608
|
+
writeNamedEvent(res, 'error', anthropicError(error));
|
|
609
|
+
}
|
|
610
|
+
res.end();
|
|
611
|
+
}
|
|
612
|
+
|
|
407
613
|
/**
|
|
408
614
|
* POST /cancel { id } — stop a run by name.
|
|
409
615
|
*
|
|
@@ -772,6 +978,10 @@ const server = createServer(async (req, res) => {
|
|
|
772
978
|
if (blocked) return json(res, 403, { error: blocked });
|
|
773
979
|
try {
|
|
774
980
|
if (req.method === 'GET' && url.pathname === '/health') return handleHealth(res);
|
|
981
|
+
if (req.method === 'GET' && url.pathname === '/v1/models') return handleCompatibleModels(res);
|
|
982
|
+
if (req.method === 'GET' && url.pathname.startsWith('/v1/models/')) {
|
|
983
|
+
return handleCompatibleModels(res, decodeURIComponent(url.pathname.slice('/v1/models/'.length)));
|
|
984
|
+
}
|
|
775
985
|
if (req.method === 'GET' && url.pathname === '/debug') {
|
|
776
986
|
// L6: by default expose only version + agent AVAILABILITY (a boolean) — enough
|
|
777
987
|
// to diagnose "is codex installed?". The full home dir, $PATH, and resolved
|
|
@@ -786,6 +996,10 @@ const server = createServer(async (req, res) => {
|
|
|
786
996
|
});
|
|
787
997
|
}
|
|
788
998
|
if (req.method === 'POST' && url.pathname === '/chat') return handleChat(req, res);
|
|
999
|
+
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') return handleOpenAIChatCompletions(req, res);
|
|
1000
|
+
if (req.method === 'POST' && url.pathname === '/v1/completions') return handleOpenAICompletions(req, res);
|
|
1001
|
+
if (req.method === 'POST' && url.pathname === '/v1/responses') return handleOpenAIResponses(req, res);
|
|
1002
|
+
if (req.method === 'POST' && url.pathname === '/v1/messages') return handleAnthropicMessages(req, res);
|
|
789
1003
|
// Stable endpoint: routes to the active chat. For CLIs configured once with a
|
|
790
1004
|
// fixed URL (e.g. `opencode mcp add chatpanel --url http://127.0.0.1:4319/mcp`).
|
|
791
1005
|
if (url.pathname === '/mcp') {
|