@langwatch/mcp-server 0.3.3 → 0.5.0

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +97 -25
  3. package/dist/archive-scenario-GAE4XVFM.js +19 -0
  4. package/dist/archive-scenario-GAE4XVFM.js.map +1 -0
  5. package/dist/chunk-AAQNA53E.js +28 -0
  6. package/dist/chunk-AAQNA53E.js.map +1 -0
  7. package/dist/chunk-JVWDWL3J.js +91 -0
  8. package/dist/chunk-JVWDWL3J.js.map +1 -0
  9. package/dist/chunk-K2YFPOSD.js +40 -0
  10. package/dist/chunk-K2YFPOSD.js.map +1 -0
  11. package/dist/chunk-ZXKLPC2E.js +27 -0
  12. package/dist/chunk-ZXKLPC2E.js.map +1 -0
  13. package/dist/config-FIQWQRUB.js +11 -0
  14. package/dist/config-FIQWQRUB.js.map +1 -0
  15. package/dist/create-prompt-P35POKBW.js +22 -0
  16. package/dist/create-prompt-P35POKBW.js.map +1 -0
  17. package/dist/create-scenario-3YRZVDYF.js +26 -0
  18. package/dist/create-scenario-3YRZVDYF.js.map +1 -0
  19. package/dist/discover-scenario-schema-MEEEVND7.js +65 -0
  20. package/dist/discover-scenario-schema-MEEEVND7.js.map +1 -0
  21. package/dist/discover-schema-3T52ORPB.js +446 -0
  22. package/dist/discover-schema-3T52ORPB.js.map +1 -0
  23. package/dist/get-analytics-BAVXTAPB.js +55 -0
  24. package/dist/get-analytics-BAVXTAPB.js.map +1 -0
  25. package/dist/get-prompt-LKCPT26O.js +48 -0
  26. package/dist/get-prompt-LKCPT26O.js.map +1 -0
  27. package/dist/get-scenario-3SCDW4Z6.js +33 -0
  28. package/dist/get-scenario-3SCDW4Z6.js.map +1 -0
  29. package/dist/get-trace-QFDWJ5D4.js +50 -0
  30. package/dist/get-trace-QFDWJ5D4.js.map +1 -0
  31. package/dist/index.js +22114 -8786
  32. package/dist/index.js.map +1 -1
  33. package/dist/list-prompts-UQPBCUYA.js +33 -0
  34. package/dist/list-prompts-UQPBCUYA.js.map +1 -0
  35. package/dist/list-scenarios-573YOUKC.js +40 -0
  36. package/dist/list-scenarios-573YOUKC.js.map +1 -0
  37. package/dist/search-traces-RSMYCAN7.js +72 -0
  38. package/dist/search-traces-RSMYCAN7.js.map +1 -0
  39. package/dist/update-prompt-G2Y5EBQY.js +31 -0
  40. package/dist/update-prompt-G2Y5EBQY.js.map +1 -0
  41. package/dist/update-scenario-SSGVOBJO.js +27 -0
  42. package/dist/update-scenario-SSGVOBJO.js.map +1 -0
  43. package/package.json +3 -3
  44. package/src/__tests__/config.unit.test.ts +89 -0
  45. package/src/__tests__/date-parsing.unit.test.ts +78 -0
  46. package/src/__tests__/discover-schema.unit.test.ts +118 -0
  47. package/src/__tests__/integration.integration.test.ts +313 -0
  48. package/src/__tests__/langwatch-api.unit.test.ts +309 -0
  49. package/src/__tests__/scenario-tools.integration.test.ts +286 -0
  50. package/src/__tests__/scenario-tools.unit.test.ts +185 -0
  51. package/src/__tests__/schemas.unit.test.ts +85 -0
  52. package/src/__tests__/tools.unit.test.ts +729 -0
  53. package/src/config.ts +31 -0
  54. package/src/index.ts +383 -0
  55. package/src/langwatch-api-scenarios.ts +67 -0
  56. package/src/langwatch-api.ts +266 -0
  57. package/src/schemas/analytics-groups.ts +78 -0
  58. package/src/schemas/analytics-metrics.ts +179 -0
  59. package/src/schemas/filter-fields.ts +119 -0
  60. package/src/schemas/index.ts +3 -0
  61. package/src/tools/archive-scenario.ts +19 -0
  62. package/src/tools/create-prompt.ts +29 -0
  63. package/src/tools/create-scenario.ts +30 -0
  64. package/src/tools/discover-scenario-schema.ts +71 -0
  65. package/src/tools/discover-schema.ts +106 -0
  66. package/src/tools/get-analytics.ts +71 -0
  67. package/src/tools/get-prompt.ts +56 -0
  68. package/src/tools/get-scenario.ts +36 -0
  69. package/src/tools/get-trace.ts +61 -0
  70. package/src/tools/list-prompts.ts +35 -0
  71. package/src/tools/list-scenarios.ts +47 -0
  72. package/src/tools/search-traces.ts +91 -0
  73. package/src/tools/update-prompt.ts +44 -0
  74. package/src/tools/update-scenario.ts +32 -0
  75. package/src/utils/date-parsing.ts +31 -0
  76. package/tests/evaluations.ipynb +634 -634
  77. package/tests/scenario-openai.test.ts +3 -1
  78. package/uv.lock +1788 -1322
@@ -0,0 +1,313 @@
1
+ import { createServer, type Server } from "http";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import { initConfig } from "../config.js";
4
+
5
+ // --- Canned responses for each API endpoint ---
6
+
7
+ const CANNED_TRACES_SEARCH = {
8
+ traces: [
9
+ {
10
+ trace_id: "trace-001",
11
+ formatted_trace: "Root [server] 1200ms\n LLM Call [llm] 500ms\n Input: Hello, how are you?\n Output: I am fine, thank you!",
12
+ input: { value: "Hello, how are you?" },
13
+ output: { value: "I am fine, thank you!" },
14
+ timestamps: { started_at: 1700000000000 },
15
+ metadata: { user_id: "user-42" },
16
+ },
17
+ ],
18
+ pagination: { totalHits: 1 },
19
+ };
20
+
21
+ const CANNED_TRACE_DETAIL = {
22
+ trace_id: "trace-001",
23
+ formatted_trace: "Root [server] 1200ms\n LLM Call [llm] 500ms\n Input: Hello\n Output: Hi there",
24
+ timestamps: {
25
+ started_at: 1700000000000,
26
+ inserted_at: 1700000001000,
27
+ },
28
+ metadata: { user_id: "user-42", thread_id: "thread-1" },
29
+ evaluations: [
30
+ {
31
+ evaluator_id: "eval-1",
32
+ name: "Faithfulness",
33
+ score: 0.95,
34
+ passed: true,
35
+ },
36
+ ],
37
+ };
38
+
39
+ const CANNED_ANALYTICS = {
40
+ currentPeriod: [
41
+ { date: "2024-01-01", "0__trace_id_cardinality": 42 },
42
+ { date: "2024-01-02", "0__trace_id_cardinality": 58 },
43
+ ],
44
+ previousPeriod: [],
45
+ };
46
+
47
+ const CANNED_PROMPTS_LIST = [
48
+ {
49
+ id: "p1",
50
+ handle: "greeting-bot",
51
+ name: "Greeting Bot",
52
+ description: "A friendly greeting bot",
53
+ latestVersionNumber: 3,
54
+ },
55
+ {
56
+ id: "p2",
57
+ handle: "qa-assistant",
58
+ name: "QA Assistant",
59
+ description: null,
60
+ latestVersionNumber: 1,
61
+ },
62
+ ];
63
+
64
+ const CANNED_PROMPT_DETAIL = {
65
+ id: "p1",
66
+ handle: "greeting-bot",
67
+ name: "Greeting Bot",
68
+ description: "A friendly greeting bot",
69
+ latestVersionNumber: 3,
70
+ versions: [
71
+ {
72
+ version: 3,
73
+ commitMessage: "Updated tone",
74
+ model: "gpt-4o",
75
+ modelProvider: "openai",
76
+ messages: [{ role: "system", content: "You are a friendly bot." }],
77
+ },
78
+ { version: 2, commitMessage: "Added greeting" },
79
+ { version: 1, commitMessage: "Initial version" },
80
+ ],
81
+ };
82
+
83
+ const CANNED_PROMPT_CREATED = {
84
+ id: "p-new",
85
+ handle: "new-prompt",
86
+ name: "New Prompt",
87
+ latestVersionNumber: 1,
88
+ };
89
+
90
+ const CANNED_PROMPT_UPDATED = {
91
+ id: "p1",
92
+ handle: "greeting-bot",
93
+ latestVersionNumber: 4,
94
+ };
95
+
96
+ // --- Mock HTTP Server ---
97
+
98
+ function createMockServer(): Server {
99
+ return createServer((req, res) => {
100
+ const authToken = req.headers["x-auth-token"];
101
+ if (authToken !== "test-integration-key") {
102
+ res.writeHead(401, { "Content-Type": "application/json" });
103
+ res.end(JSON.stringify({ message: "Invalid auth token." }));
104
+ return;
105
+ }
106
+
107
+ let body = "";
108
+ req.on("data", (chunk: string) => (body += chunk));
109
+ req.on("end", () => {
110
+ const url = req.url ?? "";
111
+ res.setHeader("Content-Type", "application/json");
112
+
113
+ if (url === "/api/traces/search" && req.method === "POST") {
114
+ res.writeHead(200);
115
+ res.end(JSON.stringify(CANNED_TRACES_SEARCH));
116
+ } else if (
117
+ url.match(/^\/api\/traces\/[^/]+(\?|$)/) &&
118
+ req.method === "GET"
119
+ ) {
120
+ res.writeHead(200);
121
+ res.end(JSON.stringify(CANNED_TRACE_DETAIL));
122
+ } else if (
123
+ url === "/api/analytics/timeseries" &&
124
+ req.method === "POST"
125
+ ) {
126
+ res.writeHead(200);
127
+ res.end(JSON.stringify(CANNED_ANALYTICS));
128
+ } else if (url === "/api/prompts" && req.method === "GET") {
129
+ res.writeHead(200);
130
+ res.end(JSON.stringify(CANNED_PROMPTS_LIST));
131
+ } else if (
132
+ url.match(/^\/api\/prompts\/[^/]+\/versions/) &&
133
+ req.method === "POST"
134
+ ) {
135
+ res.writeHead(200);
136
+ res.end(JSON.stringify(CANNED_PROMPT_UPDATED));
137
+ } else if (url.match(/^\/api\/prompts\/[^/]+$/) && req.method === "GET") {
138
+ res.writeHead(200);
139
+ res.end(JSON.stringify(CANNED_PROMPT_DETAIL));
140
+ } else if (url === "/api/prompts" && req.method === "POST") {
141
+ res.writeHead(200);
142
+ res.end(JSON.stringify(CANNED_PROMPT_CREATED));
143
+ } else if (
144
+ url.match(/^\/api\/prompts\/[^/]+$/) &&
145
+ req.method === "POST"
146
+ ) {
147
+ res.writeHead(200);
148
+ res.end(JSON.stringify(CANNED_PROMPT_UPDATED));
149
+ } else {
150
+ res.writeHead(404);
151
+ res.end(
152
+ JSON.stringify({ message: `Not found: ${req.method} ${url}` })
153
+ );
154
+ }
155
+ });
156
+ });
157
+ }
158
+
159
+ // --- Integration Tests ---
160
+
161
+ describe("MCP tools integration", () => {
162
+ let server: Server;
163
+ let port: number;
164
+
165
+ beforeAll(async () => {
166
+ server = createMockServer();
167
+ await new Promise<void>((resolve) => {
168
+ server.listen(0, () => {
169
+ const addr = server.address();
170
+ port = typeof addr === "object" && addr ? addr.port : 0;
171
+ initConfig({
172
+ apiKey: "test-integration-key",
173
+ endpoint: `http://localhost:${port}`,
174
+ });
175
+ resolve();
176
+ });
177
+ });
178
+ });
179
+
180
+ afterAll(async () => {
181
+ await new Promise<void>((resolve) => server.close(() => resolve()));
182
+ });
183
+
184
+ describe("search_traces", () => {
185
+ it("returns formatted trace digests from mock server", async () => {
186
+ const { handleSearchTraces } = await import(
187
+ "../tools/search-traces.js"
188
+ );
189
+ const result = await handleSearchTraces({
190
+ startDate: "24h",
191
+ endDate: "now",
192
+ });
193
+ expect(result).toContain("trace-001");
194
+ expect(result).toContain("LLM Call [llm] 500ms");
195
+ expect(result).toContain("1 trace");
196
+ });
197
+ });
198
+
199
+ describe("get_trace", () => {
200
+ it("returns formatted trace digest from mock server", async () => {
201
+ const { handleGetTrace } = await import("../tools/get-trace.js");
202
+ const result = await handleGetTrace({ traceId: "trace-001" });
203
+ expect(result).toContain("trace-001");
204
+ expect(result).toContain("LLM Call [llm] 500ms");
205
+ expect(result).toContain("Faithfulness");
206
+ expect(result).toContain("PASSED");
207
+ });
208
+ });
209
+
210
+ describe("get_analytics", () => {
211
+ it("returns formatted analytics data from mock server", async () => {
212
+ const { handleGetAnalytics } = await import(
213
+ "../tools/get-analytics.js"
214
+ );
215
+ const result = await handleGetAnalytics({
216
+ metric: "metadata.trace_id",
217
+ aggregation: "cardinality",
218
+ startDate: "7d",
219
+ });
220
+ expect(result).toContain("42");
221
+ expect(result).toContain("58");
222
+ });
223
+ });
224
+
225
+ describe("list_prompts", () => {
226
+ it("returns formatted prompt list from mock server", async () => {
227
+ const { handleListPrompts } = await import("../tools/list-prompts.js");
228
+ const result = await handleListPrompts();
229
+ expect(result).toContain("greeting-bot");
230
+ expect(result).toContain("Greeting Bot");
231
+ expect(result).toContain("qa-assistant");
232
+ });
233
+ });
234
+
235
+ describe("get_prompt", () => {
236
+ it("returns formatted prompt details from mock server", async () => {
237
+ const { handleGetPrompt } = await import("../tools/get-prompt.js");
238
+ const result = await handleGetPrompt({ idOrHandle: "greeting-bot" });
239
+ expect(result).toContain("Greeting Bot");
240
+ expect(result).toContain("gpt-4o");
241
+ expect(result).toContain("You are a friendly bot.");
242
+ expect(result).toContain("v3");
243
+ });
244
+ });
245
+
246
+ describe("create_prompt", () => {
247
+ it("returns success message from mock server", async () => {
248
+ const { handleCreatePrompt } = await import(
249
+ "../tools/create-prompt.js"
250
+ );
251
+ const result = await handleCreatePrompt({
252
+ name: "New Prompt",
253
+ messages: [{ role: "system", content: "You are helpful." }],
254
+ model: "gpt-4o",
255
+ modelProvider: "openai",
256
+ });
257
+ expect(result).toContain("created successfully");
258
+ expect(result).toContain("p-new");
259
+ });
260
+ });
261
+
262
+ describe("update_prompt", () => {
263
+ it("returns success message for in-place update from mock server", async () => {
264
+ const { handleUpdatePrompt } = await import(
265
+ "../tools/update-prompt.js"
266
+ );
267
+ const result = await handleUpdatePrompt({
268
+ idOrHandle: "greeting-bot",
269
+ model: "gpt-4o-mini",
270
+ commitMessage: "Switch to mini",
271
+ });
272
+ expect(result).toContain("updated successfully");
273
+ expect(result).toContain("Switch to mini");
274
+ });
275
+
276
+ it("returns success message for version creation from mock server", async () => {
277
+ const { handleUpdatePrompt } = await import(
278
+ "../tools/update-prompt.js"
279
+ );
280
+ const result = await handleUpdatePrompt({
281
+ idOrHandle: "greeting-bot",
282
+ messages: [{ role: "system", content: "Be concise." }],
283
+ createVersion: true,
284
+ commitMessage: "Make concise",
285
+ });
286
+ expect(result).toContain("version created");
287
+ expect(result).toContain("Make concise");
288
+ });
289
+ });
290
+
291
+ describe("when API key is invalid", () => {
292
+ afterAll(() => {
293
+ initConfig({
294
+ apiKey: "test-integration-key",
295
+ endpoint: `http://localhost:${port}`,
296
+ });
297
+ });
298
+
299
+ it("throws an error with 401 status", async () => {
300
+ initConfig({
301
+ apiKey: "bad-key",
302
+ endpoint: `http://localhost:${port}`,
303
+ });
304
+
305
+ const { handleSearchTraces } = await import(
306
+ "../tools/search-traces.js"
307
+ );
308
+ await expect(
309
+ handleSearchTraces({ startDate: "24h" })
310
+ ).rejects.toThrow("401");
311
+ });
312
+ });
313
+ });
@@ -0,0 +1,309 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { initConfig } from "../config.js";
3
+
4
+ const TEST_ENDPOINT = "https://test.langwatch.ai";
5
+ const TEST_API_KEY = "test-key";
6
+
7
+ describe("langwatch-api", () => {
8
+ let mockFetch: ReturnType<typeof vi.fn>;
9
+
10
+ beforeEach(() => {
11
+ initConfig({ apiKey: TEST_API_KEY, endpoint: TEST_ENDPOINT });
12
+ mockFetch = vi.fn();
13
+ vi.stubGlobal("fetch", mockFetch);
14
+ });
15
+
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ });
19
+
20
+ function mockJsonResponse(data: unknown, status = 200) {
21
+ mockFetch.mockResolvedValueOnce({
22
+ ok: status >= 200 && status < 300,
23
+ status,
24
+ text: async () => JSON.stringify(data),
25
+ json: async () => data,
26
+ });
27
+ }
28
+
29
+ function mockErrorResponse(status: number, body: string) {
30
+ mockFetch.mockResolvedValueOnce({
31
+ ok: false,
32
+ status,
33
+ text: async () => body,
34
+ });
35
+ }
36
+
37
+ describe("searchTraces()", () => {
38
+ it("sends POST to /api/traces/search with format digest by default", async () => {
39
+ const { searchTraces } = await import("../langwatch-api.js");
40
+ const responseData = { traces: [] };
41
+ mockJsonResponse(responseData);
42
+
43
+ const result = await searchTraces({
44
+ startDate: 1000,
45
+ endDate: 2000,
46
+ query: "hello",
47
+ });
48
+
49
+ const [calledUrl, calledOptions] = mockFetch.mock.calls[0]!;
50
+ expect(calledUrl).toBe(`${TEST_ENDPOINT}/api/traces/search`);
51
+ expect(calledOptions.method).toBe("POST");
52
+ expect(calledOptions.headers["X-Auth-Token"]).toBe(TEST_API_KEY);
53
+ expect(calledOptions.headers["Content-Type"]).toBe("application/json");
54
+
55
+ const parsedBody = JSON.parse(calledOptions.body as string);
56
+ expect(parsedBody).toEqual({
57
+ query: "hello",
58
+ startDate: 1000,
59
+ endDate: 2000,
60
+ format: "digest",
61
+ });
62
+ expect(result).toEqual(responseData);
63
+ });
64
+
65
+ it("sends format json when specified", async () => {
66
+ const { searchTraces } = await import("../langwatch-api.js");
67
+ mockJsonResponse({ traces: [] });
68
+
69
+ await searchTraces({
70
+ startDate: 1000,
71
+ endDate: 2000,
72
+ format: "json",
73
+ });
74
+
75
+ const parsedBody = JSON.parse(mockFetch.mock.calls[0]![1]!.body as string);
76
+ expect(parsedBody.format).toBe("json");
77
+ });
78
+
79
+ describe("when response is not OK", () => {
80
+ it("throws a descriptive error with status code and body", async () => {
81
+ const { searchTraces } = await import("../langwatch-api.js");
82
+ mockErrorResponse(401, "Unauthorized");
83
+
84
+ await expect(
85
+ searchTraces({ startDate: 1000, endDate: 2000 })
86
+ ).rejects.toThrow("401");
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("getTraceById()", () => {
92
+ it("sends GET to /api/traces/{id}?format=digest by default", async () => {
93
+ const { getTraceById } = await import("../langwatch-api.js");
94
+ const responseData = { trace: { id: "abc" } };
95
+ mockJsonResponse(responseData);
96
+
97
+ const result = await getTraceById("abc");
98
+
99
+ expect(mockFetch).toHaveBeenCalledWith(
100
+ `${TEST_ENDPOINT}/api/traces/abc?format=digest`,
101
+ expect.objectContaining({
102
+ method: "GET",
103
+ headers: expect.objectContaining({
104
+ "X-Auth-Token": TEST_API_KEY,
105
+ }),
106
+ })
107
+ );
108
+ expect(result).toEqual(responseData);
109
+ });
110
+
111
+ it("sends format=json when specified", async () => {
112
+ const { getTraceById } = await import("../langwatch-api.js");
113
+ mockJsonResponse({});
114
+
115
+ await getTraceById("abc", "json");
116
+
117
+ expect(mockFetch).toHaveBeenCalledWith(
118
+ `${TEST_ENDPOINT}/api/traces/abc?format=json`,
119
+ expect.anything()
120
+ );
121
+ });
122
+
123
+ it("does not include Content-Type for GET requests", async () => {
124
+ const { getTraceById } = await import("../langwatch-api.js");
125
+ mockJsonResponse({});
126
+
127
+ await getTraceById("abc");
128
+
129
+ const callHeaders = mockFetch.mock.calls[0]![1]!.headers as Record<
130
+ string,
131
+ string
132
+ >;
133
+ expect(callHeaders["Content-Type"]).toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe("getAnalyticsTimeseries()", () => {
138
+ it("sends POST to /api/analytics/timeseries", async () => {
139
+ const { getAnalyticsTimeseries } = await import("../langwatch-api.js");
140
+ const params = {
141
+ series: [{ metric: "performance.completion_time", aggregation: "avg" }],
142
+ startDate: 1000,
143
+ endDate: 2000,
144
+ };
145
+ const responseData = { data: [] };
146
+ mockJsonResponse(responseData);
147
+
148
+ const result = await getAnalyticsTimeseries(params);
149
+
150
+ expect(mockFetch).toHaveBeenCalledWith(
151
+ `${TEST_ENDPOINT}/api/analytics/timeseries`,
152
+ expect.objectContaining({
153
+ method: "POST",
154
+ headers: expect.objectContaining({
155
+ "X-Auth-Token": TEST_API_KEY,
156
+ "Content-Type": "application/json",
157
+ }),
158
+ body: JSON.stringify(params),
159
+ })
160
+ );
161
+ expect(result).toEqual(responseData);
162
+ });
163
+ });
164
+
165
+ describe("listPrompts()", () => {
166
+ it("sends GET to /api/prompts", async () => {
167
+ const { listPrompts } = await import("../langwatch-api.js");
168
+ const responseData = [{ id: "1", name: "test" }];
169
+ mockJsonResponse(responseData);
170
+
171
+ const result = await listPrompts();
172
+
173
+ expect(mockFetch).toHaveBeenCalledWith(
174
+ `${TEST_ENDPOINT}/api/prompts`,
175
+ expect.objectContaining({
176
+ method: "GET",
177
+ headers: expect.objectContaining({
178
+ "X-Auth-Token": TEST_API_KEY,
179
+ }),
180
+ })
181
+ );
182
+ expect(result).toEqual(responseData);
183
+ });
184
+ });
185
+
186
+ describe("getPrompt()", () => {
187
+ it("sends GET to /api/prompts/{id} with encoded ID", async () => {
188
+ const { getPrompt } = await import("../langwatch-api.js");
189
+ const responseData = { id: "1", name: "test" };
190
+ mockJsonResponse(responseData);
191
+
192
+ const result = await getPrompt("my prompt/v1");
193
+
194
+ expect(mockFetch).toHaveBeenCalledWith(
195
+ `${TEST_ENDPOINT}/api/prompts/${encodeURIComponent("my prompt/v1")}`,
196
+ expect.objectContaining({
197
+ method: "GET",
198
+ headers: expect.objectContaining({
199
+ "X-Auth-Token": TEST_API_KEY,
200
+ }),
201
+ })
202
+ );
203
+ expect(result).toEqual(responseData);
204
+ });
205
+ });
206
+
207
+ describe("createPrompt()", () => {
208
+ it("sends POST to /api/prompts with body", async () => {
209
+ const { createPrompt } = await import("../langwatch-api.js");
210
+ const data = {
211
+ name: "Test Prompt",
212
+ messages: [{ role: "system", content: "You are helpful." }],
213
+ model: "gpt-4o",
214
+ modelProvider: "openai",
215
+ };
216
+ const responseData = { id: "new-id", ...data };
217
+ mockJsonResponse(responseData);
218
+
219
+ const result = await createPrompt(data);
220
+
221
+ expect(mockFetch).toHaveBeenCalledWith(
222
+ `${TEST_ENDPOINT}/api/prompts`,
223
+ expect.objectContaining({
224
+ method: "POST",
225
+ headers: expect.objectContaining({
226
+ "X-Auth-Token": TEST_API_KEY,
227
+ "Content-Type": "application/json",
228
+ }),
229
+ body: JSON.stringify(data),
230
+ })
231
+ );
232
+ expect(result).toEqual(responseData);
233
+ });
234
+ });
235
+
236
+ describe("updatePrompt()", () => {
237
+ it("sends POST to /api/prompts/{id} with body", async () => {
238
+ const { updatePrompt } = await import("../langwatch-api.js");
239
+ const data = {
240
+ messages: [{ role: "system", content: "Updated" }],
241
+ commitMessage: "update system prompt",
242
+ };
243
+ const responseData = { id: "p1", ...data };
244
+ mockJsonResponse(responseData);
245
+
246
+ const result = await updatePrompt("p1", data);
247
+
248
+ expect(mockFetch).toHaveBeenCalledWith(
249
+ `${TEST_ENDPOINT}/api/prompts/${encodeURIComponent("p1")}`,
250
+ expect.objectContaining({
251
+ method: "POST",
252
+ headers: expect.objectContaining({
253
+ "X-Auth-Token": TEST_API_KEY,
254
+ "Content-Type": "application/json",
255
+ }),
256
+ body: JSON.stringify(data),
257
+ })
258
+ );
259
+ expect(result).toEqual(responseData);
260
+ });
261
+ });
262
+
263
+ describe("createPromptVersion()", () => {
264
+ it("sends POST to /api/prompts/{id}/versions with body", async () => {
265
+ const { createPromptVersion } = await import("../langwatch-api.js");
266
+ const data = {
267
+ messages: [{ role: "user", content: "new version" }],
268
+ commitMessage: "v2",
269
+ };
270
+ const responseData = { version: 2 };
271
+ mockJsonResponse(responseData);
272
+
273
+ const result = await createPromptVersion("p1", data);
274
+
275
+ expect(mockFetch).toHaveBeenCalledWith(
276
+ `${TEST_ENDPOINT}/api/prompts/${encodeURIComponent("p1")}/versions`,
277
+ expect.objectContaining({
278
+ method: "POST",
279
+ headers: expect.objectContaining({
280
+ "X-Auth-Token": TEST_API_KEY,
281
+ "Content-Type": "application/json",
282
+ }),
283
+ body: JSON.stringify(data),
284
+ })
285
+ );
286
+ expect(result).toEqual(responseData);
287
+ });
288
+ });
289
+
290
+ describe("when the API returns an error", () => {
291
+ it("includes the status code in the error message", async () => {
292
+ const { searchTraces } = await import("../langwatch-api.js");
293
+ mockErrorResponse(500, "Internal Server Error");
294
+
295
+ await expect(
296
+ searchTraces({ startDate: 1000, endDate: 2000 })
297
+ ).rejects.toThrow("500");
298
+ });
299
+
300
+ it("includes the response body in the error message", async () => {
301
+ const { searchTraces } = await import("../langwatch-api.js");
302
+ mockErrorResponse(403, "Forbidden: invalid API key");
303
+
304
+ await expect(
305
+ searchTraces({ startDate: 1000, endDate: 2000 })
306
+ ).rejects.toThrow("Forbidden: invalid API key");
307
+ });
308
+ });
309
+ });