@cartanova/qgrid-ai-sdk 0.1.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.
- package/README.md +250 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.js +750 -0
- package/e2e/e2e-logger.ts +112 -0
- package/e2e/e2e.ts +217 -0
- package/package.json +31 -0
- package/src/index.test.ts +338 -0
- package/src/index.ts +396 -0
- package/src/index.types.ts +131 -0
- package/src/logger.test.ts +563 -0
- package/src/logger.ts +364 -0
- package/src/utils.ts +305 -0
- package/tsconfig.json +15 -0
- package/tsdown.config.ts +9 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartanova/qgrid-ai-sdk Logger E2E Test
|
|
3
|
+
*
|
|
4
|
+
* TelemetryIntegration 기반 logger가 실서버에 run lifecycle을 기록하는지 검증.
|
|
5
|
+
* qgrid 서버(localhost:44900)가 떠 있어야 합니다.
|
|
6
|
+
*
|
|
7
|
+
* 사용법: pnpm --filter @cartanova/qgrid-ai-sdk e2e:logger
|
|
8
|
+
*/
|
|
9
|
+
import { generateText, stepCountIs, tool } from "ai";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { createQgridLogger, qgrid } from "../src/index";
|
|
13
|
+
|
|
14
|
+
const SERVER = process.env.QGRID_URL ?? "http://localhost:44900";
|
|
15
|
+
const MODEL = (process.env.QGRID_MODEL ?? "openai/gpt-5.5") as Parameters<typeof qgrid>[0];
|
|
16
|
+
|
|
17
|
+
let passed = 0;
|
|
18
|
+
let failed = 0;
|
|
19
|
+
|
|
20
|
+
async function test(name: string, fn: () => Promise<void>) {
|
|
21
|
+
try {
|
|
22
|
+
await fn();
|
|
23
|
+
passed++;
|
|
24
|
+
console.log(` ✓ ${name}`);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
failed++;
|
|
27
|
+
console.error(` ✗ ${name}`);
|
|
28
|
+
console.error(` ${(e as Error).message}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
33
|
+
if (!condition) throw new Error(message);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WEATHER_DB: Record<string, { temperature: number; condition: string }> = {
|
|
37
|
+
Seoul: { temperature: 22, condition: "sunny" },
|
|
38
|
+
Busan: { temperature: 26, condition: "partly cloudy" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
console.log(`\n@cartanova/qgrid-ai-sdk Logger E2E (server: ${SERVER}, model: ${MODEL})\n`);
|
|
43
|
+
|
|
44
|
+
// 1. 단순 텍스트 + logger
|
|
45
|
+
await test("logger: simple text generation", async () => {
|
|
46
|
+
const logger = createQgridLogger({
|
|
47
|
+
serverUrl: SERVER,
|
|
48
|
+
projectName: "e2e-logger",
|
|
49
|
+
tokenName: "e2e-test",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const result = await generateText({
|
|
53
|
+
model: qgrid(MODEL),
|
|
54
|
+
prompt: 'Say exactly: "logger-ok"',
|
|
55
|
+
experimental_telemetry: { integrations: [logger] },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
assert(result.text.length > 0, "empty response");
|
|
59
|
+
console.log(` text="${result.text.slice(0, 50)}"`);
|
|
60
|
+
|
|
61
|
+
// 대기 후 DB 검증
|
|
62
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
63
|
+
|
|
64
|
+
const logRes = await fetch(
|
|
65
|
+
`${SERVER}/api/requestLog/findMany?subset=A&rawParams%5Bnum%5D=1&rawParams%5Bpage%5D=1&rawParams%5BorderBy%5D=id-desc`,
|
|
66
|
+
);
|
|
67
|
+
const logData = (await logRes.json()) as { rows: Array<Record<string, unknown>> };
|
|
68
|
+
const log = logData.rows[0];
|
|
69
|
+
|
|
70
|
+
// qgrid provider가 이미 lifecycle을 관리하므로 logger는 skip해야 함
|
|
71
|
+
// 하지만 단순 호출이라 qgrid provider의 runState도 생성 안 됨 (tools 없음)
|
|
72
|
+
// 이 경우 둘 다 기록하지 않거나, qgrid provider만 기록
|
|
73
|
+
console.log(` latest log id=${log?.id}, status=${log?.status}`);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 2. tool calling + logger (qgrid provider 사용 — logger가 skip하는지 검증)
|
|
77
|
+
await test("logger: skips when qgrid provider is used", async () => {
|
|
78
|
+
const errors: Error[] = [];
|
|
79
|
+
const logger = createQgridLogger({
|
|
80
|
+
serverUrl: SERVER,
|
|
81
|
+
projectName: "e2e-logger",
|
|
82
|
+
onLogError: (err) => errors.push(err),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await generateText({
|
|
86
|
+
model: qgrid(MODEL),
|
|
87
|
+
prompt: "What is the weather in Seoul? Use the getWeather tool.",
|
|
88
|
+
tools: {
|
|
89
|
+
getWeather: tool({
|
|
90
|
+
description: "Get weather for a city",
|
|
91
|
+
inputSchema: z.object({ city: z.string() }),
|
|
92
|
+
execute: async ({ city }) => WEATHER_DB[city] ?? { temperature: 0, condition: "unknown" },
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
stopWhen: stepCountIs(3),
|
|
96
|
+
experimental_telemetry: { integrations: [logger] },
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
assert(result.text.length > 0, "empty response");
|
|
100
|
+
assert(errors.length === 0, `logger errors: ${errors.map((e) => e.message).join(", ")}`);
|
|
101
|
+
console.log(` text="${result.text.slice(0, 80)}"`);
|
|
102
|
+
console.log(` logger skipped (qgrid provider detected)`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
|
106
|
+
if (failed > 0) process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
main().catch((e) => {
|
|
110
|
+
console.error("E2E failed:", e);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
});
|
package/e2e/e2e.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartanova/qgrid-ai-sdk E2E Test Suite
|
|
3
|
+
*
|
|
4
|
+
* 실제 qgrid 서버(localhost:44900)에 요청을 보내는 통합 테스트.
|
|
5
|
+
* 서버가 떠 있어야 합니다.
|
|
6
|
+
*
|
|
7
|
+
* 사용법: pnpm --filter @cartanova/qgrid-ai-sdk e2e
|
|
8
|
+
*/
|
|
9
|
+
import { generateText, stepCountIs, tool } from "ai";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
import { qgrid } from "../src/index";
|
|
13
|
+
|
|
14
|
+
const SERVER = process.env.QGRID_URL ?? "http://localhost:44900";
|
|
15
|
+
const MODEL = (process.env.QGRID_MODEL ?? "openai/gpt-5.5") as Parameters<typeof qgrid>[0];
|
|
16
|
+
|
|
17
|
+
// ── Test runner ────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
|
|
22
|
+
async function test(name: string, fn: () => Promise<void>) {
|
|
23
|
+
try {
|
|
24
|
+
await fn();
|
|
25
|
+
passed++;
|
|
26
|
+
console.log(` ✓ ${name}`);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
failed++;
|
|
29
|
+
console.error(` ✗ ${name}`);
|
|
30
|
+
console.error(` ${(e as Error).message}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function assert(condition: unknown, message: string): asserts condition {
|
|
35
|
+
if (!condition) throw new Error(message);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Mock data ──────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
const WEATHER_DB: Record<string, { temperature: number; condition: string }> = {
|
|
41
|
+
Seoul: { temperature: 22, condition: "sunny" },
|
|
42
|
+
Busan: { temperature: 26, condition: "partly cloudy" },
|
|
43
|
+
Daegu: { temperature: 28, condition: "hot" },
|
|
44
|
+
Jeju: { temperature: 24, condition: "rainy" },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const RESTAURANT_DB: Record<
|
|
48
|
+
string,
|
|
49
|
+
Array<{ id: string; name: string; cuisine: string; rating: number }>
|
|
50
|
+
> = {
|
|
51
|
+
Daegu: [
|
|
52
|
+
{ id: "r6", name: "대구 막창", cuisine: "BBQ", rating: 4.4 },
|
|
53
|
+
{ id: "r7", name: "안지랑 곱창", cuisine: "Korean", rating: 4.7 },
|
|
54
|
+
],
|
|
55
|
+
Busan: [
|
|
56
|
+
{ id: "r3", name: "해운대 회센터", cuisine: "Seafood", rating: 4.7 },
|
|
57
|
+
{ id: "r4", name: "서면 돼지국밥", cuisine: "Korean", rating: 4.9 },
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ── Tests ──────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
console.log(`\n@cartanova/qgrid-ai-sdk E2E (server: ${SERVER}, model: ${MODEL})\n`);
|
|
65
|
+
|
|
66
|
+
// 1. 단순 텍스트
|
|
67
|
+
await test("simple text generation", async () => {
|
|
68
|
+
const result = await generateText({
|
|
69
|
+
model: qgrid(MODEL),
|
|
70
|
+
prompt: 'Respond with exactly: "hello-e2e"',
|
|
71
|
+
});
|
|
72
|
+
assert(result.text.length > 0, "empty response");
|
|
73
|
+
assert(result.finishReason === "stop", `unexpected finishReason: ${result.finishReason}`);
|
|
74
|
+
assert(result.usage?.inputTokens, "missing input token usage");
|
|
75
|
+
console.log(` text="${result.text.slice(0, 80)}"`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// 2. system prompt
|
|
79
|
+
await test("system prompt", async () => {
|
|
80
|
+
const result = await generateText({
|
|
81
|
+
model: qgrid(MODEL),
|
|
82
|
+
system: "You must reply in exactly one word.",
|
|
83
|
+
prompt: "What color is the sky?",
|
|
84
|
+
});
|
|
85
|
+
assert(result.text.length > 0, "empty response");
|
|
86
|
+
console.log(` text="${result.text.slice(0, 80)}"`);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 3. single tool call
|
|
90
|
+
await test("single tool call", async () => {
|
|
91
|
+
const result = await generateText({
|
|
92
|
+
model: qgrid(MODEL),
|
|
93
|
+
prompt: "What is the weather in Seoul? Use the getWeather tool.",
|
|
94
|
+
tools: {
|
|
95
|
+
getWeather: tool({
|
|
96
|
+
description: "Get current weather for a city",
|
|
97
|
+
inputSchema: z.object({ city: z.string() }),
|
|
98
|
+
execute: async ({ city }) => WEATHER_DB[city] ?? { temperature: 0, condition: "unknown" },
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
stopWhen: stepCountIs(3),
|
|
102
|
+
});
|
|
103
|
+
assert(result.steps.length >= 2, `expected at least 2 steps, got ${result.steps.length}`);
|
|
104
|
+
assert(result.steps[0].toolCalls.length > 0, "step 0 should have tool calls");
|
|
105
|
+
assert(result.finishReason === "stop", `unexpected finishReason: ${result.finishReason}`);
|
|
106
|
+
assert(result.text.length > 0, "empty final text");
|
|
107
|
+
console.log(` steps=${result.steps.length}, text="${result.text.slice(0, 80)}"`);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// 4. multi-tool, multi-step
|
|
111
|
+
await test("multi-tool multi-step", async () => {
|
|
112
|
+
const result = await generateText({
|
|
113
|
+
model: qgrid(MODEL),
|
|
114
|
+
prompt:
|
|
115
|
+
"Step 1: Use the getWeather tool for Seoul, Busan, and Daegu. Step 2: Identify the warmest city. Step 3: Use the searchRestaurants tool for that warmest city. Then give a final summary.",
|
|
116
|
+
tools: {
|
|
117
|
+
getWeather: tool({
|
|
118
|
+
description: "Get weather for a city (English name: Seoul, Busan, Daegu, Jeju)",
|
|
119
|
+
inputSchema: z.object({ city: z.string() }),
|
|
120
|
+
execute: async ({ city }) => WEATHER_DB[city] ?? { temperature: 0, condition: "unknown" },
|
|
121
|
+
}),
|
|
122
|
+
searchRestaurants: tool({
|
|
123
|
+
description: "Search restaurants in a city",
|
|
124
|
+
inputSchema: z.object({ city: z.string() }),
|
|
125
|
+
execute: async ({ city }) => RESTAURANT_DB[city] ?? [],
|
|
126
|
+
}),
|
|
127
|
+
},
|
|
128
|
+
stopWhen: stepCountIs(5),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const allToolCalls = result.steps.flatMap((s) => s.toolCalls);
|
|
132
|
+
const weatherCalls = allToolCalls.filter((tc) => tc.toolName === "getWeather");
|
|
133
|
+
const restaurantCalls = allToolCalls.filter((tc) => tc.toolName === "searchRestaurants");
|
|
134
|
+
const toolNames = [...new Set(allToolCalls.map((tc) => tc.toolName))];
|
|
135
|
+
|
|
136
|
+
assert(
|
|
137
|
+
allToolCalls.length >= 2,
|
|
138
|
+
`expected at least 2 total tool calls, got ${allToolCalls.length}`,
|
|
139
|
+
);
|
|
140
|
+
assert(
|
|
141
|
+
weatherCalls.length >= 2,
|
|
142
|
+
`expected at least 2 getWeather calls, got ${weatherCalls.length}`,
|
|
143
|
+
);
|
|
144
|
+
assert(result.steps.length >= 2, `expected at least 2 steps, got ${result.steps.length}`);
|
|
145
|
+
assert(result.finishReason === "stop", `unexpected finishReason: ${result.finishReason}`);
|
|
146
|
+
console.log(
|
|
147
|
+
` steps=${result.steps.length}, tools=${toolNames.join(",")}, weatherCalls=${weatherCalls.length}, restaurantCalls=${restaurantCalls.length}`,
|
|
148
|
+
);
|
|
149
|
+
console.log(` text="${result.text.slice(0, 100)}"`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 5. auto lifecycle — run이 DB에 기록되었는지 검증
|
|
153
|
+
await test("auto lifecycle (createRun → appendStep → finishRun)", async () => {
|
|
154
|
+
const result = await generateText({
|
|
155
|
+
model: qgrid(MODEL),
|
|
156
|
+
prompt: "What is the weather in Busan? Use the getWeather tool.",
|
|
157
|
+
tools: {
|
|
158
|
+
getWeather: tool({
|
|
159
|
+
description: "Get weather for a city",
|
|
160
|
+
inputSchema: z.object({ city: z.string() }),
|
|
161
|
+
execute: async ({ city }) => WEATHER_DB[city] ?? { temperature: 0, condition: "unknown" },
|
|
162
|
+
}),
|
|
163
|
+
},
|
|
164
|
+
stopWhen: stepCountIs(3),
|
|
165
|
+
});
|
|
166
|
+
assert(result.finishReason === "stop", `unexpected finishReason: ${result.finishReason}`);
|
|
167
|
+
|
|
168
|
+
// fire-and-forget appendStep 완료 대기
|
|
169
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
170
|
+
|
|
171
|
+
// 가장 최근 request_log 조회
|
|
172
|
+
const logRes = await fetch(
|
|
173
|
+
`${SERVER}/api/requestLog/findMany?subset=A&rawParams%5Bnum%5D=1&rawParams%5Bpage%5D=1&rawParams%5BorderBy%5D=id-desc`,
|
|
174
|
+
);
|
|
175
|
+
const logData = (await logRes.json()) as { rows: Array<Record<string, unknown>> };
|
|
176
|
+
const log = logData.rows[0];
|
|
177
|
+
assert(log, "no request_log found");
|
|
178
|
+
assert(log.status === "succeeded", `expected status=succeeded, got ${log.status}`);
|
|
179
|
+
assert((log.input_tokens as number) > 0, "input_tokens should be > 0");
|
|
180
|
+
assert(
|
|
181
|
+
(log.tool_call_count as number) >= 1,
|
|
182
|
+
`expected tool_call_count >= 1, got ${log.tool_call_count}`,
|
|
183
|
+
);
|
|
184
|
+
assert((log.response as string)?.length > 0, "response should not be empty");
|
|
185
|
+
|
|
186
|
+
// steps 검증
|
|
187
|
+
const stepsRes = await fetch(
|
|
188
|
+
`${SERVER}/api/requestLogStep/findMany?subset=A&rawParams%5Bnum%5D=50&rawParams%5Bpage%5D=1&rawParams%5Brequest_log_id%5D=${log.id}&rawParams%5BorderBy%5D=id-asc`,
|
|
189
|
+
);
|
|
190
|
+
const stepsData = (await stepsRes.json()) as {
|
|
191
|
+
rows: Array<Record<string, unknown>>;
|
|
192
|
+
total: number;
|
|
193
|
+
};
|
|
194
|
+
const generateSteps = stepsData.rows.filter((s) => s.type === "generate");
|
|
195
|
+
const toolSteps = stepsData.rows.filter((s) => s.type === "tool_call");
|
|
196
|
+
|
|
197
|
+
assert(
|
|
198
|
+
generateSteps.length >= 2,
|
|
199
|
+
`expected at least 2 generate steps, got ${generateSteps.length}`,
|
|
200
|
+
);
|
|
201
|
+
assert(toolSteps.length >= 1, `expected at least 1 tool_call step, got ${toolSteps.length}`);
|
|
202
|
+
|
|
203
|
+
console.log(
|
|
204
|
+
` requestLogId=${log.id}, status=${log.status}, tool_call_count=${log.tool_call_count}, steps=${stepsData.total}`,
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ── Summary ──────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
console.log(`\n ${passed} passed, ${failed} failed\n`);
|
|
211
|
+
if (failed > 0) process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
main().catch((e) => {
|
|
215
|
+
console.error("E2E failed:", e);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cartanova/qgrid-ai-sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI SDK LanguageModelV3 provider for qgrid",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsdown && mv dist/index-*.d.ts dist/index.d.ts",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"e2e": "tsx e2e/e2e.ts",
|
|
18
|
+
"e2e:logger": "tsx e2e/e2e-logger.ts"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@ai-sdk/provider": "catalog:",
|
|
22
|
+
"ai": "catalog:",
|
|
23
|
+
"tsdown": "catalog:",
|
|
24
|
+
"typescript": "catalog:",
|
|
25
|
+
"vitest": "catalog:"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@ai-sdk/provider": "^3.0.0",
|
|
29
|
+
"ai": "^6.0.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { qgrid } from "./index";
|
|
4
|
+
|
|
5
|
+
const usage = {
|
|
6
|
+
input_tokens: 10,
|
|
7
|
+
output_tokens: 5,
|
|
8
|
+
cache_creation_input_tokens: 0,
|
|
9
|
+
cache_read_input_tokens: 3,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function tool() {
|
|
17
|
+
return {
|
|
18
|
+
type: "function",
|
|
19
|
+
name: "getWeather",
|
|
20
|
+
description: "Get weather",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: { city: { type: "string" } },
|
|
24
|
+
required: ["city"],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toolPrompt(callIds: string[]) {
|
|
30
|
+
return [
|
|
31
|
+
{ role: "user", content: [{ type: "text", text: "weather" }] },
|
|
32
|
+
...callIds.flatMap((callId) => [
|
|
33
|
+
{
|
|
34
|
+
role: "assistant",
|
|
35
|
+
content: [
|
|
36
|
+
{
|
|
37
|
+
type: "tool-call",
|
|
38
|
+
toolCallId: callId,
|
|
39
|
+
toolName: "getWeather",
|
|
40
|
+
input: { city: callId },
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
role: "tool",
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "tool-result",
|
|
49
|
+
toolCallId: callId,
|
|
50
|
+
output: { temperature: callId },
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
]),
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sseDone(data: unknown) {
|
|
59
|
+
const encoder = new TextEncoder();
|
|
60
|
+
return new ReadableStream<Uint8Array>({
|
|
61
|
+
start(controller) {
|
|
62
|
+
controller.enqueue(encoder.encode(`event: done\ndata: ${JSON.stringify(data)}\n\n`));
|
|
63
|
+
controller.close();
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("qgrid AI SDK provider", () => {
|
|
69
|
+
it("sends tools and maps tool-call response", async () => {
|
|
70
|
+
let queryBody: unknown;
|
|
71
|
+
vi.stubGlobal(
|
|
72
|
+
"fetch",
|
|
73
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
74
|
+
const body = JSON.parse(String(init?.body));
|
|
75
|
+
if (url.includes("/query")) {
|
|
76
|
+
queryBody = body;
|
|
77
|
+
return new Response(
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
text: "",
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "tool-call",
|
|
83
|
+
toolCallId: "call_weather",
|
|
84
|
+
toolName: "getWeather",
|
|
85
|
+
input: JSON.stringify({ city: "Seoul" }),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
finishReason: "tool-calls",
|
|
89
|
+
model: "gpt-5.5",
|
|
90
|
+
usage,
|
|
91
|
+
durationMs: 100,
|
|
92
|
+
costUsd: 0.01,
|
|
93
|
+
runContext: { requestLogId: 1 },
|
|
94
|
+
}),
|
|
95
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return new Response("{}", { status: 200 });
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const result = await qgrid("openai/gpt-5.5").doGenerate({
|
|
103
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "weather" }] }],
|
|
104
|
+
tools: [tool()],
|
|
105
|
+
} as never);
|
|
106
|
+
|
|
107
|
+
expect(queryBody).toMatchObject({
|
|
108
|
+
args: {
|
|
109
|
+
prompt: "weather",
|
|
110
|
+
logMode: "run",
|
|
111
|
+
tools: [{ name: "getWeather", description: "Get weather" }],
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
expect(result.finishReason).toEqual({ unified: "tool-calls", raw: "tool_call" });
|
|
115
|
+
expect(result.content).toEqual([
|
|
116
|
+
{
|
|
117
|
+
type: "tool-call",
|
|
118
|
+
toolCallId: "call_weather",
|
|
119
|
+
toolName: "getWeather",
|
|
120
|
+
input: JSON.stringify({ city: "Seoul" }),
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("sends runContext and toolResults on tool-call follow-up", async () => {
|
|
126
|
+
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
127
|
+
|
|
128
|
+
vi.stubGlobal(
|
|
129
|
+
"fetch",
|
|
130
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
131
|
+
const body = JSON.parse(String(init?.body));
|
|
132
|
+
calls.push({ url, body });
|
|
133
|
+
|
|
134
|
+
if (url.includes("/query")) {
|
|
135
|
+
const prompt = body.args.prompt as string;
|
|
136
|
+
if (prompt === "weather") {
|
|
137
|
+
return new Response(
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
text: "",
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "tool-call",
|
|
143
|
+
toolCallId: "call_1",
|
|
144
|
+
toolName: "getWeather",
|
|
145
|
+
input: '{"city":"Seoul"}',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
finishReason: "tool-calls",
|
|
149
|
+
model: "gpt-5.5",
|
|
150
|
+
usage,
|
|
151
|
+
durationMs: 100,
|
|
152
|
+
costUsd: 0.01,
|
|
153
|
+
runContext: { requestLogId: 42 },
|
|
154
|
+
}),
|
|
155
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
// follow-up (stop)
|
|
159
|
+
return new Response(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
text: "Seoul is 22°C",
|
|
162
|
+
content: [{ type: "text", text: "Seoul is 22°C" }],
|
|
163
|
+
finishReason: "stop",
|
|
164
|
+
model: "gpt-5.5",
|
|
165
|
+
usage,
|
|
166
|
+
durationMs: 80,
|
|
167
|
+
costUsd: 0.005,
|
|
168
|
+
}),
|
|
169
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return new Response("{}", { status: 200 });
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const model = qgrid("openai/gpt-5.5");
|
|
177
|
+
|
|
178
|
+
// 턴 1: tool-calls
|
|
179
|
+
await model.doGenerate({
|
|
180
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "weather" }] }],
|
|
181
|
+
tools: [tool()],
|
|
182
|
+
} as never);
|
|
183
|
+
|
|
184
|
+
// 턴 2: follow-up with tool result
|
|
185
|
+
const result = await model.doGenerate({
|
|
186
|
+
prompt: toolPrompt(["call_1"]),
|
|
187
|
+
tools: [tool()],
|
|
188
|
+
} as never);
|
|
189
|
+
|
|
190
|
+
// follow-up 호출에 runContext + toolResults가 포함되어야 함
|
|
191
|
+
const followUpQuery = calls.filter((c) => c.url.includes("/query"))[1];
|
|
192
|
+
expect(followUpQuery?.body.args).toMatchObject({
|
|
193
|
+
logMode: "run",
|
|
194
|
+
runContext: { requestLogId: 42 },
|
|
195
|
+
toolResults: [{ toolCallId: "call_1" }],
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// SDK는 직접 createRun/appendStep/finishRun 호출 안 함
|
|
199
|
+
expect(calls.filter((c) => c.url.includes("/createRun"))).toHaveLength(0);
|
|
200
|
+
expect(calls.filter((c) => c.url.includes("/appendStep"))).toHaveLength(0);
|
|
201
|
+
expect(calls.filter((c) => c.url.includes("/finishRun"))).toHaveLength(0);
|
|
202
|
+
|
|
203
|
+
expect(result.content).toEqual([{ type: "text", text: "Seoul is 22°C" }]);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does not send logMode for non-tool doGenerate", async () => {
|
|
207
|
+
let queryBody: unknown;
|
|
208
|
+
vi.stubGlobal(
|
|
209
|
+
"fetch",
|
|
210
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
211
|
+
const body = JSON.parse(String(init?.body));
|
|
212
|
+
if (url.includes("/query")) {
|
|
213
|
+
queryBody = body;
|
|
214
|
+
return new Response(
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
text: "hello",
|
|
217
|
+
content: [{ type: "text", text: "hello" }],
|
|
218
|
+
finishReason: "stop",
|
|
219
|
+
model: "gpt-5.5",
|
|
220
|
+
usage,
|
|
221
|
+
durationMs: 50,
|
|
222
|
+
costUsd: 0.001,
|
|
223
|
+
}),
|
|
224
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return new Response("{}", { status: 200 });
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await qgrid("openai/gpt-5.5").doGenerate({
|
|
232
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
|
|
233
|
+
} as never);
|
|
234
|
+
|
|
235
|
+
// logMode가 없어야 함 (서버 auto 경로)
|
|
236
|
+
expect((queryBody as Record<string, unknown>).args).not.toHaveProperty("logMode");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("sends logMode:'run' for all doStream calls", async () => {
|
|
240
|
+
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
241
|
+
|
|
242
|
+
vi.stubGlobal(
|
|
243
|
+
"fetch",
|
|
244
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
245
|
+
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
|
246
|
+
calls.push({ url, body });
|
|
247
|
+
|
|
248
|
+
if (url.includes("/prepareStream")) {
|
|
249
|
+
return new Response(JSON.stringify({ streamId: "s1" }), {
|
|
250
|
+
status: 200,
|
|
251
|
+
headers: { "content-type": "application/json" },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (url.includes("/queryStream")) {
|
|
255
|
+
return new Response(
|
|
256
|
+
sseDone({
|
|
257
|
+
text: "streamed",
|
|
258
|
+
content: [{ type: "text", text: "streamed" }],
|
|
259
|
+
finishReason: "stop",
|
|
260
|
+
model: "gpt-5.5",
|
|
261
|
+
usage,
|
|
262
|
+
durationMs: 100,
|
|
263
|
+
costUsd: 0.01,
|
|
264
|
+
}),
|
|
265
|
+
{ status: 200 },
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return new Response("{}", { status: 200 });
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const result = await qgrid("openai/gpt-5.5").doStream({
|
|
273
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "hello" }] }],
|
|
274
|
+
} as never);
|
|
275
|
+
for await (const _part of result.stream) {
|
|
276
|
+
// drain
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const prepareCall = calls.find((c) => c.url.includes("/prepareStream"));
|
|
280
|
+
expect(prepareCall?.body.args).toMatchObject({ logMode: "run" });
|
|
281
|
+
|
|
282
|
+
// SDK는 직접 lifecycle 호출 안 함
|
|
283
|
+
expect(calls.filter((c) => c.url.includes("/createRun"))).toHaveLength(0);
|
|
284
|
+
expect(calls.filter((c) => c.url.includes("/finishRun"))).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("clears client run state when prompt does not match pending tool calls", async () => {
|
|
288
|
+
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
289
|
+
|
|
290
|
+
vi.stubGlobal(
|
|
291
|
+
"fetch",
|
|
292
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
293
|
+
const body = JSON.parse(String(init?.body));
|
|
294
|
+
calls.push({ url, body });
|
|
295
|
+
|
|
296
|
+
if (url.includes("/query")) {
|
|
297
|
+
return new Response(
|
|
298
|
+
JSON.stringify({
|
|
299
|
+
text: "",
|
|
300
|
+
content: [
|
|
301
|
+
{
|
|
302
|
+
type: "tool-call",
|
|
303
|
+
toolCallId: "call_1",
|
|
304
|
+
toolName: "getWeather",
|
|
305
|
+
input: '{"city":"Seoul"}',
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
finishReason: "tool-calls",
|
|
309
|
+
model: "gpt-5.5",
|
|
310
|
+
usage,
|
|
311
|
+
durationMs: 100,
|
|
312
|
+
costUsd: 0.01,
|
|
313
|
+
runContext: { requestLogId: 1 },
|
|
314
|
+
}),
|
|
315
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return new Response("{}", { status: 200 });
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const model = qgrid("openai/gpt-5.5");
|
|
323
|
+
await model.doGenerate({
|
|
324
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "weather" }] }],
|
|
325
|
+
tools: [tool()],
|
|
326
|
+
} as never);
|
|
327
|
+
|
|
328
|
+
// 다른 prompt로 호출 (tool result 없음) → overlap, runContext 안 보냄
|
|
329
|
+
await model.doGenerate({
|
|
330
|
+
prompt: [{ role: "user", content: [{ type: "text", text: "different" }] }],
|
|
331
|
+
tools: [tool()],
|
|
332
|
+
} as never);
|
|
333
|
+
|
|
334
|
+
const secondQuery = calls.filter((c) => c.url.includes("/query"))[1];
|
|
335
|
+
expect(secondQuery?.body.args).not.toHaveProperty("runContext");
|
|
336
|
+
expect(secondQuery?.body.args).toMatchObject({ logMode: "run" });
|
|
337
|
+
});
|
|
338
|
+
});
|