@cartanova/qgrid-ai-sdk 2.7.0 → 2.7.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
@@ -4,7 +4,7 @@
4
4
 
5
5
  AI SDK v6 custom `LanguageModelV3` provider for [qgrid](https://github.com/cartanova-ai/Qgrid).
6
6
 
7
- **기존 AI SDK 코드 변경 없이, `model` 한 줄만 바꾸면 구독 토큰 풀링(토큰 N개 × worker 병렬 처리) + request log 대시보드를 사용할 수 있습니다.**
7
+ **기존 AI SDK 코드 변경 없이, `model` 한 줄만 바꾸면 구독 토큰 풀링(토큰 N개 × concurrent permit) + request log 대시보드를 사용할 수 있습니다.**
8
8
 
9
9
  ```diff
10
10
  import { generateText } from "ai";
@@ -50,6 +50,8 @@ const { text } = await generateText({
50
50
 
51
51
  qgrid 서버(`http://localhost:44900`)가 실행 중이어야 합니다.
52
52
 
53
+ OpenAI 서버 경로는 private ChatGPT Codex Responses backend를 HTTPS/SSE로 직접 호출합니다. 이 backend는 문서화되지 않아 예고 없이 변경될 수 있으며, qgrid의 mock protocol test는 live provider 검증이 아닙니다.
54
+
53
55
  ## 사용법
54
56
  > 들어가기전에: 모든 클라이언트 사용법은 [AI-SDK](https://ai-sdk.dev/docs/ai-sdk-core)와 동일합니다.
55
57
 
@@ -200,12 +202,12 @@ const { text } = await generateText({
200
202
  | 옵션 | 값 | 적용 범위 | 설명 |
201
203
  |---|---|---|---|
202
204
  | `logger` | `boolean` | 공통 | qgrid request log 저장 여부. 기본값은 `true`. `false`로 설정해도 client tool 실행과 multi-step 연결은 계속 동작 |
203
- | `sessionKey` | `string` | OpenAI 전용 | 멀티턴 대화 식별자. 같은 key는 같은 codex thread로 라우팅되어 prompt cache 적중 ([아래](#멀티턴-prompt-cache-sessionkey) 참조) |
205
+ | `sessionKey` | `string` | OpenAI 전용 | 전체 history 재전송 시 불투명 prompt-cache affinity를 파생하는 멀티턴 대화 식별자 ([아래](#멀티턴-prompt-cache-sessionkey) 참조) |
204
206
  | `effort` | `"none"` \| `"minimal"` \| `"low"` \| `"medium"` \| `"high"` \| `"xhigh"` \| `"max"` | 공통 (지원 값은 모델별 상이, 예: `"max"`는 GPT-5.6+) | reasoning 모델의 추론 깊이. 기본값은 config의 `defaultEffort` (`"low"`) |
205
207
  | `verbosity` | `"low"` \| `"medium"` \| `"high"` | OpenAI 전용 | 응답 텍스트의 상세도 |
206
208
  | `reasoningSummary` | `"auto"` \| `"concise"` \| `"detailed"` \| `"none"` | OpenAI 전용 | 추론 요약 출력 방식 |
207
209
  | `serviceTier` | `string` | OpenAI 전용 | OpenAI/codex service tier |
208
- | `timeoutMs` | 양의 정수, 최대 `1_800_000` | Anthropic 전용 | 서버의 Claude Code 프로세스 제한시간(ms). 기본값은 240초 |
210
+ | `timeoutMs` | 양의 정수, 최대 `1_800_000` | Anthropic 전용 | 서버의 Claude Code 프로세스 제한시간(ms). SDK의 non-stream HTTP 제한은 이 값보다 60초 길게 설정. 기본값은 240초 |
209
211
  | `imageGeneration` | `boolean` | OpenAI 전용, non-stream | codex 내장 `image_generation` tool 활성화 ([아래](#image-generation) 참조) |
210
212
  | `imageGenerationOptions` | `{ quality?, size? }` | OpenAI 전용 | 이미지 품질/크기 힌트. `quality: "low" \| "medium" \| "high"`, `size: "1024x1024" \| "1024x1536" \| "1536x1024"` (기본: `medium` / `1536x1024`) |
211
213
  | `fallbackModels` | `string[]` | 예약 | 향후 qgrid 서버 fallback routing용 예약 필드. 현재 동작하지 않으며 Claude Code의 Fable refusal fallback과 무관 |
@@ -213,11 +215,15 @@ const { text } = await generateText({
213
215
  AI SDK 최상위 `timeout`은 전체 클라이언트 제한시간이며 custom provider 실행 전에
214
216
  `AbortSignal`로 변환됩니다. 따라서 그 숫자 자체는 qgrid에 전달되지 않습니다. qgrid 서버의
215
217
  Claude Code 프로세스 제한시간을 바꾸려면 `providerOptions.qgrid.timeoutMs`를 사용하세요.
216
- 클라이언트 취소 또는 비스트리밍 HTTP 연결 종료도 서버의 provider 실행을 중단합니다.
218
+ Anthropic `generateText` 요청에는 전역 설정을 바꾸지 않는 요청별 Undici dispatcher가 붙고,
219
+ `headersTimeout`과 `bodyTimeout`은 `timeoutMs + 60_000`으로 설정됩니다. 예를 들어 서버 제한이
220
+ 600초면 HTTP 전송 예산은 660초입니다. 이 여유 시간 덕분에 클라이언트 전송 계층보다 서버의
221
+ 명시적 timeout 응답이 먼저 도착할 수 있습니다. 클라이언트 취소 또는 비스트리밍 HTTP 연결
222
+ 종료도 서버의 provider 실행을 중단합니다.
217
223
 
218
224
  ### 멀티턴 prompt cache (sessionKey)
219
225
 
220
- 멀티턴 대화에서 `sessionKey`로 호출자의 도메인 ID(게임 세션 ID, 채팅방 ID 등) 하나만 넘기면, provider가 같은 대화를 같은 codex thread로 라우팅하여 prompt cache를 적중시킵니다. thread 좌표의 발급/보관/회송은 provider 내부에서 처리하므로 호출자는 key만 넘기면 됩니다.
226
+ 멀티턴 대화에서 `sessionKey`로 호출자의 도메인 ID(게임 세션 ID, 채팅방 ID 등)를 넘기면 SDK가 model 범위의 불투명 affinity key를 파생합니다. Qgrid는 이를 `prompt_cache_key`로 보내고 매 요청 전체 대화 history를 재전송합니다. Provider thread나 process session은 보관하지 않습니다.
221
227
 
222
228
  ```typescript
223
229
  const { text } = await generateText({
@@ -227,8 +233,8 @@ const { text } = await generateText({
227
233
  });
228
234
  ```
229
235
 
230
- - thread는 idle 10분 후 만료됩니다. 만료되거나 worker가 재시작되면 자동으로 cold thread로 시작하므로 요청이 실패하지는 않습니다.
231
- - `anthropic/*` 모델에서는 무시됩니다 (Claude Code는 요청마다 fresh process를 띄우므로 thread reuse가 없음).
236
+ - SDK 내부 affinity coordinate entry는 idle 10분 후 만료됩니다. 동일 affinity key 파생은 이 entry에 의존하지 않습니다.
237
+ - `anthropic/*` 모델에서는 무시됩니다. Claude Code는 자체 prefix-cache 동작을 사용합니다.
232
238
 
233
239
  ### Image Generation
234
240
 
@@ -268,7 +274,7 @@ const result = await generateText({
268
274
  ```
269
275
 
270
276
  - `streamText`에서는 거부됩니다 (non-stream 전용).
271
- - 이미지 생성 요청은 항상 cold one-shot thread로 실행되며 thread reuse 대상에서 제외됩니다.
277
+ - 이미지 생성 요청은 provider 대화 상태를 보관하지 않고 전체 input을 직접 전송합니다.
272
278
  - 레퍼런스 이미지는 JSON data URL로 전송됩니다. 큰 사진은 압축하거나 리사이즈해서 전달하세요. 과도하게 큰 base64 입력은 SDK가 거부하며, 사진에는 WebP/JPEG를 권장합니다.
273
279
  - 이미지 비용은 `gpt-image-2` 공개 단가표 기반 **추정치**로 request log의 `image_cost_usd`에 별도 기록됩니다 (codex가 정확한 이미지 tool 사용량을 노출하지 않음).
274
280
 
@@ -340,7 +346,7 @@ AI SDK response metadata로 실제 serving 모델을 다르게 보고하면 step
340
346
 
341
347
  ```typescript
342
348
  type QgridSupportedModel =
343
- // OpenAI (based on codex app-server)
349
+ // OpenAI (direct private Codex Responses backend)
344
350
  | "openai/gpt-5.6-sol"
345
351
  | "openai/gpt-5.6-terra"
346
352
  | "openai/gpt-5.6-luna"
@@ -369,13 +375,13 @@ type QgridSupportedModel =
369
375
 
370
376
  ### GPT-5.6 사양
371
377
 
372
- | 모델 | Context (qgrid/codex 런타임) | 최대 출력 | 1M tokens당 input / cached input / output |
378
+ | 모델 | Context (qgrid OpenAI 경로) | 최대 출력 | 1M tokens당 input / cached input / output |
373
379
  |---|---:|---:|---:|
374
380
  | `openai/gpt-5.6-sol` | 372K | 128K | $5 / $0.50 / $30 |
375
381
  | `openai/gpt-5.6-terra` | 372K | 128K | $2.50 / $0.25 / $15 |
376
382
  | `openai/gpt-5.6-luna` | 372K | 128K | $1 / $0.10 / $6 |
377
383
 
378
- 세 모델 모두 `max` reasoning effort를 지원합니다. OpenAI native API 사양은 1.05M context / 128K 최대 출력이지만, qgrid는 codex app-server 구독 경로로 실행되어 세 모델 모두 context window가 372K(95% effective, 실사용 입력 약 353K)로 보고되며 설정으로 올릴 수 없습니다. 입력이 272K tokens를 넘으면 요청 전체에 input 2x, output 1.5x 장문 컨텍스트 할증이 적용되며, cache write는 uncached input 단가의 1.25x입니다.
384
+ 세 모델 모두 `max` reasoning effort를 지원합니다. Qgrid는 model 설정에 사용해 온 구독 경로의 관측 한도인 372K context window(95% effective, 실사용 입력 약 353K)와 128K 최대 출력을 유지합니다. 이는 OpenAI public API에 표시된 1.05M context보다 작으며 local runtime의 제약으로 귀속하지 않습니다. 입력이 272K tokens를 넘으면 요청 전체에 input 2x, output 1.5x 장문 컨텍스트 할증이 적용되며, cache write는 uncached input 단가의 1.25x입니다.
379
385
 
380
386
  `anthropic/claude-fable-5`는 1M context와 128K 최대 출력을 지원합니다. 1M tokens당 표준 단가는 input $10, cache read $1, 5분 cache write $12.50, 1시간 cache write $20, output $50입니다. qgrid는 Claude 응답의 5분/1시간 cache creation breakdown을 보존해 TTL별 단가를 각각 적용하며, breakdown이 없는 구버전 응답에서만 Claude Code가 subscription OAuth 경로에 자동 적용하는 1시간 TTL 단가로 fallback합니다. Fable은 adaptive thinking이 항상 켜져 있어야 하므로 qgrid는 이 모델의 adaptive thinking을 보존합니다.
381
387
 
@@ -406,7 +412,7 @@ qgrid(modelId, {
406
412
 
407
413
  ## 주의사항
408
414
 
409
- - `temperature`, `maxOutputTokens` 등 sampling 파라미터는 CLI 런타임(OpenAI: codex app-server, Anthropic: Claude Code)이 지원하지 않아 무시됩니다.
415
+ - `temperature`, `maxOutputTokens` 등 sampling 파라미터는 OpenAI private Codex 경로와 Anthropic Claude Code 경로가 qgrid를 통해 받지 않으므로 무시됩니다.
410
416
  - Structured output은 top-level `object` schema만 서버에서 강제됩니다. top-level `array`는 클라이언트 파싱 fallback.
411
417
  - `tools`와 `Output.object`를 함께 사용하려면 qgrid server와 AI SDK가 모두 2.5.4 이상이어야 합니다.
412
418
  - AI SDK/Zod가 Draft-7 `items: [...]`로 만드는 위치 기반 tuple은 지원되는
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  AI SDK v6 custom `LanguageModelV3` provider for [qgrid](https://github.com/cartanova-ai/Qgrid).
6
6
 
7
- **Without changing your existing AI SDK code, swap a single `model` line to get subscription-token pooling (N tokens × parallel workers) + the request log dashboard.**
7
+ **Without changing your existing AI SDK code, swap a single `model` line to get subscription-token pooling (N tokens × concurrent permits) + the request log dashboard.**
8
8
 
9
9
  ```diff
10
10
  import { generateText } from "ai";
@@ -50,6 +50,8 @@ const { text } = await generateText({
50
50
 
51
51
  A qgrid server (`http://localhost:44900`) must be running.
52
52
 
53
+ The OpenAI server route calls the private ChatGPT Codex Responses backend directly over HTTPS/SSE. That backend is undocumented and may change without notice; qgrid's mocked protocol tests are not live-provider verification.
54
+
53
55
  ## Usage
54
56
  > Before you start: all client-side usage is identical to the [AI SDK](https://ai-sdk.dev/docs/ai-sdk-core).
55
57
 
@@ -90,10 +92,18 @@ const { output } = await generateText({
90
92
  console.log(output.title, output.authors);
91
93
  ```
92
94
 
93
- Schemas with a top-level `object` are forwarded to server-side structured output and enforced.
94
- If the top level is not an `object` (e.g. array), the AI SDK falls back to client-side parsing and a warning is logged.
95
+ Schemas with a top-level `object` are forwarded to the server. If the top level is not an
96
+ `object` (e.g. array), the AI SDK falls back to client-side parsing and a warning is logged.
95
97
 
96
- > **Note for Anthropic models:** OpenAI/codex structured output constrains decoding to the schema, so this class of failure is rare there. Claude Code's `--json-schema`, however, works as a `StructuredOutput` tool plus post-hoc validation, so the model may fail to honor a complex schema. For structured streaming only, qgrid sets `MAX_STRUCTURED_OUTPUT_RETRIES=1` (one attempt; values below 1 are clamped to 1) to bound stream latency. Non-streaming `generate` leaves that override unset and uses Claude Code's default retry budget. Validation failures after the applicable attempts return an explicit error instead of broken JSON.
98
+ > **Note for Anthropic models:** enforcement differs by provider. OpenAI/codex constrains
99
+ > decoding to the schema, so non-conforming output is rare. The Anthropic route has no
100
+ > enforcement mechanism: qgrid delivers your original schema as prompt guidance at the end of
101
+ > the system prompt, Claude Code generates plain text, and the server only strips code fences.
102
+ > The reply is schema-guided JSON text, **not server-validated JSON** — validation happens in
103
+ > this SDK via your zod schema (`Output.object` does this automatically), and a non-conforming
104
+ > reply surfaces as an explicit validation error on your side instead of hidden server retries.
105
+ > Raw HTTP consumers calling the qgrid API without this SDK must validate the response
106
+ > themselves.
97
107
 
98
108
  ### Streaming
99
109
 
@@ -201,12 +211,12 @@ const { text } = await generateText({
201
211
  | Option | Values | Applies to | Description |
202
212
  |---|---|---|---|
203
213
  | `logger` | `boolean` | both providers | qgrid request logging. Defaults to `true`; `false` disables request-log persistence for this generation without disabling client tools or multi-step continuation |
204
- | `sessionKey` | `string` | OpenAI only | Multi-turn conversation identifier. The same key routes to the same codex thread for prompt-cache hits (see [below](#multi-turn-prompt-cache-sessionkey)) |
214
+ | `sessionKey` | `string` | OpenAI only | Multi-turn conversation identifier used to derive opaque prompt-cache affinity while replaying full history (see [below](#multi-turn-prompt-cache-sessionkey)) |
205
215
  | `effort` | `"none"` \| `"minimal"` \| `"low"` \| `"medium"` \| `"high"` \| `"xhigh"` \| `"max"` | both providers (supported values are model-dependent, e.g. `"max"` is GPT-5.6+) | Reasoning depth. Defaults to the config's `defaultEffort` (`"low"`) |
206
216
  | `verbosity` | `"low"` \| `"medium"` \| `"high"` | OpenAI only | Response text verbosity |
207
217
  | `reasoningSummary` | `"auto"` \| `"concise"` \| `"detailed"` \| `"none"` | OpenAI only | Reasoning summary output mode |
208
218
  | `serviceTier` | `string` | OpenAI only | OpenAI/codex service tier |
209
- | `timeoutMs` | positive integer, max `1_800_000` | Anthropic only | Server-side Claude Code process timeout in milliseconds. Defaults to 240 seconds |
219
+ | `timeoutMs` | positive integer, max `1_800_000` | Anthropic only | Server-side Claude Code process timeout in milliseconds. The SDK's non-stream HTTP budget is 60 seconds longer. Defaults to 240 seconds |
210
220
  | `imageGeneration` | `boolean` | OpenAI only, non-stream | Enables codex's built-in `image_generation` tool (see [below](#image-generation)) |
211
221
  | `imageGenerationOptions` | `{ quality?, size? }` | OpenAI only | Image quality/size hints. `quality: "low" \| "medium" \| "high"`, `size: "1024x1024" \| "1024x1536" \| "1536x1024"` (defaults: `medium` / `1536x1024`) |
212
222
  | `fallbackModels` | `string[]` | reserved | Reserved for future qgrid server-side fallback routing. Not functional yet and unrelated to Claude Code's Fable refusal fallback |
@@ -214,12 +224,15 @@ const { text } = await generateText({
214
224
  AI SDK's top-level `timeout` remains the overall client-side budget and is converted to an
215
225
  `AbortSignal` before the custom provider runs. It does not expose the numeric timeout to qgrid.
216
226
  Use `providerOptions.qgrid.timeoutMs` when the qgrid server's Claude Code process needs a different
217
- limit. A client abort or disconnected non-streaming HTTP request also terminates the server-side
218
- provider execution.
227
+ limit. Anthropic `generateText` requests use a request-scoped Undici dispatcher without changing global process
228
+ state; its `headersTimeout` and `bodyTimeout` are set to `timeoutMs + 60_000`. For example, a
229
+ 600-second server limit gets a 660-second HTTP transport budget, allowing the server's explicit
230
+ timeout response to arrive before the client transport gives up. A client abort or disconnected
231
+ non-streaming HTTP request also terminates the server-side provider execution.
219
232
 
220
233
  ### Multi-turn prompt cache (sessionKey)
221
234
 
222
- For multi-turn conversations, pass a single caller-side domain ID (game session ID, chat room ID, ...) as `sessionKey` and the provider routes the same conversation to the same codex thread, hitting the prompt cache. Thread coordinate issuance/storage/replay is handled inside the provider — the caller only passes the key.
235
+ For multi-turn conversations, pass a caller-side domain ID (game session ID, chat room ID, ...) as `sessionKey`. The SDK derives a model-scoped opaque affinity key; qgrid sends it as `prompt_cache_key` and replays the complete conversation history on every request. No provider thread or process session is retained.
223
236
 
224
237
  ```typescript
225
238
  const { text } = await generateText({
@@ -229,8 +242,8 @@ const { text } = await generateText({
229
242
  });
230
243
  ```
231
244
 
232
- - Threads expire after 10 minutes idle. On expiry or worker restart, the request automatically starts a cold thread instead of failing.
233
- - Ignored for `anthropic/*` models (Claude Code spawns a fresh process per request, so there is no thread reuse).
245
+ - The SDK's internal affinity-coordinate entry expires after 10 minutes idle; deriving the same affinity key does not depend on that entry.
246
+ - Ignored for `anthropic/*` models; Claude Code uses its own prefix-cache behavior.
234
247
 
235
248
  ### Image Generation
236
249
 
@@ -270,7 +283,7 @@ const result = await generateText({
270
283
  ```
271
284
 
272
285
  - Rejected in `streamText` (non-stream only).
273
- - Image-generation requests always run on a cold one-shot thread and are excluded from thread reuse.
286
+ - Image-generation requests do not retain provider conversation state. Their full input is sent directly.
274
287
  - 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.
275
288
  - 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).
276
289
 
@@ -343,7 +356,7 @@ The `qgrid()` provider has its own lifecycle, so the logger automatically suppre
343
356
 
344
357
  ```typescript
345
358
  type QgridSupportedModel =
346
- // OpenAI (based on codex app-server)
359
+ // OpenAI (direct private Codex Responses backend)
347
360
  | "openai/gpt-5.6-sol"
348
361
  | "openai/gpt-5.6-terra"
349
362
  | "openai/gpt-5.6-luna"
@@ -372,13 +385,13 @@ type QgridSupportedModel =
372
385
 
373
386
  ### GPT-5.6 specifications
374
387
 
375
- | Model | Context (qgrid/codex runtime) | Max output | Input / cached input / output per 1M tokens |
388
+ | Model | Context (qgrid OpenAI route) | Max output | Input / cached input / output per 1M tokens |
376
389
  |---|---:|---:|---:|
377
390
  | `openai/gpt-5.6-sol` | 372K | 128K | $5 / $0.50 / $30 |
378
391
  | `openai/gpt-5.6-terra` | 372K | 128K | $2.50 / $0.25 / $15 |
379
392
  | `openai/gpt-5.6-luna` | 372K | 128K | $1 / $0.10 / $6 |
380
393
 
381
- All GPT-5.6 models support reasoning through `max`. The OpenAI native API spec is a 1.05M context window with 128K max output, but qgrid runs on the codex app-server subscription path, where all three models report a 372K context window (95% effective — about 353K of usable input) that cannot be raised by configuration. Prompts over 272K input tokens apply a 2x input and 1.5x output surcharge to the full request; cache writes cost 1.25x the uncached input rate.
394
+ All GPT-5.6 models support reasoning through `max`. Qgrid retains the observed subscription-route limits used by its model configuration: a 372K context window (95% effective — about 353K of usable input) and 128K maximum output. This is narrower than the 1.05M context listed for the public OpenAI API and is not attributed to a local runtime. Prompts over 272K input tokens apply a 2x input and 1.5x output surcharge to the full request; cache writes cost 1.25x the uncached input rate.
382
395
 
383
396
  `anthropic/claude-fable-5` has a 1M context window and 128K max output. Its standard prices per 1M tokens are $10 input, $1 cache read, $12.50 five-minute cache write, $20 one-hour cache write, and $50 output. qgrid preserves Claude's 5m/1h cache-creation breakdown and prices each TTL separately; only legacy responses without that breakdown fall back to the one-hour TTL automatically selected by Claude Code on subscription OAuth. Fable requires always-on adaptive thinking, so qgrid preserves adaptive thinking for this model.
384
397
 
@@ -409,7 +422,7 @@ If multiple projects/workflows share one qgrid server, set `QGRID_PROJECT_NAME`.
409
422
 
410
423
  ## Notes
411
424
 
412
- - Sampling parameters such as `temperature` and `maxOutputTokens` are ignored — the CLI runtimes (OpenAI: codex app-server, Anthropic: Claude Code) do not support them.
425
+ - Sampling parameters such as `temperature` and `maxOutputTokens` are ignored because the OpenAI private Codex route and the Anthropic Claude Code route do not accept them through qgrid.
413
426
  - Structured output is server-enforced only for top-level `object` schemas. Top-level `array` falls back to client-side parsing.
414
427
  - Combining `tools` with `Output.object` requires qgrid server and AI SDK 2.5.4 or later.
415
428
  - AI SDK/Zod positional tuples emitted as Draft-7 `items: [...]` are normalized
package/dist/index.d.ts CHANGED
@@ -18,23 +18,24 @@ type QgridProviderOptions = {
18
18
  */
19
19
  logger?: boolean;
20
20
  /**
21
- * 멀티턴 시 codex thread reuse를 위한 대화 식별자, 호출자가 자기 도메인 ID(예: 게임 세션 ID) 하나만 넘기면
22
- * provider가 같은 sessionKey 의 thread 좌표를 내부에서 회송해 prompt cache 를 적중시킨다
23
- * 좌표는 sessionKey 별로 격리된다.
21
+ * 멀티턴 프롬프트 캐시 어피니티용 대화 식별자, 호출자가 자기 도메인 ID(예: 게임 세션 ID) 하나만 넘기면
22
+ * provider가 model+sessionKey에서 opaque cache affinity를 결정적으로 파생해 회송한다.
23
+ * 원문 sessionKey는 서버에 전송하지 않으며 좌표는 model+sessionKey 별로 격리된다.
24
24
  */
25
25
  sessionKey?: string;
26
26
  /** reasoning 모델의 추론 깊이. 기본값은 qgrid config의 defaultEffort. */
27
27
  effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
28
- /** 응답 텍스트의 상세도. OpenAI/codex route에서만 적용된다. */
28
+ /** 응답 텍스트의 상세도. OpenAI route에서만 적용된다. */
29
29
  verbosity?: "low" | "medium" | "high";
30
- /** reasoning 모델의 추론 요약 출력 방식. OpenAI/codex route에서만 적용된다. */
30
+ /** reasoning 모델의 추론 요약 출력 방식. OpenAI route에서만 적용된다. */
31
31
  reasoningSummary?: "auto" | "concise" | "detailed" | "none";
32
- /** OpenAI/codex service tier. OpenAI/codex route에서만 적용된다. */
32
+ /** OpenAI service tier. OpenAI route에서만 적용된다. */
33
33
  serviceTier?: string;
34
34
  /**
35
35
  * qgrid 서버의 provider 실행 제한(ms). Anthropic 경로에서는 Claude Code 프로세스 타이머로
36
- * 사용된다. AI SDK 표준 timeout은 provider에 숫자를 전달하지 않고 abortSignal로 변환되므로,
37
- * 서버 측 제한을 바꾸려면 이 옵션을 사용한다. 양의 정수만 허용하며 최대 30분, 기본 240초다.
36
+ * 사용되며, SDK의 non-stream 요청별 HTTP headers/body timeout은 이 값보다 60초 길게 설정된다.
37
+ * AI SDK 표준 timeout은 provider에 숫자를 전달하지 않고 abortSignal로 변환되므로, 서버 측
38
+ * 제한을 바꾸려면 이 옵션을 사용한다. 양의 정수만 허용하며 최대 30분, 기본 240초다.
38
39
  */
39
40
  timeoutMs?: number;
40
41
  /**
@@ -43,7 +44,7 @@ type QgridProviderOptions = {
43
44
  */
44
45
  fallbackModels?: string[];
45
46
  /**
46
- * codex 내장 image_generation tool 을 켠다. OpenAI/codex route + non-stream 전용.
47
+ * OpenAI Responses image_generation tool 을 켠다. OpenAI route + non-stream 전용.
47
48
  * generateText 결과의 files 로 이미지를 받는다. streaming(streamText)에서는 거부된다.
48
49
  */
49
50
  imageGeneration?: boolean;
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Agent } from "undici";
1
2
  //#region src/envelope-stream-parser.ts
2
3
  const ESCAPE_MAP = {
3
4
  "\"": "\"",
@@ -252,6 +253,87 @@ function createEnvelopeStreamParser(answerKind) {
252
253
  } };
253
254
  }
254
255
  //#endregion
256
+ //#region src/http-transport.ts
257
+ const DEFAULT_ANTHROPIC_TIMEOUT_MS = 24e4;
258
+ const TRANSPORT_RESPONSE_GRACE_MS = 6e4;
259
+ function anthropicTransportOptions(timeoutMs) {
260
+ const transportTimeoutMs = (Number.isFinite(timeoutMs) && timeoutMs !== void 0 && timeoutMs > 0 ? timeoutMs : DEFAULT_ANTHROPIC_TIMEOUT_MS) + TRANSPORT_RESPONSE_GRACE_MS;
261
+ return {
262
+ headersTimeout: transportTimeoutMs,
263
+ bodyTimeout: transportTimeoutMs
264
+ };
265
+ }
266
+ function createQgridRequestTransport(modelId, timeoutMs) {
267
+ if (!modelId.startsWith("anthropic/")) return void 0;
268
+ const options = anthropicTransportOptions(timeoutMs);
269
+ return {
270
+ dispatcher: new Agent(options),
271
+ timeoutMs: options.headersTimeout
272
+ };
273
+ }
274
+ async function closeQgridRequestTransport(transport) {
275
+ if (!transport) return;
276
+ await transport.dispatcher.close().catch(() => void 0);
277
+ }
278
+ async function qgridFetch(input, init, context) {
279
+ try {
280
+ return await fetch(input, init);
281
+ } catch (error) {
282
+ throw toQgridTransportError(error, context);
283
+ }
284
+ }
285
+ function toQgridTransportError(error, context) {
286
+ if (isAbortError(error)) return error;
287
+ const code = findErrorCode(error);
288
+ const prefix = `qgrid ${context.operation} transport failed`;
289
+ if (code === "UND_ERR_HEADERS_TIMEOUT") {
290
+ const budget = context.transportTimeoutMs ? ` after ${context.transportTimeoutMs}ms` : "";
291
+ return new Error(`${prefix}: response headers timed out${budget} (${code})`, { cause: error });
292
+ }
293
+ if (code === "UND_ERR_BODY_TIMEOUT") {
294
+ const budget = context.transportTimeoutMs ? ` after ${context.transportTimeoutMs}ms` : "";
295
+ return new Error(`${prefix}: response body timed out${budget} (${code})`, { cause: error });
296
+ }
297
+ if (code === "ECONNREFUSED") return new Error(`${prefix}: connection refused by ${serverOrigin(context.serverUrl)} (${code})`, { cause: error });
298
+ const detail = findDeepestMessage(error) ?? "unknown network error";
299
+ return new Error(`${prefix}: ${detail}${code ? ` (${code})` : ""}`, { cause: error });
300
+ }
301
+ function isAbortError(error) {
302
+ return error instanceof Error && error.name === "AbortError";
303
+ }
304
+ function findErrorCode(error, seen = /* @__PURE__ */ new Set()) {
305
+ if (!isErrorLike(error) || seen.has(error)) return void 0;
306
+ seen.add(error);
307
+ if (typeof error.code === "string") return error.code;
308
+ const causeCode = findErrorCode(error.cause, seen);
309
+ if (causeCode) return causeCode;
310
+ if (Array.isArray(error.errors)) for (const nested of error.errors) {
311
+ const nestedCode = findErrorCode(nested, seen);
312
+ if (nestedCode) return nestedCode;
313
+ }
314
+ }
315
+ function findDeepestMessage(error, seen = /* @__PURE__ */ new Set()) {
316
+ if (!isErrorLike(error) || seen.has(error)) return void 0;
317
+ seen.add(error);
318
+ const causeMessage = findDeepestMessage(error.cause, seen);
319
+ if (causeMessage) return causeMessage;
320
+ if (Array.isArray(error.errors)) for (const nested of error.errors) {
321
+ const nestedMessage = findDeepestMessage(nested, seen);
322
+ if (nestedMessage) return nestedMessage;
323
+ }
324
+ return typeof error.message === "string" && error.message.length > 0 ? error.message : void 0;
325
+ }
326
+ function isErrorLike(value) {
327
+ return typeof value === "object" && value !== null;
328
+ }
329
+ function serverOrigin(serverUrl) {
330
+ try {
331
+ return new URL(serverUrl).origin;
332
+ } catch {
333
+ return serverUrl;
334
+ }
335
+ }
336
+ //#endregion
255
337
  //#region src/qgrid.constant.ts
256
338
  const QGRID_PROVIDER_NAME = "qgrid";
257
339
  const THREAD_COORD_TTL_MS = 10 * 6e4;
@@ -384,7 +466,7 @@ function extractPromptAndHistory(messages, options = {}) {
384
466
  const extracted = extractUserContent(msg.content, includeImages);
385
467
  imageUrls.push(...extracted.imageUrls);
386
468
  droppedImageCount += extracted.droppedImageCount;
387
- const content = codexContentItemsFromExtracted(extracted.parts);
469
+ const content = responsesContentItemsFromExtracted(extracted.parts);
388
470
  history.push({
389
471
  type: "message",
390
472
  role: "user",
@@ -495,7 +577,7 @@ function inputPartsFromExtracted(extractedParts) {
495
577
  });
496
578
  return parts.some((part) => part.type === "image") ? parts : void 0;
497
579
  }
498
- function codexContentItemsFromExtracted(parts) {
580
+ function responsesContentItemsFromExtracted(parts) {
499
581
  const items = [];
500
582
  for (const part of parts) if (part.kind === "text") items.push({
501
583
  type: "input_text",
@@ -917,6 +999,7 @@ function createQgridLogger(config = {}) {
917
999
  //#endregion
918
1000
  //#region src/index.ts
919
1001
  const threadCoordStore = /* @__PURE__ */ new Map();
1002
+ const CACHE_AFFINITY_NAMESPACE = "qgrid-cache-affinity-v1";
920
1003
  const MAX_IMAGE_INPUT_DATA_URL_CHARS = 9e6;
921
1004
  const MAX_IMAGE_INPUT_DATA_URL_CHARS_LABEL = "9M";
922
1005
  function assertImageInputFitsJsonTransport(imageUrls) {
@@ -957,6 +1040,12 @@ function setThreadCoord(sessionKey, coord) {
957
1040
  expiresAt: Date.now() + THREAD_COORD_TTL_MS
958
1041
  });
959
1042
  }
1043
+ async function deriveCacheAffinityKey(modelId, sessionKey) {
1044
+ if (!sessionKey || modelId.startsWith("anthropic/")) return void 0;
1045
+ const bytes = new TextEncoder().encode(`${CACHE_AFFINITY_NAMESPACE}\0${modelId}\0${sessionKey}`);
1046
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
1047
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
1048
+ }
960
1049
  function qgridProviderMetadata(data) {
961
1050
  return { qgrid: {
962
1051
  model: data.model,
@@ -970,15 +1059,13 @@ function qgridProviderMetadata(data) {
970
1059
  cacheCreation1hInputTokens: data.usage.cache_creation_1h_input_tokens ?? null
971
1060
  } };
972
1061
  }
973
- function reusableSessionKey(modelId, sessionKey) {
974
- return modelId.startsWith("anthropic/") ? void 0 : sessionKey;
975
- }
976
1062
  function getQgridProviderOptions(options) {
977
1063
  return options.providerOptions?.qgrid ?? {};
978
1064
  }
979
1065
  function toAiSdkUsage(usage) {
980
1066
  const cacheRead = usage.cache_read_input_tokens;
981
1067
  const cacheWrite = usage.cache_creation_input_tokens;
1068
+ const reasoning = usage.reasoning_tokens ?? 0;
982
1069
  return {
983
1070
  inputTokens: {
984
1071
  total: usage.input_tokens,
@@ -988,8 +1075,8 @@ function toAiSdkUsage(usage) {
988
1075
  },
989
1076
  outputTokens: {
990
1077
  total: usage.output_tokens,
991
- text: usage.output_tokens,
992
- reasoning: void 0
1078
+ text: Math.max(usage.output_tokens - reasoning, 0),
1079
+ reasoning
993
1080
  }
994
1081
  };
995
1082
  }
@@ -1051,7 +1138,7 @@ function qgrid(modelId, config) {
1051
1138
  const imageGenerationOptions = qgridOptions.imageGenerationOptions;
1052
1139
  if (imageGeneration) assertImageInputFitsJsonTransport(imageUrls);
1053
1140
  const sessionKey = qgridOptions.sessionKey;
1054
- const reuseSessionKey = reusableSessionKey(modelId, sessionKey);
1141
+ const cacheAffinityKey = imageGeneration ? void 0 : await deriveCacheAffinityKey(modelId, sessionKey);
1055
1142
  const jsonSchema = serializeObjectResponseFormat(options.responseFormat);
1056
1143
  let runContext;
1057
1144
  let toolResultsPayload;
@@ -1060,9 +1147,10 @@ function qgrid(modelId, config) {
1060
1147
  runContext = matchedClientRun.runContext;
1061
1148
  toolResultsPayload = matchedClientRun.toolResultsPayload;
1062
1149
  }
1063
- const storedCoord = reuseSessionKey ? getThreadCoord(reuseSessionKey) : void 0;
1150
+ const storedCoord = cacheAffinityKey ? getThreadCoord(cacheAffinityKey) : void 0;
1064
1151
  if (!matchedClientRun && storedCoord) runContext = { threadCoord: storedCoord };
1065
- const data = await fetch(`${serverUrl}/api/qgrid/query`, {
1152
+ const transport = createQgridRequestTransport(modelId, timeoutMs);
1153
+ const data = await qgridFetch(`${serverUrl}/api/qgrid/query`, {
1066
1154
  method: "POST",
1067
1155
  headers: { "Content-Type": "application/json" },
1068
1156
  body: JSON.stringify({ args: {
@@ -1079,20 +1167,26 @@ function qgrid(modelId, config) {
1079
1167
  ...jsonSchema ? { jsonSchema } : {},
1080
1168
  ...history.length > 0 ? { history: JSON.stringify(history) } : {},
1081
1169
  ...projectName ? { projectName } : {},
1170
+ ...cacheAffinityKey ? { cacheAffinityKey } : {},
1082
1171
  ...logger === false ? { logger: false } : {},
1083
1172
  ...runContext ? { runContext } : {},
1084
1173
  ...toolResultsPayload ? { toolResults: toolResultsPayload } : {},
1085
1174
  ...imageGeneration ? { imageGeneration } : {},
1086
1175
  ...imageGenerationOptions ? { imageGenerationOptions } : {}
1087
1176
  } }),
1088
- signal: options.abortSignal
1177
+ signal: options.abortSignal,
1178
+ ...transport ? { dispatcher: transport.dispatcher } : {}
1179
+ }, {
1180
+ operation: "query",
1181
+ serverUrl,
1182
+ transportTimeoutMs: transport?.timeoutMs
1089
1183
  }).then(async (res) => {
1090
1184
  if (!res.ok) {
1091
1185
  const text = await res.text().catch(() => "");
1092
1186
  throw new Error(`qgrid ${res.status}: ${text}`);
1093
1187
  }
1094
1188
  return await res.json();
1095
- });
1189
+ }).finally(() => closeQgridRequestTransport(transport));
1096
1190
  const content = [];
1097
1191
  let finishReason = {
1098
1192
  unified: "stop",
@@ -1125,7 +1219,7 @@ function qgrid(modelId, config) {
1125
1219
  if (imageGeneration && finishReason.unified !== "tool-calls" && !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.");
1126
1220
  if (matchedClientRun) clientRuns.delete(matchedClientRun.key);
1127
1221
  if (finishReason.unified === "tool-calls") rememberClientRun(data.runContext, content.filter((c) => c.type === "tool-call").map((c) => c.toolCallId));
1128
- if (reuseSessionKey && data.runContext?.threadCoord) setThreadCoord(reuseSessionKey, data.runContext.threadCoord);
1222
+ if (!imageGeneration && cacheAffinityKey && data.runContext?.threadCoord) setThreadCoord(cacheAffinityKey, data.runContext.threadCoord);
1129
1223
  return {
1130
1224
  content,
1131
1225
  finishReason,
@@ -1149,7 +1243,7 @@ function qgrid(modelId, config) {
1149
1243
  const { prompt, system, history, droppedImageCount } = extractPromptAndHistory(options.prompt, { includeImages: false });
1150
1244
  warnDroppedImages(droppedImageCount);
1151
1245
  const sessionKey = qgridOptions.sessionKey;
1152
- const reuseSessionKey = reusableSessionKey(modelId, sessionKey);
1246
+ const cacheAffinityKey = await deriveCacheAffinityKey(modelId, sessionKey);
1153
1247
  const jsonSchema = serializeObjectResponseFormat(options.responseFormat);
1154
1248
  let runContext;
1155
1249
  let toolResultsPayload;
@@ -1158,7 +1252,7 @@ function qgrid(modelId, config) {
1158
1252
  runContext = matchedClientRun.runContext;
1159
1253
  toolResultsPayload = matchedClientRun.toolResultsPayload;
1160
1254
  }
1161
- const storedCoord = reuseSessionKey ? getThreadCoord(reuseSessionKey) : void 0;
1255
+ const storedCoord = cacheAffinityKey ? getThreadCoord(cacheAffinityKey) : void 0;
1162
1256
  if (!matchedClientRun && storedCoord) runContext = { threadCoord: storedCoord };
1163
1257
  const prepRes = await fetch(`${serverUrl}/api/qgrid/prepareStream`, {
1164
1258
  method: "POST",
@@ -1176,6 +1270,7 @@ function qgrid(modelId, config) {
1176
1270
  ...jsonSchema ? { jsonSchema } : {},
1177
1271
  ...history.length > 0 ? { history: JSON.stringify(history) } : {},
1178
1272
  ...projectName ? { projectName } : {},
1273
+ ...cacheAffinityKey ? { cacheAffinityKey } : {},
1179
1274
  ...logger === false ? { logger: false } : {},
1180
1275
  ...runContext ? { runContext } : {},
1181
1276
  ...toolResultsPayload ? { toolResults: toolResultsPayload } : {}
@@ -1228,7 +1323,7 @@ function qgrid(modelId, config) {
1228
1323
  const done = event.data;
1229
1324
  if (matchedClientRun) clientRuns.delete(matchedClientRun.key);
1230
1325
  if (done.finishReason === "tool-calls") rememberClientRun(done.runContext, (done.content ?? []).filter((c) => c.type === "tool-call").map((c) => c.toolCallId));
1231
- if (reuseSessionKey && done.runContext?.threadCoord) setThreadCoord(reuseSessionKey, done.runContext.threadCoord);
1326
+ if (cacheAffinityKey && done.runContext?.threadCoord) setThreadCoord(cacheAffinityKey, done.runContext.threadCoord);
1232
1327
  if (done.content) {
1233
1328
  for (const item of done.content) if (item.type === "text" && !deltaTextEmitted) {
1234
1329
  const tid = `${TEXT_STREAM_ID_PREFIX}_${Math.random().toString(36).slice(2, 10)}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartanova/qgrid-ai-sdk",
3
- "version": "2.7.0",
3
+ "version": "2.7.2",
4
4
  "description": "AI SDK LanguageModelV3 provider for qgrid",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,9 @@
19
19
  "types": "./dist/index.d.ts"
20
20
  }
21
21
  },
22
+ "dependencies": {
23
+ "undici": "^6.28.0"
24
+ },
22
25
  "devDependencies": {
23
26
  "@ai-sdk/provider": "^3.0.0",
24
27
  "ai": "^6.0.190",