@cartanova/qgrid-ai-sdk 2.4.0 → 2.4.2

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.ko.md CHANGED
@@ -203,8 +203,27 @@ const result = await generateText({
203
203
  const image = result.files[0]; // mediaType: "image/png", base64
204
204
  ```
205
205
 
206
+ 레퍼런스 이미지는 일반 AI SDK multimodal message part로 전달할 수 있습니다:
207
+
208
+ ```typescript
209
+ const result = await generateText({
210
+ model: qgrid("openai/gpt-5.4"),
211
+ messages: [
212
+ {
213
+ role: "user",
214
+ content: [
215
+ { type: "text", text: "이 이미지를 스타일 레퍼런스로 사용해서 포스터를 만들어줘" },
216
+ { type: "file", mediaType: "image/png", data: referenceImageBase64 },
217
+ ],
218
+ },
219
+ ],
220
+ providerOptions: { qgrid: { imageGeneration: true } },
221
+ });
222
+ ```
223
+
206
224
  - `streamText`에서는 거부됩니다 (non-stream 전용).
207
225
  - 이미지 생성 요청은 항상 cold one-shot thread로 실행되며 thread reuse 대상에서 제외됩니다.
226
+ - 레퍼런스 이미지는 JSON data URL로 전송됩니다. 큰 사진은 압축하거나 리사이즈해서 전달하세요. 과도하게 큰 base64 입력은 SDK가 거부하며, 사진에는 WebP/JPEG를 권장합니다.
208
227
  - 이미지 비용은 `gpt-image-2` 공개 단가표 기반 **추정치**로 request log의 `image_cost_usd`에 별도 기록됩니다 (codex가 정확한 이미지 tool 사용량을 노출하지 않음).
209
228
 
210
229
  ## Telemetry Logger
package/README.md CHANGED
@@ -203,8 +203,27 @@ const result = await generateText({
203
203
  const image = result.files[0]; // mediaType: "image/png", base64
204
204
  ```
205
205
 
206
+ Reference images can be passed through normal AI SDK multimodal message parts:
207
+
208
+ ```typescript
209
+ const result = await generateText({
210
+ model: qgrid("openai/gpt-5.4"),
211
+ messages: [
212
+ {
213
+ role: "user",
214
+ content: [
215
+ { type: "text", text: "Use this image as a style reference and create a poster" },
216
+ { type: "file", mediaType: "image/png", data: referenceImageBase64 },
217
+ ],
218
+ },
219
+ ],
220
+ providerOptions: { qgrid: { imageGeneration: true } },
221
+ });
222
+ ```
223
+
206
224
  - Rejected in `streamText` (non-stream only).
207
225
  - Image-generation requests always run on a cold one-shot thread and are excluded from thread reuse.
226
+ - Reference images are sent as JSON data URLs. Compress or resize large photos before passing them in; oversized base64 inputs are rejected by the SDK. WebP/JPEG is recommended for photos.
208
227
  - The image cost is an **estimate** based on the public `gpt-image-2` price table, recorded separately as `image_cost_usd` on the request log (codex does not expose exact image-tool usage).
209
228
 
210
229
  ## Telemetry Logger
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ const QGRID_PROVIDER_NAME = "qgrid";
3
3
  const THREAD_COORD_TTL_MS = 10 * 6e4;
4
4
  const TEXT_STREAM_ID_PREFIX = "text";
5
5
  const TOP_LEVEL_SCHEMA_WARNING = "OpenAI structured output requires \"object\". Falling back to client-side parsing.";
6
- const PENDING_TOOL_RESULTS_WARNING = "[qgrid] pending tool results not found in prompt, clearing client run state";
6
+ const PENDING_TOOL_RESULTS_WARNING = "[qgrid] tool results did not match any pending qgrid run; sending request without runContext";
7
7
  //#endregion
8
8
  //#region src/utils.ts
9
9
  async function createRun(serverUrl, body) {
@@ -84,7 +84,10 @@ async function* parseSSE(body) {
84
84
  }
85
85
  }
86
86
  }
87
- function extractPromptAndHistory(messages) {
87
+ function extractPromptAndHistory(messages, options = {}) {
88
+ const includeImages = options.includeImages ?? true;
89
+ const imageUrls = [];
90
+ let droppedImageCount = 0;
88
91
  let system;
89
92
  const nonSystem = [];
90
93
  for (const msg of messages) if (msg.role === "system") system = extractTextFromContent(msg.content);
@@ -92,28 +95,51 @@ function extractPromptAndHistory(messages) {
92
95
  if (nonSystem.length === 0) return {
93
96
  prompt: "",
94
97
  system,
95
- history: []
96
- };
97
- if (nonSystem.length === 1 && nonSystem[0].role === "user") return {
98
- prompt: extractTextFromContent(nonSystem[0].content),
99
- system,
100
- history: []
98
+ history: [],
99
+ imageUrls,
100
+ droppedImageCount
101
101
  };
102
+ if (nonSystem.length === 1 && nonSystem[0].role === "user") {
103
+ const extracted = extractUserContent(nonSystem[0].content, includeImages);
104
+ imageUrls.push(...extracted.imageUrls);
105
+ droppedImageCount += extracted.droppedImageCount;
106
+ return {
107
+ prompt: extracted.text,
108
+ system,
109
+ history: [],
110
+ imageUrls,
111
+ droppedImageCount,
112
+ ...extracted.input ? { input: extracted.input } : {}
113
+ };
114
+ }
102
115
  const last = nonSystem[nonSystem.length - 1];
103
- const prompt = last.role === "user" ? extractTextFromContent(last.content) : "";
116
+ let prompt = "";
117
+ let input;
118
+ if (last.role === "user") {
119
+ const extracted = extractUserContent(last.content, includeImages);
120
+ prompt = extracted.text;
121
+ imageUrls.push(...extracted.imageUrls);
122
+ droppedImageCount += extracted.droppedImageCount;
123
+ input = extracted.input;
124
+ }
104
125
  const historyEnd = last.role === "user" ? nonSystem.length - 1 : nonSystem.length;
105
126
  const history = [];
106
127
  for (let i = 0; i < historyEnd; i++) {
107
128
  const msg = nonSystem[i];
108
- if (msg.role === "user") history.push({
109
- type: "message",
110
- role: "user",
111
- content: [{
112
- type: "input_text",
113
- text: extractTextFromContent(msg.content)
114
- }]
115
- });
116
- else if (msg.role === "assistant") {
129
+ if (msg.role === "user") {
130
+ const extracted = extractUserContent(msg.content, includeImages);
131
+ imageUrls.push(...extracted.imageUrls);
132
+ droppedImageCount += extracted.droppedImageCount;
133
+ const content = codexContentItemsFromExtracted(extracted.parts);
134
+ history.push({
135
+ type: "message",
136
+ role: "user",
137
+ content: content.length > 0 ? content : [{
138
+ type: "input_text",
139
+ text: extracted.text
140
+ }]
141
+ });
142
+ } else if (msg.role === "assistant") {
117
143
  for (const part of msg.content) if ("text" in part && typeof part.text === "string") history.push({
118
144
  type: "message",
119
145
  role: "assistant",
@@ -143,7 +169,10 @@ function extractPromptAndHistory(messages) {
143
169
  return {
144
170
  prompt,
145
171
  system,
146
- history
172
+ history,
173
+ imageUrls,
174
+ droppedImageCount,
175
+ ...input ? { input } : {}
147
176
  };
148
177
  }
149
178
  function extractTextFromContent(content) {
@@ -152,6 +181,108 @@ function extractTextFromContent(content) {
152
181
  for (const part of content) if ("text" in part && typeof part.text === "string") parts.push(part.text);
153
182
  return parts.join("\n");
154
183
  }
184
+ function extractUserContent(content, includeImages) {
185
+ const extracted = extractMessageParts(content, { includeImages });
186
+ return {
187
+ text: extractTextFromContent(content),
188
+ parts: extracted.parts,
189
+ input: inputPartsFromExtracted(extracted.parts),
190
+ imageUrls: extracted.imageUrls,
191
+ droppedImageCount: extracted.droppedImageCount
192
+ };
193
+ }
194
+ function extractMessageParts(content, options) {
195
+ if (typeof content === "string") return {
196
+ parts: [{
197
+ kind: "text",
198
+ text: content
199
+ }],
200
+ imageUrls: [],
201
+ droppedImageCount: 0
202
+ };
203
+ const parts = [];
204
+ const imageUrls = [];
205
+ let droppedImageCount = 0;
206
+ for (const part of content) {
207
+ if ("text" in part && typeof part.text === "string") {
208
+ parts.push({
209
+ kind: "text",
210
+ text: part.text
211
+ });
212
+ continue;
213
+ }
214
+ const url = extractImageUrl(part);
215
+ if (!url) continue;
216
+ if (options.includeImages) {
217
+ parts.push({
218
+ kind: "image",
219
+ url
220
+ });
221
+ imageUrls.push(url);
222
+ } else droppedImageCount++;
223
+ }
224
+ return {
225
+ parts,
226
+ imageUrls,
227
+ droppedImageCount
228
+ };
229
+ }
230
+ function inputPartsFromExtracted(extractedParts) {
231
+ const parts = [];
232
+ for (const part of extractedParts) if (part.kind === "text") {
233
+ if (part.text.length > 0) parts.push({
234
+ type: "text",
235
+ text: part.text,
236
+ text_elements: []
237
+ });
238
+ } else parts.push({
239
+ type: "image",
240
+ url: part.url
241
+ });
242
+ return parts.some((part) => part.type === "image") ? parts : void 0;
243
+ }
244
+ function codexContentItemsFromExtracted(parts) {
245
+ const items = [];
246
+ for (const part of parts) if (part.kind === "text") items.push({
247
+ type: "input_text",
248
+ text: part.text
249
+ });
250
+ else items.push({
251
+ type: "input_image",
252
+ image_url: part.url
253
+ });
254
+ return items;
255
+ }
256
+ function extractImageUrl(part) {
257
+ if (!part || typeof part !== "object") return void 0;
258
+ const record = part;
259
+ const type = record.type;
260
+ if (type !== "file" && type !== "image") return void 0;
261
+ const mediaType = typeof record.mediaType === "string" ? record.mediaType : "image/png";
262
+ if (type === "file" && !mediaType.toLowerCase().startsWith("image/")) return void 0;
263
+ const data = record.data ?? record.image;
264
+ if (typeof data === "string") {
265
+ if (isSupportedImageUrl(data)) return data;
266
+ if (/^[a-z][a-z0-9+.-]*:/i.test(data)) return void 0;
267
+ return `data:${mediaType};base64,${data}`;
268
+ }
269
+ if (data instanceof URL) {
270
+ const url = data.toString();
271
+ return isSupportedImageUrl(url) ? url : void 0;
272
+ }
273
+ if (data instanceof Uint8Array) return `data:${mediaType};base64,${bytesToBase64(data)}`;
274
+ if (data instanceof ArrayBuffer) return `data:${mediaType};base64,${bytesToBase64(new Uint8Array(data))}`;
275
+ }
276
+ function isSupportedImageUrl(value) {
277
+ return /^(https?:|data:|blob:)/i.test(value);
278
+ }
279
+ function bytesToBase64(bytes) {
280
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
281
+ const chunkSize = 32768;
282
+ let binary = "";
283
+ for (let i = 0; i < bytes.length; i += chunkSize) binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
284
+ return btoa(binary);
285
+ }
155
286
  function safeStringify(value) {
156
287
  try {
157
288
  const json = JSON.stringify(value);
@@ -471,6 +602,21 @@ function createQgridLogger(config = {}) {
471
602
  //#endregion
472
603
  //#region src/index.ts
473
604
  const threadCoordStore = /* @__PURE__ */ new Map();
605
+ const MAX_IMAGE_INPUT_DATA_URL_CHARS = 9e5;
606
+ const MAX_IMAGE_INPUT_DATA_URL_CHARS_LABEL = `${MAX_IMAGE_INPUT_DATA_URL_CHARS / 1e3}k`;
607
+ function assertImageInputFitsJsonTransport(imageUrls) {
608
+ let totalDataUrlChars = 0;
609
+ for (const url of imageUrls) {
610
+ if (!url.toLowerCase().startsWith("data:")) continue;
611
+ totalDataUrlChars += url.length;
612
+ if (url.length > MAX_IMAGE_INPUT_DATA_URL_CHARS) throw new Error(`qgrid: reference image input is too large for JSON transport (${url.length} chars). Compress or resize it before passing it to imageGeneration, for example as WebP/JPEG under ${MAX_IMAGE_INPUT_DATA_URL_CHARS_LABEL} total base64 data-url chars.`);
613
+ }
614
+ if (totalDataUrlChars > MAX_IMAGE_INPUT_DATA_URL_CHARS) throw new Error(`qgrid: reference image inputs are too large for JSON transport (${totalDataUrlChars} total chars). Compress or resize them before passing them to imageGeneration, for example as WebP/JPEG under ${MAX_IMAGE_INPUT_DATA_URL_CHARS_LABEL} total base64 data-url chars.`);
615
+ }
616
+ function warnDroppedImages(droppedImageCount) {
617
+ if (droppedImageCount <= 0) return;
618
+ console.warn(`[qgrid] ${droppedImageCount} image message part(s) were ignored because providerOptions.qgrid.imageGeneration is not enabled.`);
619
+ }
474
620
  function getThreadCoord(sessionKey) {
475
621
  const entry = threadCoordStore.get(sessionKey);
476
622
  if (!entry) return void 0;
@@ -513,7 +659,39 @@ function qgrid(modelId, config) {
513
659
  const serverUrl = config?.serverUrl ?? process.env.QGRID_URL ?? "http://localhost:44900";
514
660
  const effort = config?.defaultEffort ?? "low";
515
661
  const projectName = config?.projectName ?? process.env.QGRID_PROJECT_NAME;
516
- let clientRun = null;
662
+ const clientRuns = /* @__PURE__ */ new Map();
663
+ let anonymousClientRunId = 0;
664
+ function clientRunKey(runContext) {
665
+ if (runContext.requestLogId !== void 0) return `request:${runContext.requestLogId}`;
666
+ if (runContext.threadCoord) {
667
+ const { workerId, epoch, threadId } = runContext.threadCoord;
668
+ return `thread:${workerId}:${epoch}:${threadId}`;
669
+ }
670
+ anonymousClientRunId++;
671
+ return `anonymous:${anonymousClientRunId}`;
672
+ }
673
+ function rememberClientRun(runContext, toolCallIds) {
674
+ if (toolCallIds.length === 0) return;
675
+ clientRuns.set(clientRunKey(runContext), {
676
+ runContext,
677
+ pendingToolCallIds: new Set(toolCallIds)
678
+ });
679
+ }
680
+ function matchClientRun(messages) {
681
+ const toolResults = extractToolResultsFromHistory(messages);
682
+ if (toolResults.length === 0) return null;
683
+ const resultIds = new Set(toolResults.map((r) => r.callId));
684
+ for (const [key, run] of clientRuns) if (run.pendingToolCallIds.size > 0 && [...run.pendingToolCallIds].every((id) => resultIds.has(id))) return {
685
+ key,
686
+ runContext: run.runContext,
687
+ toolResultsPayload: toolResults.filter((r) => run.pendingToolCallIds.has(r.callId)).map((r) => ({
688
+ toolCallId: r.callId,
689
+ output: r.result
690
+ }))
691
+ };
692
+ if (clientRuns.size > 0) console.warn(PENDING_TOOL_RESULTS_WARNING);
693
+ return null;
694
+ }
517
695
  return {
518
696
  specificationVersion: "v3",
519
697
  provider: QGRID_PROVIDER_NAME,
@@ -522,14 +700,16 @@ function qgrid(modelId, config) {
522
700
  async doGenerate(options) {
523
701
  const tools = options.tools?.filter((t) => t.type === "function");
524
702
  const hasTools = tools && tools.length > 0;
525
- const { prompt, system, history } = extractPromptAndHistory(options.prompt);
526
703
  const qgridOptions = getQgridProviderOptions(options);
704
+ const imageGeneration = qgridOptions.imageGeneration;
705
+ const { prompt, system, history, input, imageUrls, droppedImageCount } = extractPromptAndHistory(options.prompt, { includeImages: imageGeneration === true });
706
+ if (!imageGeneration) warnDroppedImages(droppedImageCount);
527
707
  const effectiveEffort = qgridOptions.effort ?? effort;
528
708
  const verbosity = qgridOptions.verbosity;
529
709
  const reasoningSummary = qgridOptions.reasoningSummary;
530
710
  const serviceTier = qgridOptions.serviceTier;
531
- const imageGeneration = qgridOptions.imageGeneration;
532
711
  const imageGenerationOptions = qgridOptions.imageGenerationOptions;
712
+ if (imageGeneration) assertImageInputFitsJsonTransport(imageUrls);
533
713
  const sessionKey = qgridOptions.sessionKey;
534
714
  const reuseSessionKey = reusableSessionKey(modelId, sessionKey);
535
715
  const rawSchema = options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0;
@@ -539,20 +719,11 @@ function qgrid(modelId, config) {
539
719
  let runContext;
540
720
  let toolResultsPayload;
541
721
  let logMode;
542
- if (clientRun) {
543
- const toolResults = extractToolResultsFromHistory(options.prompt);
544
- const resultIds = new Set(toolResults.map((r) => r.callId));
545
- if (clientRun.pendingToolCallIds.size > 0 && [...clientRun.pendingToolCallIds].every((id) => resultIds.has(id))) {
546
- runContext = clientRun.runContext;
547
- toolResultsPayload = toolResults.filter((r) => clientRun.pendingToolCallIds.has(r.callId)).map((r) => ({
548
- toolCallId: r.callId,
549
- output: r.result
550
- }));
551
- logMode = "run";
552
- } else {
553
- console.warn(PENDING_TOOL_RESULTS_WARNING);
554
- clientRun = null;
555
- }
722
+ const matchedClientRun = matchClientRun(options.prompt);
723
+ if (matchedClientRun) {
724
+ runContext = matchedClientRun.runContext;
725
+ toolResultsPayload = matchedClientRun.toolResultsPayload;
726
+ logMode = "run";
556
727
  }
557
728
  const storedCoord = reuseSessionKey ? getThreadCoord(reuseSessionKey) : void 0;
558
729
  if (!runContext && storedCoord) runContext = { threadCoord: storedCoord };
@@ -562,6 +733,7 @@ function qgrid(modelId, config) {
562
733
  headers: { "Content-Type": "application/json" },
563
734
  body: JSON.stringify({ args: {
564
735
  prompt,
736
+ ...imageGeneration && input ? { input } : {},
565
737
  model: modelId,
566
738
  system,
567
739
  effort: effectiveEffort,
@@ -616,11 +788,8 @@ function qgrid(modelId, config) {
616
788
  text: data.text
617
789
  });
618
790
  if (imageGeneration && !content.some((c) => c.type === "file")) throw new Error("qgrid: imageGeneration was requested but the response contained no image — the server may be an older version that does not support image generation.");
619
- if (data.runContext && finishReason.unified === "tool-calls") clientRun = {
620
- runContext: data.runContext,
621
- pendingToolCallIds: new Set(content.filter((c) => c.type === "tool-call").map((c) => c.toolCallId))
622
- };
623
- else clientRun = null;
791
+ if (matchedClientRun) clientRuns.delete(matchedClientRun.key);
792
+ if (data.runContext && finishReason.unified === "tool-calls") rememberClientRun(data.runContext, content.filter((c) => c.type === "tool-call").map((c) => c.toolCallId));
624
793
  if (reuseSessionKey && data.runContext?.threadCoord) setThreadCoord(reuseSessionKey, data.runContext.threadCoord);
625
794
  return {
626
795
  content,
@@ -639,13 +808,14 @@ function qgrid(modelId, config) {
639
808
  async doStream(options) {
640
809
  const tools = options.tools?.filter((t) => t.type === "function");
641
810
  const hasTools = tools && tools.length > 0;
642
- const { prompt, system, history } = extractPromptAndHistory(options.prompt);
643
811
  const qgridOptions = getQgridProviderOptions(options);
644
812
  const effectiveEffort = qgridOptions.effort ?? effort;
645
813
  const verbosity = qgridOptions.verbosity;
646
814
  const reasoningSummary = qgridOptions.reasoningSummary;
647
815
  const serviceTier = qgridOptions.serviceTier;
648
816
  if (qgridOptions.imageGeneration) throw new Error("qgrid: imageGeneration is not supported with streamText — use generateText instead.");
817
+ const { prompt, system, history, droppedImageCount } = extractPromptAndHistory(options.prompt, { includeImages: false });
818
+ warnDroppedImages(droppedImageCount);
649
819
  const sessionKey = qgridOptions.sessionKey;
650
820
  const reuseSessionKey = reusableSessionKey(modelId, sessionKey);
651
821
  const rawSchema = options.responseFormat?.type === "json" ? options.responseFormat.schema : void 0;
@@ -653,20 +823,11 @@ function qgrid(modelId, config) {
653
823
  let runContext;
654
824
  let toolResultsPayload;
655
825
  let logMode;
656
- if (clientRun) {
657
- const toolResults = extractToolResultsFromHistory(options.prompt);
658
- const resultIds = new Set(toolResults.map((r) => r.callId));
659
- if (clientRun.pendingToolCallIds.size > 0 && [...clientRun.pendingToolCallIds].every((id) => resultIds.has(id))) {
660
- runContext = clientRun.runContext;
661
- toolResultsPayload = toolResults.filter((r) => clientRun.pendingToolCallIds.has(r.callId)).map((r) => ({
662
- toolCallId: r.callId,
663
- output: r.result
664
- }));
665
- logMode = "run";
666
- } else {
667
- console.warn(PENDING_TOOL_RESULTS_WARNING);
668
- clientRun = null;
669
- }
826
+ const matchedClientRun = matchClientRun(options.prompt);
827
+ if (matchedClientRun) {
828
+ runContext = matchedClientRun.runContext;
829
+ toolResultsPayload = matchedClientRun.toolResultsPayload;
830
+ logMode = "run";
670
831
  }
671
832
  const storedCoord = reuseSessionKey ? getThreadCoord(reuseSessionKey) : void 0;
672
833
  if (!runContext && storedCoord) runContext = { threadCoord: storedCoord };
@@ -733,11 +894,8 @@ function qgrid(modelId, config) {
733
894
  textStarted = false;
734
895
  }
735
896
  const done = event.data;
736
- if (done.runContext && done.finishReason === "tool-calls") clientRun = {
737
- runContext: done.runContext,
738
- pendingToolCallIds: new Set((done.content ?? []).filter((c) => c.type === "tool-call").map((c) => c.toolCallId))
739
- };
740
- else clientRun = null;
897
+ if (matchedClientRun) clientRuns.delete(matchedClientRun.key);
898
+ if (done.runContext && done.finishReason === "tool-calls") rememberClientRun(done.runContext, (done.content ?? []).filter((c) => c.type === "tool-call").map((c) => c.toolCallId));
741
899
  if (reuseSessionKey && done.runContext?.threadCoord) setThreadCoord(reuseSessionKey, done.runContext.threadCoord);
742
900
  if (done.content) {
743
901
  for (const item of done.content) if (item.type === "text" && !deltaTextEmitted) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-ai-sdk",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "AI SDK LanguageModelV3 provider for qgrid",
5
5
  "repository": {
6
6
  "type": "git",