Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 55 additions & 11 deletions cli/openai-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,10 @@ function normalizeResponsesInputToChatMessages(input) {

const toRole = (value) => {
const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
if (roleRaw === 'assistant') return 'assistant';
// codex 把 AGENTS.md 注入 developer 角色;Responses 的 developer 在 chat 侧等价于 system。
if (roleRaw === 'system' || roleRaw === 'developer') return 'system';
return 'user';
};

if (input && typeof input === 'object' && !Array.isArray(input)) {
Expand Down Expand Up @@ -439,19 +442,54 @@ function normalizeResponsesToolsForResponsesApi(tools) {
.filter(Boolean);
}

function mergeLeadingSystemMessages(messages, leadingInstructions) {
const segments = [];
const seen = new Set();
const pushSegment = (text) => {
const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
segments.push(trimmed);
};
if (typeof leadingInstructions === 'string') {
pushSegment(leadingInstructions);
}
const rest = [];
for (const msg of messages) {
if (msg && msg.role === 'system') {
const content = msg.content;
if (typeof content === 'string') {
pushSegment(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === 'object' && typeof part.text === 'string') {
pushSegment(part.text);
}
}
}
continue;
}
rest.push(msg);
}
Comment on lines +458 to +473
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve non-leading system message order during merge

Line 458 currently captures every system message in the array, not just leading ones. That hoists later system messages to the front and can change model behavior for mixed-role conversations.

🔧 Proposed fix
 function mergeLeadingSystemMessages(messages, leadingInstructions) {
@@
-    const rest = [];
-    for (const msg of messages) {
-        if (msg && msg.role === 'system') {
+    const rest = [];
+    let stillLeading = true;
+    for (const msg of messages) {
+        if (stillLeading && msg && msg.role === 'system') {
             const content = msg.content;
             if (typeof content === 'string') {
                 pushSegment(content);
@@
             }
             continue;
         }
+        stillLeading = false;
         rest.push(msg);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const msg of messages) {
if (msg && msg.role === 'system') {
const content = msg.content;
if (typeof content === 'string') {
pushSegment(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === 'object' && typeof part.text === 'string') {
pushSegment(part.text);
}
}
}
continue;
}
rest.push(msg);
}
const rest = [];
let stillLeading = true;
for (const msg of messages) {
if (stillLeading && msg && msg.role === 'system') {
const content = msg.content;
if (typeof content === 'string') {
pushSegment(content);
} else if (Array.isArray(content)) {
for (const part of content) {
if (part && typeof part === 'object' && typeof part.text === 'string') {
pushSegment(part.text);
}
}
}
continue;
}
stillLeading = false;
rest.push(msg);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cli/openai-bridge.js` around lines 458 - 473, The loop currently hoists every
system message by pushing them into segments and removing them from the rest
array, which changes message order; change the logic to only extract leading
system messages (those before the first non-system message) and stop extracting
once the first non-system message is seen so subsequent system messages remain
in-place. Modify the loop over messages (variables: messages, pushSegment, rest)
to track whether a non-system message has been encountered (e.g.,
encounteredNonSystem flag), while encounteredNonSystem is false process system
messages exactly as now (string or array parts) and skip them from rest; once
you hit the first non-system message set the flag and from then on push every
message (including later system messages) into rest preserving original order.

const out = [];
if (segments.length) {
out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
}
for (const msg of rest) out.push(msg);
return out;
}

function convertResponsesRequestToChatCompletions(payload) {
const body = payload && typeof payload === 'object' ? payload : {};
const model = typeof body.model === 'string' ? body.model.trim() : '';
if (!model) {
return { error: 'responses 请求缺少 model' };
}

const messages = [];
// Align with Maxx/CLIProxyAPI style: map "instructions" to a leading system message.
if (typeof body.instructions === 'string' && body.instructions.trim()) {
messages.push({ role: 'system', content: body.instructions.trim() });
}
messages.push(...normalizeResponsesInputToChatMessages(body.input));
const rawMessages = normalizeResponsesInputToChatMessages(body.input);
// codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
// 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
const messages = mergeLeadingSystemMessages(rawMessages, body.instructions);
if (!messages.length) {
// codex sometimes sends empty input for probes; tolerate.
messages.push({ role: 'user', content: '' });
Expand Down Expand Up @@ -795,9 +833,10 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
if (!delta) continue;

const segments = [];
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
segments.push(delta.reasoning_content);
}
// DeepSeek-style OpenAI-compatible streams may emit private reasoning in
// `reasoning_content` before the final answer. Responses `output_text`
// must stay user-visible answer text only; forwarding reasoning here
// pollutes Codex output and breaks exact-answer prompts.
if (typeof delta.content === 'string' && delta.content) {
segments.push(delta.content);
}
Expand Down Expand Up @@ -833,6 +872,10 @@ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
appendChatStreamToolCall(state.toolCalls, toolCall);
}
}

if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
state.sawFinishReason = true;
}
}
}

Expand Down Expand Up @@ -1049,6 +1092,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
toolCalls: [],
finished: false,
sawDone: false,
sawFinishReason: false,
nextSeq: () => {
sequence += 1;
return sequence;
Expand Down Expand Up @@ -1103,7 +1147,7 @@ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
});
upstreamRes.on('end', () => {
if (buffer.trim()) handleEventBlock(buffer);
if (!state.finished && !state.sawDone) {
if (!state.finished && !state.sawDone && !state.sawFinishReason) {
failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
finish({ ok: true });
return;
Expand Down
214 changes: 210 additions & 4 deletions tests/unit/openai-bridge-upstream-responses.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ test('openai-bridge streams chat/completions directly when Responses client requ
await rm(tmpDir, { recursive: true, force: true });
});

test('openai-bridge forwards upstream reasoning_content as output_text delta', async () => {
test('openai-bridge omits upstream reasoning_content from output_text deltas', async () => {
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
let body = '';
Expand Down Expand Up @@ -217,10 +217,67 @@ test('openai-bridge forwards upstream reasoning_content as output_text delta', a
body: { model: 'deepseek-v4', input: 'ping', stream: true }
});
assert.equal(sse.status, 200);
assert.match(sse.text, /"delta":"thinking-"/);
assert.match(sse.text, /"delta":"step"/);
assert.doesNotMatch(sse.text, /"delta":"thinking-"/);
assert.doesNotMatch(sse.text, /"delta":"step"/);
assert.match(sse.text, /"delta":"answer"/);
assert.match(sse.text, /"text":"thinking-stepanswer"/);
assert.match(sse.text, /"text":"answer"/);
assert.match(sse.text, /data: \[DONE\]/);

await bridge.close();
await upstream.close();
await rm(tmpDir, { recursive: true, force: true });
});

test('openai-bridge completes Responses SSE when upstream chat stream closes after finish_reason without DONE', async () => {
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' });
res.write('data: {"id":"chatcmpl_stream","model":"gpt-test","choices":[{"delta":{"content":"answer"}}]}\n\n');
res.write('data: {"id":"chatcmpl_stream","model":"gpt-test","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n');
res.end();
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
const { port: upstreamPort } = await listen(upstream);

const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-'));
const settingsFile = path.join(tmpDir, 'bridge.json');
await writeFile(settingsFile, JSON.stringify({
version: 1,
providers: {
test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' }
}
}), 'utf-8');

const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' });
const bridge = http.createServer((req, res) => {
if (!handler(req, res)) {
res.statusCode = 404;
res.end('not handled');
}
});
const { port: bridgePort } = await listen(bridge);

const sse = await requestText(`http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': 'Bearer codexmate'
},
body: {
model: 'gpt-test',
input: 'ping',
stream: true
}
});
assert.equal(sse.status, 200);
assert.match(sse.text, /response\.output_text\.delta/);
assert.match(sse.text, /event: response\.completed/);
assert.match(sse.text, /"output_text":"answer"/);
assert.doesNotMatch(sse.text, /event: response\.failed/);
assert.match(sse.text, /data: \[DONE\]/);

await bridge.close();
Expand Down Expand Up @@ -810,3 +867,152 @@ test('openai-bridge falls back to /chat/completions when upstream /responses ret
await upstream.close();
await rm(tmpDir, { recursive: true, force: true });
});

test('openai-bridge merges codex developer-role AGENTS.md into a single leading system message', async () => {
let capturedChatRequest = null;
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/responses') {
// 模拟上游接受 /responses 但不可用,强制走 fallback。
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not implemented' }));
return;
}
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
capturedChatRequest = JSON.parse(body || '{}');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: 'chatcmpl_dev',
model: 'gpt-test',
choices: [{ message: { role: 'assistant', content: 'ok' } }]
}));
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
const { port: upstreamPort } = await listen(upstream);

const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-'));
const settingsFile = path.join(tmpDir, 'bridge.json');
await writeFile(settingsFile, JSON.stringify({
version: 1,
providers: {
test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' }
}
}), 'utf-8');

const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' });
const bridge = http.createServer((req, res) => {
if (!handler(req, res)) {
res.statusCode = 404;
res.end('not handled');
}
});
const { port: bridgePort } = await listen(bridge);

const url = `http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`;
const resp = await requestText(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer codexmate' },
body: {
model: 'gpt-test',
instructions: 'codex-base-prompt',
input: [
{ role: 'developer', content: [{ type: 'input_text', text: 'MISAKA_TOKEN_XYZ from AGENTS.md' }] },
{ role: 'user', content: [{ type: 'input_text', text: 'hi there' }] }
],
stream: false
}
});

assert.equal(resp.status, 200);
assert.ok(capturedChatRequest, 'fallback should hit chat/completions');
const msgs = capturedChatRequest.messages;
assert.ok(Array.isArray(msgs) && msgs.length >= 2, 'should produce system + user');
assert.equal(msgs[0].role, 'system', 'first message must be system');
assert.match(msgs[0].content, /codex-base-prompt/);
assert.match(msgs[0].content, /MISAKA_TOKEN_XYZ from AGENTS\.md/);
const systemCount = msgs.filter((m) => m && m.role === 'system').length;
assert.equal(systemCount, 1, 'multiple system sources must be merged into one');
const devLeak = msgs.find((m) => m && m.role !== 'system' && typeof m.content === 'string' && /MISAKA_TOKEN_XYZ/.test(m.content));
assert.equal(devLeak, undefined, 'AGENTS.md must not leak into user/assistant role');
const userMsg = msgs.find((m) => m && m.role === 'user');
assert.ok(userMsg, 'user message preserved');

await bridge.close();
await upstream.close();
await rm(tmpDir, { recursive: true, force: true });
});

test('openai-bridge SSE fast path also merges developer-role AGENTS.md into leading system', async () => {
let capturedChatRequest = null;
const upstream = http.createServer((req, res) => {
if (req.url === '/v1/chat/completions' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
capturedChatRequest = JSON.parse(body || '{}');
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8' });
res.write('data: {"id":"x","model":"gpt-test","choices":[{"delta":{"content":"ok"}}]}\n\n');
res.end('data: [DONE]\n\n');
});
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
const { port: upstreamPort } = await listen(upstream);

const tmpDir = await mkdtemp(path.join(os.tmpdir(), 'codexmate-bridge-test-'));
const settingsFile = path.join(tmpDir, 'bridge.json');
await writeFile(settingsFile, JSON.stringify({
version: 1,
providers: {
test: { baseUrl: `http://127.0.0.1:${upstreamPort}/v1`, apiKey: 'sk-upstream' }
}
}), 'utf-8');

const handler = createOpenaiBridgeHttpHandler({ settingsFile, expectedToken: 'codexmate' });
const bridge = http.createServer((req, res) => {
if (!handler(req, res)) {
res.statusCode = 404;
res.end('not handled');
}
});
const { port: bridgePort } = await listen(bridge);

const url = `http://127.0.0.1:${bridgePort}/bridge/openai/test/v1/responses`;
await requestText(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': 'Bearer codexmate'
},
body: {
model: 'gpt-test',
instructions: 'codex-base-prompt',
input: [
{ role: 'developer', content: [{ type: 'input_text', text: 'AGENTS_MARK_STREAM' }] },
{ role: 'user', content: [{ type: 'input_text', text: 'hi' }] }
],
stream: true
}
});

assert.ok(capturedChatRequest, 'fast path should call chat/completions');
const msgs = capturedChatRequest.messages;
assert.equal(msgs[0].role, 'system');
assert.match(msgs[0].content, /codex-base-prompt/);
assert.match(msgs[0].content, /AGENTS_MARK_STREAM/);
const leak = msgs.find((m) => m && m.role !== 'system' && typeof m.content === 'string' && /AGENTS_MARK_STREAM/.test(m.content));
assert.equal(leak, undefined, 'developer content must not leak into non-system role');

await bridge.close();
await upstream.close();
await rm(tmpDir, { recursive: true, force: true });
});
15 changes: 13 additions & 2 deletions tests/unit/provider-switch-regression.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ function createProviderUpdateContext() {
readOnly: false,
nonEditable: false
},
// c3c9ee5:updateProvider 改为本地 providersList 增量更新,fixture 须提供初始列表。
providersList: [{
name: 'alpha',
url: 'https://api.example.com/v1-old',
key: 'sk-***old',
hasKey: true
}],
showEditModal: true,
messages,
loadAllCalls: 0,
Expand Down Expand Up @@ -251,7 +258,9 @@ test('updateProvider keeps existing key when edit key input is blank', async ()
}]);
assert.strictEqual(context.showEditModal, false);
assert.deepStrictEqual(context.editingProvider, { name: '', url: '', key: '', readOnly: false, nonEditable: false, useTransform: false });
assert.strictEqual(context.loadAllCalls, 1);
// c3c9ee5:不再 loadAll,断言本地 providersList url 已更新。
assert.strictEqual(context.loadAllCalls, 0);
assert.strictEqual(context.providersList[0].url, 'https://api.example.com/v1');
assert.deepStrictEqual(context.messages, [{
text: '操作成功',
type: 'success'
Expand Down Expand Up @@ -280,7 +289,9 @@ test('updateProvider sends explicit key when user enters a new key', async () =>
}
}]);
assert.strictEqual(context.showEditModal, false);
assert.strictEqual(context.loadAllCalls, 1);
// c3c9ee5:不再 loadAll,断言本地 providersList 已挂上新 key。
assert.strictEqual(context.loadAllCalls, 0);
assert.strictEqual(context.providersList[0].hasKey, true);
assert.deepStrictEqual(context.messages, [{
text: '操作成功',
type: 'success'
Expand Down
7 changes: 6 additions & 1 deletion tests/unit/providers-validation.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ test('addProvider normalizes trimmed values and submits sanitized payload', asyn
}]);
assert.strictEqual(context.showAddModal, false);
assert.deepStrictEqual(context.newProvider, { name: '', url: '', key: '', useTransform: false, _suggestedModel: '' });
assert.deepStrictEqual(loadAllCalls, ['loadAll']);
// c3c9ee5:增删改不再触发 loadAll,改为本地 providersList 增量更新。
assert.deepStrictEqual(loadAllCalls, []);
assert.ok(
context.providersList.some((p) => p && p.name === 'beta.provider' && p.url === 'https://api.example.com/v1'),
'new provider should be appended to providersList locally'
);
assert.strictEqual(messages.length, 1);
assert.deepStrictEqual(messages[0], {
text: '操作成功',
Expand Down
Loading
Loading