diff --git a/package.json b/package.json index 883f58989..0320b2e4b 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "scripts": { "postinstall": "patch-package", "start": "node main.js", + "test": "node --test", "reinstall": "npm run clean:modules && npm install", "clean:modules": "node -e \"const fs=require('fs');const p=require('path');['node_modules','package-lock.json'].forEach(f=>{if(fs.existsSync(f)){if(fs.lstatSync(f).isDirectory()){fs.rmSync(f,{recursive:true,force:true});}else{fs.unlinkSync(f);}}});\"" }, diff --git a/src/models/gpt.js b/src/models/gpt.js index 364d24693..df021015f 100644 --- a/src/models/gpt.js +++ b/src/models/gpt.js @@ -22,11 +22,6 @@ export class GPT { } async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = strictFormat(turns); - messages = messages.map(message => { - message.content += stop_seq; - return message; - }); let model = this.model_name || "gpt-5.4-mini"; let res = null; @@ -36,7 +31,9 @@ export class GPT { // if a custom URL is set, use chat.completions // because custom "OpenAI-compatible" endpoints likely do not have responses endpoint if (this.url) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + let messages = [{'role': 'system', 'content': systemMessage}] + .concat(turns) + .map(message => ({...message})); messages = strictFormat(messages); const pack = { model: model, @@ -55,7 +52,7 @@ export class GPT { } // otherwise, use responses else { - let messages = strictFormat(turns); + let messages = strictFormat(turns.map(message => ({...message}))); messages = messages.map(message => { message.content += stop_seq; return message; diff --git a/test/gpt.test.js b/test/gpt.test.js new file mode 100644 index 000000000..46ac3564a --- /dev/null +++ b/test/gpt.test.js @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import test from 'node:test'; +import { GPT } from '../src/models/gpt.js'; + +function createGPT(url) { + const previousKey = process.env.OPENAI_API_KEY; + process.env.OPENAI_API_KEY = 'test-key'; + try { + return new GPT('unit-test-model', url); + } finally { + if (previousKey === undefined) + delete process.env.OPENAI_API_KEY; + else + process.env.OPENAI_API_KEY = previousKey; + } +} + +test('custom endpoints receive unmodified messages without mutating turns', async () => { + const gpt = createGPT('http://localhost:8080/v1'); + const turns = [ + {role: 'user', content: ' hello '}, + {role: 'assistant', content: 'world'} + ]; + const originalTurns = structuredClone(turns); + let request; + + gpt.openai.chat.completions.create = (pack) => { + request = structuredClone(pack); + return Promise.resolve({ + choices: [{ + finish_reason: 'stop', + message: {content: 'adapter-ok'} + }] + }); + }; + + const response = await gpt.sendRequest(turns, 'system prompt', ''); + + assert.equal(response, 'adapter-ok'); + assert.deepEqual(turns, originalTurns); + assert.equal(request.stop, ''); + assert.deepEqual(request.messages, [ + {role: 'user', content: 'SYSTEM: system prompt\nhello'}, + {role: 'assistant', content: 'world'} + ]); +}); + +test('Responses API prompt shaping does not mutate turns', async () => { + const gpt = createGPT(); + const turns = [ + {role: 'user', content: 'hello'}, + {role: 'assistant', content: 'world'} + ]; + const originalTurns = structuredClone(turns); + let request; + + gpt.openai.responses.create = (pack) => { + request = structuredClone(pack); + return Promise.resolve({output_text: 'responses-ok***ignored'}); + }; + + const response = await gpt.sendRequest(turns, 'system prompt'); + + assert.equal(response, 'responses-ok'); + assert.deepEqual(turns, originalTurns); + assert.deepEqual(request.input, [ + {role: 'user', content: 'hello***'}, + {role: 'assistant', content: 'world***'} + ]); +});