mdi-llmkit 0.1.0 → 1.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/README.md +116 -34
- package/dist/src/comparison/compareLists.d.ts +97 -0
- package/dist/src/comparison/compareLists.js +375 -0
- package/dist/src/comparison/index.d.ts +1 -0
- package/dist/src/comparison/index.js +1 -0
- package/dist/src/gptApi/functions.d.ts +21 -0
- package/dist/src/gptApi/functions.js +154 -0
- package/dist/src/gptApi/gptConversation.d.ts +43 -0
- package/dist/src/gptApi/gptConversation.js +146 -0
- package/dist/src/gptApi/index.d.ts +3 -0
- package/dist/src/gptApi/index.js +3 -0
- package/dist/src/gptApi/jsonSchemaFormat.d.ts +14 -0
- package/dist/src/gptApi/jsonSchemaFormat.js +198 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/jsonSurgery/jsonSurgery.d.ts +81 -0
- package/dist/src/jsonSurgery/jsonSurgery.js +776 -0
- package/dist/src/jsonSurgery/placemarkedJSON.d.ts +57 -0
- package/dist/src/jsonSurgery/placemarkedJSON.js +151 -0
- package/dist/tests/comparison/compareLists.test.d.ts +1 -0
- package/dist/tests/comparison/compareLists.test.js +434 -0
- package/dist/tests/gptApi/gptConversation.test.d.ts +1 -0
- package/dist/tests/gptApi/gptConversation.test.js +157 -0
- package/dist/tests/gptApi/gptSubmit.test.d.ts +1 -0
- package/dist/tests/gptApi/gptSubmit.test.js +161 -0
- package/dist/tests/gptApi/jsonSchemaFormat.test.d.ts +1 -0
- package/dist/tests/gptApi/jsonSchemaFormat.test.js +372 -0
- package/dist/tests/jsonSurgery/jsonSurgery.test.d.ts +1 -0
- package/dist/tests/jsonSurgery/jsonSurgery.test.js +729 -0
- package/dist/tests/jsonSurgery/placemarkedJSON.test.d.ts +1 -0
- package/dist/tests/jsonSurgery/placemarkedJSON.test.js +209 -0
- package/dist/tests/setupEnv.d.ts +1 -0
- package/dist/tests/setupEnv.js +4 -0
- package/dist/tests/subpathExports.test.d.ts +1 -0
- package/dist/tests/subpathExports.test.js +47 -0
- package/package.json +18 -5
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { GptConversation } from "../../src/gptApi/gptConversation.js";
|
|
3
|
+
class FakeResponse {
|
|
4
|
+
output_text;
|
|
5
|
+
error;
|
|
6
|
+
incomplete_details;
|
|
7
|
+
constructor(outputText = "", error = null, incompleteDetails = null) {
|
|
8
|
+
this.output_text = outputText;
|
|
9
|
+
this.error = error;
|
|
10
|
+
this.incomplete_details = incompleteDetails;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
class FakeResponsesAPI {
|
|
14
|
+
sideEffects;
|
|
15
|
+
createCalls;
|
|
16
|
+
constructor(sideEffects = []) {
|
|
17
|
+
this.sideEffects = [...sideEffects];
|
|
18
|
+
this.createCalls = [];
|
|
19
|
+
}
|
|
20
|
+
create(kwargs) {
|
|
21
|
+
this.createCalls.push(kwargs);
|
|
22
|
+
if (!this.sideEffects.length) {
|
|
23
|
+
return new FakeResponse();
|
|
24
|
+
}
|
|
25
|
+
const next = this.sideEffects.shift();
|
|
26
|
+
if (next instanceof Error) {
|
|
27
|
+
throw next;
|
|
28
|
+
}
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class FakeOpenAIClient {
|
|
33
|
+
responses;
|
|
34
|
+
constructor(sideEffects = []) {
|
|
35
|
+
this.responses = new FakeResponsesAPI(sideEffects);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
describe("GptConversation", () => {
|
|
39
|
+
it("defaults to empty state with no client or model", () => {
|
|
40
|
+
const conversation = new GptConversation();
|
|
41
|
+
expect(conversation).toEqual([]);
|
|
42
|
+
expect(conversation.openaiClient).toBeUndefined();
|
|
43
|
+
expect(conversation.model).toBeUndefined();
|
|
44
|
+
expect(conversation.lastReply).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
it("assignMessages replaces contents and returns self", () => {
|
|
47
|
+
const conversation = new GptConversation([{ role: "user", content: "old" }]);
|
|
48
|
+
const next = [
|
|
49
|
+
{ role: "system", content: "new" },
|
|
50
|
+
{ role: "user", content: "hello" },
|
|
51
|
+
];
|
|
52
|
+
const returned = conversation.assignMessages(next);
|
|
53
|
+
expect(returned).toBe(conversation);
|
|
54
|
+
expect(conversation).toEqual(next);
|
|
55
|
+
});
|
|
56
|
+
it("clone deep copies messages while preserving client and model refs", () => {
|
|
57
|
+
const client = new FakeOpenAIClient();
|
|
58
|
+
const conversation = new GptConversation([
|
|
59
|
+
{
|
|
60
|
+
role: "user",
|
|
61
|
+
content: JSON.stringify({ nested: { x: 1 } }),
|
|
62
|
+
},
|
|
63
|
+
], { openaiClient: client, model: "gpt-custom" });
|
|
64
|
+
const cloned = conversation.clone();
|
|
65
|
+
expect(cloned).not.toBe(conversation);
|
|
66
|
+
expect(cloned).toEqual(conversation);
|
|
67
|
+
expect(cloned.openaiClient).toBe(client);
|
|
68
|
+
expect(cloned.model).toBe("gpt-custom");
|
|
69
|
+
});
|
|
70
|
+
it("addMessage serializes object content as pretty JSON", () => {
|
|
71
|
+
const conversation = new GptConversation();
|
|
72
|
+
conversation.addMessage("user", { a: 1 });
|
|
73
|
+
expect(conversation[0]).toEqual({
|
|
74
|
+
role: "user",
|
|
75
|
+
content: '{\n "a": 1\n}',
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
it("role-specific helpers add expected role labels", () => {
|
|
79
|
+
const conversation = new GptConversation();
|
|
80
|
+
conversation.addUserMessage("u");
|
|
81
|
+
conversation.addAssistantMessage("a");
|
|
82
|
+
conversation.addSystemMessage("s");
|
|
83
|
+
conversation.addDeveloperMessage("d");
|
|
84
|
+
expect(conversation.map((m) => m.role)).toEqual([
|
|
85
|
+
"user",
|
|
86
|
+
"assistant",
|
|
87
|
+
"system",
|
|
88
|
+
"developer",
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
it("submit throws if no openai client is configured", async () => {
|
|
92
|
+
const conversation = new GptConversation([{ role: "user", content: "hello" }]);
|
|
93
|
+
await expect(conversation.submit()).rejects.toThrow("OpenAI client is not set. Please provide an OpenAI client.");
|
|
94
|
+
});
|
|
95
|
+
it("submit appends user message then assistant reply", async () => {
|
|
96
|
+
const client = new FakeOpenAIClient([new FakeResponse("assistant reply")]);
|
|
97
|
+
const conversation = new GptConversation([], { openaiClient: client });
|
|
98
|
+
const result = await conversation.submit("hello");
|
|
99
|
+
expect(result).toBe("assistant reply");
|
|
100
|
+
expect(conversation).toEqual([
|
|
101
|
+
{ role: "user", content: "hello" },
|
|
102
|
+
{ role: "assistant", content: "assistant reply" },
|
|
103
|
+
]);
|
|
104
|
+
expect(conversation.lastReply).toBe("assistant reply");
|
|
105
|
+
});
|
|
106
|
+
it("submit infers jsonResponse and role from dict-like message", async () => {
|
|
107
|
+
const client = new FakeOpenAIClient([new FakeResponse('{"ok": true}')]);
|
|
108
|
+
const conversation = new GptConversation([], { openaiClient: client });
|
|
109
|
+
const message = {
|
|
110
|
+
format: { type: "json_object" },
|
|
111
|
+
role: "developer",
|
|
112
|
+
content: "Return JSON only.",
|
|
113
|
+
};
|
|
114
|
+
const result = await conversation.submit(message, null);
|
|
115
|
+
expect(result).toEqual({ ok: true });
|
|
116
|
+
expect(conversation[0]).toEqual({ role: "developer", content: "Return JSON only." });
|
|
117
|
+
expect(conversation[1]).toEqual({ role: "assistant", content: '{\n "ok": true\n}' });
|
|
118
|
+
});
|
|
119
|
+
it("submit wrapper methods add role and return reply", async () => {
|
|
120
|
+
const client = new FakeOpenAIClient([
|
|
121
|
+
new FakeResponse("r1"),
|
|
122
|
+
new FakeResponse("r2"),
|
|
123
|
+
new FakeResponse("r3"),
|
|
124
|
+
new FakeResponse("r4"),
|
|
125
|
+
new FakeResponse("r5"),
|
|
126
|
+
]);
|
|
127
|
+
const conversation = new GptConversation([], { openaiClient: client });
|
|
128
|
+
await expect(conversation.submitMessage("system", "m1")).resolves.toBe("r1");
|
|
129
|
+
await expect(conversation.submitUserMessage("m2")).resolves.toBe("r2");
|
|
130
|
+
await expect(conversation.submitAssistantMessage("m3")).resolves.toBe("r3");
|
|
131
|
+
await expect(conversation.submitSystemMessage("m4")).resolves.toBe("r4");
|
|
132
|
+
await expect(conversation.submitDeveloperMessage("m5")).resolves.toBe("r5");
|
|
133
|
+
expect(conversation.filter((_, index) => index % 2 === 0).map((m) => m.role)).toEqual([
|
|
134
|
+
"system",
|
|
135
|
+
"user",
|
|
136
|
+
"assistant",
|
|
137
|
+
"system",
|
|
138
|
+
"developer",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
it("lastReply accessors enforce expected types", () => {
|
|
142
|
+
const conversation = new GptConversation();
|
|
143
|
+
conversation.lastReply = "hello";
|
|
144
|
+
expect(conversation.getLastReplyStr()).toBe("hello");
|
|
145
|
+
expect(conversation.getLastReplyDict()).toEqual({});
|
|
146
|
+
conversation.lastReply = { x: 10, nested: { y: 1 } };
|
|
147
|
+
expect(conversation.getLastReplyStr()).toBe("");
|
|
148
|
+
const cloned = conversation.getLastReplyDict();
|
|
149
|
+
expect(cloned).toEqual({ x: 10, nested: { y: 1 } });
|
|
150
|
+
cloned.nested.y = 2;
|
|
151
|
+
expect(conversation.lastReply.nested.y).toBe(1);
|
|
152
|
+
expect(conversation.getLastReplyDictField("x")).toBe(10);
|
|
153
|
+
expect(conversation.getLastReplyDictField("missing", 99)).toBe(99);
|
|
154
|
+
conversation.lastReply = "not dict";
|
|
155
|
+
expect(conversation.getLastReplyDictField("x", 99)).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { GPT_MODEL_SMART, gptSubmit, } from "../../src/gptApi/functions.js";
|
|
3
|
+
class FakeResponse {
|
|
4
|
+
output_text;
|
|
5
|
+
error;
|
|
6
|
+
incomplete_details;
|
|
7
|
+
constructor(outputText = "", error = null, incompleteDetails = null) {
|
|
8
|
+
this.output_text = outputText;
|
|
9
|
+
this.error = error;
|
|
10
|
+
this.incomplete_details = incompleteDetails;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
class FakeResponsesAPI {
|
|
14
|
+
sideEffects;
|
|
15
|
+
createCalls;
|
|
16
|
+
constructor(sideEffects = []) {
|
|
17
|
+
this.sideEffects = [...sideEffects];
|
|
18
|
+
this.createCalls = [];
|
|
19
|
+
}
|
|
20
|
+
create(kwargs) {
|
|
21
|
+
this.createCalls.push(kwargs);
|
|
22
|
+
if (!this.sideEffects.length) {
|
|
23
|
+
return new FakeResponse();
|
|
24
|
+
}
|
|
25
|
+
const next = this.sideEffects.shift();
|
|
26
|
+
if (next instanceof Error) {
|
|
27
|
+
throw next;
|
|
28
|
+
}
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
class FakeOpenAIClient {
|
|
33
|
+
responses;
|
|
34
|
+
constructor(sideEffects = []) {
|
|
35
|
+
this.responses = new FakeResponsesAPI(sideEffects);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
class OpenAIError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "OpenAIError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
describe("gptSubmit", () => {
|
|
45
|
+
it("uses default model and omits text config when json mode is disabled", async () => {
|
|
46
|
+
const client = new FakeOpenAIClient([new FakeResponse("ok")]);
|
|
47
|
+
const result = await gptSubmit([{ role: "user", content: "Hello" }], client);
|
|
48
|
+
expect(result).toBe("ok");
|
|
49
|
+
expect(client.responses.createCalls).toHaveLength(1);
|
|
50
|
+
const request = client.responses.createCalls[0];
|
|
51
|
+
expect(request.model).toBe(GPT_MODEL_SMART);
|
|
52
|
+
expect("text" in request).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it("prepends datetime system message and keeps user messages after it", async () => {
|
|
55
|
+
const client = new FakeOpenAIClient([new FakeResponse("ok")]);
|
|
56
|
+
const messages = [{ role: "user", content: "Hello" }];
|
|
57
|
+
await gptSubmit(messages, client);
|
|
58
|
+
const submitted = client.responses.createCalls[0].input;
|
|
59
|
+
expect(submitted[0].role).toBe("system");
|
|
60
|
+
expect(submitted[0].content.startsWith("!DATETIME:")).toBe(true);
|
|
61
|
+
expect(submitted.slice(1)).toEqual(messages);
|
|
62
|
+
});
|
|
63
|
+
it("replaces stale datetime messages and keeps other system messages", async () => {
|
|
64
|
+
const client = new FakeOpenAIClient([new FakeResponse("ok")]);
|
|
65
|
+
const messages = [
|
|
66
|
+
{ role: "system", content: "!DATETIME: old timestamp" },
|
|
67
|
+
{ role: "system", content: "keep me" },
|
|
68
|
+
{ role: "user", content: "hello" },
|
|
69
|
+
];
|
|
70
|
+
await gptSubmit(messages, client);
|
|
71
|
+
const submitted = client.responses.createCalls[0].input;
|
|
72
|
+
const datetimeMessages = submitted.filter((m) => m.role === "system" && typeof m.content === "string" && m.content.startsWith("!DATETIME:"));
|
|
73
|
+
expect(datetimeMessages).toHaveLength(1);
|
|
74
|
+
expect(submitted.slice(1)).toEqual(messages.slice(1));
|
|
75
|
+
});
|
|
76
|
+
it("supports json_response=true with json_object text format", async () => {
|
|
77
|
+
const client = new FakeOpenAIClient([new FakeResponse('{"value":1}')]);
|
|
78
|
+
const result = await gptSubmit([{ role: "user", content: "json" }], client, {
|
|
79
|
+
jsonResponse: true,
|
|
80
|
+
});
|
|
81
|
+
expect(result).toEqual({ value: 1 });
|
|
82
|
+
const request = client.responses.createCalls[0];
|
|
83
|
+
expect(request.text).toEqual({ format: { type: "json_object" } });
|
|
84
|
+
});
|
|
85
|
+
it("deep copies json schema dict and appends no-unicode note without mutating input", async () => {
|
|
86
|
+
const schema = {
|
|
87
|
+
format: {
|
|
88
|
+
type: "json_schema",
|
|
89
|
+
name: "answer",
|
|
90
|
+
description: "Return answer",
|
|
91
|
+
schema: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: { answer: { type: "string" } },
|
|
94
|
+
required: ["answer"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
const client = new FakeOpenAIClient([new FakeResponse('{"answer":"ok"}')]);
|
|
99
|
+
const result = await gptSubmit([{ role: "user", content: "json" }], client, {
|
|
100
|
+
jsonResponse: schema,
|
|
101
|
+
});
|
|
102
|
+
expect(result).toEqual({ answer: "ok" });
|
|
103
|
+
expect(schema.format.description).toBe("Return answer");
|
|
104
|
+
const submitted = client.responses.createCalls[0].text;
|
|
105
|
+
expect(submitted.format.description).toContain("ABSOLUTELY NO UNICODE ALLOWED");
|
|
106
|
+
});
|
|
107
|
+
it("parses first json object when response contains trailing json", async () => {
|
|
108
|
+
const client = new FakeOpenAIClient([new FakeResponse('{"first":1}{"second":2}')]);
|
|
109
|
+
const result = await gptSubmit([{ role: "user", content: "json" }], client, {
|
|
110
|
+
jsonResponse: true,
|
|
111
|
+
});
|
|
112
|
+
expect(result).toEqual({ first: 1 });
|
|
113
|
+
});
|
|
114
|
+
it("retries openai errors and succeeds", async () => {
|
|
115
|
+
const warnings = [];
|
|
116
|
+
const client = new FakeOpenAIClient([new OpenAIError("temporary"), new FakeResponse("ok")]);
|
|
117
|
+
const result = await gptSubmit([{ role: "user", content: "hello" }], client, {
|
|
118
|
+
retryLimit: 2,
|
|
119
|
+
retryBackoffTimeSeconds: 0,
|
|
120
|
+
warningCallback: (message) => warnings.push(message),
|
|
121
|
+
});
|
|
122
|
+
expect(result).toBe("ok");
|
|
123
|
+
expect(client.responses.createCalls).toHaveLength(2);
|
|
124
|
+
expect(warnings).toHaveLength(1);
|
|
125
|
+
expect(warnings[0]).toContain("OpenAI API error");
|
|
126
|
+
expect(warnings[0]).toContain("Retrying (attempt 1 of 2)");
|
|
127
|
+
});
|
|
128
|
+
it("retries json decode errors and succeeds", async () => {
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const client = new FakeOpenAIClient([new FakeResponse("not json"), new FakeResponse('{"ok":true}')]);
|
|
131
|
+
const result = await gptSubmit([{ role: "user", content: "hello" }], client, {
|
|
132
|
+
jsonResponse: true,
|
|
133
|
+
retryLimit: 2,
|
|
134
|
+
warningCallback: (message) => warnings.push(message),
|
|
135
|
+
});
|
|
136
|
+
expect(result).toEqual({ ok: true });
|
|
137
|
+
expect(client.responses.createCalls).toHaveLength(2);
|
|
138
|
+
expect(warnings).toHaveLength(1);
|
|
139
|
+
expect(warnings[0]).toContain("JSON decode error");
|
|
140
|
+
});
|
|
141
|
+
it("throws for malformed response output_text without retry", async () => {
|
|
142
|
+
const client = new FakeOpenAIClient([new FakeResponse(null)]);
|
|
143
|
+
await expect(gptSubmit([{ role: "user", content: "hello" }], client, {
|
|
144
|
+
retryLimit: 5,
|
|
145
|
+
})).rejects.toBeInstanceOf(TypeError);
|
|
146
|
+
expect(client.responses.createCalls).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
it("throws immediately if jsonResponse string is invalid json", async () => {
|
|
149
|
+
const client = new FakeOpenAIClient([new FakeResponse('{"unused":true}')]);
|
|
150
|
+
await expect(gptSubmit([{ role: "user", content: "hello" }], client, {
|
|
151
|
+
jsonResponse: "{not valid json",
|
|
152
|
+
})).rejects.toBeInstanceOf(SyntaxError);
|
|
153
|
+
expect(client.responses.createCalls).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
it("throws unknown error when retryLimit is zero", async () => {
|
|
156
|
+
const client = new FakeOpenAIClient([new FakeResponse("ok")]);
|
|
157
|
+
await expect(gptSubmit([{ role: "user", content: "hello" }], client, {
|
|
158
|
+
retryLimit: 0,
|
|
159
|
+
})).rejects.toThrow("Unknown error occurred in gptSubmit");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|