@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 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.26",
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.26';
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 ') ? h.slice(7) : String(req.headers['x-chatpanel-token'] || '')).trim();
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') {