@cartanova/qgrid-cli 2.10.1 → 2.10.3

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 (97) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/bundle/dist/application/monit/log-buffer.js +7 -11
  4. package/bundle/dist/application/monit/monit.frame.js +6 -11
  5. package/bundle/dist/application/qgrid/oauth.js +2 -2
  6. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  7. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +12 -36
  8. package/bundle/dist/application/qgrid/qgrid.frame.js +2 -2
  9. package/bundle/dist/application/qgrid/qgrid.types.js +2 -1
  10. package/bundle/dist/application/qgrid/token-subscriber.js +4 -7
  11. package/bundle/dist/application/qgrid/token-window-keepalive.js +3 -2
  12. package/bundle/dist/application/qgrid/tool-emulation-schema.js +4 -4
  13. package/bundle/dist/application/qgrid/tool-emulation.js +14 -2
  14. package/bundle/dist/application/request-log/request-log.model.js +7 -5
  15. package/bundle/dist/application/setting/setting.model.js +1 -1
  16. package/bundle/dist/sonamu.config.js +6 -6
  17. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +53 -84
  19. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +55 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +11 -13
  21. package/bundle/dist/utils/providers/common/effort.js +1 -1
  22. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +3 -3
  23. package/bundle/dist/utils/providers/common/thinking.js +12 -0
  24. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +3 -2
  25. package/bundle/dist/utils/providers/openai/openai-direct-client.js +6 -16
  26. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +89 -78
  27. package/bundle/src/application/monit/log-buffer.test.ts +3 -4
  28. package/bundle/src/application/monit/log-buffer.ts +13 -27
  29. package/bundle/src/application/monit/monit.frame.test.ts +7 -8
  30. package/bundle/src/application/monit/monit.frame.ts +9 -14
  31. package/bundle/src/application/qgrid/oauth.ts +1 -1
  32. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +7 -0
  33. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -1
  34. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +62 -1
  35. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +25 -71
  36. package/bundle/src/application/qgrid/qgrid.frame.ts +4 -8
  37. package/bundle/src/application/qgrid/qgrid.types.ts +2 -0
  38. package/bundle/src/application/qgrid/token-subscriber.test.ts +8 -18
  39. package/bundle/src/application/qgrid/token-subscriber.ts +10 -20
  40. package/bundle/src/application/qgrid/token-window-keepalive.test.ts +2 -2
  41. package/bundle/src/application/qgrid/token-window-keepalive.ts +2 -1
  42. package/bundle/src/application/qgrid/tool-emulation-schema.test.ts +6 -3
  43. package/bundle/src/application/qgrid/tool-emulation-schema.ts +3 -3
  44. package/bundle/src/application/qgrid/tool-emulation.test.ts +8 -1
  45. package/bundle/src/application/qgrid/tool-emulation.ts +29 -0
  46. package/bundle/src/application/request-log/request-log.model.ts +5 -5
  47. package/bundle/src/application/setting/setting.model.ts +1 -1
  48. package/bundle/src/sonamu.config.ts +5 -5
  49. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +3 -6
  50. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +26 -0
  51. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +70 -148
  52. package/bundle/src/utils/providers/anthropic/anthropic-thinking.test.ts +36 -0
  53. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +84 -0
  54. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +74 -13
  55. package/bundle/src/utils/providers/anthropic/claude-session.ts +11 -20
  56. package/bundle/src/utils/providers/common/effort.ts +1 -1
  57. package/bundle/src/utils/providers/common/provider-dispatcher.ts +4 -0
  58. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -4
  59. package/bundle/src/utils/providers/common/thinking.ts +10 -0
  60. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +7 -3
  61. package/bundle/src/utils/providers/openai/openai-direct-client.test.ts +3 -3
  62. package/bundle/src/utils/providers/openai/openai-direct-client.ts +20 -33
  63. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +97 -6
  64. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +113 -77
  65. package/bundle/web-dist/client/assets/{index-Cq4_ExLO.js → index-BIC1sQxw.js} +3 -3
  66. package/bundle/web-dist/client/assets/logs-zvdujZWn.js +1 -0
  67. package/bundle/web-dist/client/assets/monit-CraVV3MQ.js +1 -0
  68. package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +1 -0
  69. package/bundle/web-dist/client/assets/{routes-CXotE1OF.js → routes-B3ZBoJ60.js} +1 -1
  70. package/bundle/web-dist/client/assets/{settings-CfJDz4AC.js → settings-DwbzT5jo.js} +1 -1
  71. package/bundle/web-dist/client/assets/{show-gBpWK4z2.js → show-ItTExdAh.js} +1 -1
  72. package/bundle/web-dist/client/assets/{tokens-CbFJjIyC.js → tokens-BynKx8Vn.js} +1 -1
  73. package/bundle/web-dist/client/assets/{use-oauth-login-flow-jJo7GV3r.js → use-oauth-login-flow-8xw6on5P.js} +1 -1
  74. package/bundle/web-dist/client/index.html +1 -1
  75. package/bundle/web-dist/server/assets/{logs-DMlOXcDS.js → logs-BSk9lOHe.js} +13 -3
  76. package/bundle/web-dist/server/assets/{monit-Dg1wZCdx.js → monit-D9W4ioUi.js} +2 -21
  77. package/bundle/web-dist/server/assets/refresh-cw-B8U9BL9-.js +23 -0
  78. package/bundle/web-dist/server/assets/{settings-BaIBt_Vs.js → settings-BFVuldch.js} +1 -1
  79. package/bundle/web-dist/server/assets/{show-CDxGr4rS.js → show-Cswh_tqs.js} +1 -1
  80. package/bundle/web-dist/server/assets/{tokens-DJlfeGcx.js → tokens-DiTUwUwr.js} +1 -1
  81. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  82. package/package.json +1 -1
  83. package/skills/qgrid/SKILL.md +1 -0
  84. package/skills/qgrid/references/ai-sdk-provider-contract.md +2 -1
  85. package/skills/qgrid/references/anthropic-claude-code-runtime.md +7 -4
  86. package/skills/qgrid/references/decision-rationale.md +9 -0
  87. package/skills/qgrid/references/openai-codex-runtime.md +6 -0
  88. package/skills/qgrid/references/sonamu-api-web-flow.md +3 -1
  89. package/skills/qgrid/references/token-auth-quota-lifecycle.md +2 -0
  90. package/skills/qgrid/references/tool-calling-and-multiturn.md +18 -1
  91. package/skills/qgrid/references/verification-and-debugging.md +1 -1
  92. package/bundle/web-dist/client/assets/logs-kg1KOMBk.js +0 -1
  93. package/bundle/web-dist/client/assets/monit-DmtHTmSM.js +0 -1
  94. /package/bundle/web-dist/client/assets/{plus-CknMNCP5.js → plus-B62CuqT8.js} +0 -0
  95. /package/bundle/web-dist/client/assets/{request-model-eygfdRv7.js → request-model-D4CZPmnG.js} +0 -0
  96. /package/bundle/web-dist/server/assets/{plus-DVobMrag.js → plus-CmvbWuDf.js} +0 -0
  97. /package/bundle/web-dist/server/assets/{request-model-DXclJfdP.js → request-model-CnCeaNLE.js} +0 -0
@@ -18,7 +18,6 @@ import { spawn } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
19
  import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
 
21
- import { resolveAnthropicEffort } from "../common/effort";
22
21
  import { type JsonValue, type TokenUsageBreakdown, type UserInput } from "../common/provider-types";
23
22
  import {
24
23
  ARGV_SAFE_MAX_UTF8_BYTES,
@@ -28,14 +27,13 @@ import { createTtftTracker } from "../common/ttft";
28
27
  import {
29
28
  ANTHROPIC_CLAUDE_CWD,
30
29
  anthropicConfigDir,
31
- ANTHROPIC_DEFAULT_EFFORT,
32
30
  ANTHROPIC_DISALLOWED_TOOLS,
33
31
  assertSupportedOneMillionSuffix,
34
32
  canonicalAnthropicModel,
35
33
  needsCli1mSuffix,
36
34
  supports1MContext,
37
- usesAdaptiveThinking,
38
35
  } from "./anthropic-constants";
36
+ import { assertAnthropicThinkingRuntime, resolveAnthropicThinking } from "./anthropic-thinking";
39
37
  import {
40
38
  buildStreamJsonInput,
41
39
  type ClaudeStreamJsonState,
@@ -106,6 +104,7 @@ export interface ClaudeSessionRequest {
106
104
  system?: string;
107
105
  jsonSchema?: string; // 있으면 structured output
108
106
  effort?: string;
107
+ thinking?: boolean;
109
108
  timeoutMs: number;
110
109
  coldHistory?: Array<JsonValue>;
111
110
  input: Array<UserInput>;
@@ -149,18 +148,6 @@ export function oneMillionEnv(supported: boolean): { CLAUDE_CODE_DISABLE_1M_CONT
149
148
  return supported ? {} : { CLAUDE_CODE_DISABLE_1M_CONTEXT: "1" };
150
149
  }
151
150
 
152
- export function thinkingEnv(model: string): {
153
- CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING?: "1";
154
- MAX_THINKING_TOKENS?: "0";
155
- } {
156
- return usesAdaptiveThinking(model)
157
- ? {}
158
- : {
159
- CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING: "1",
160
- MAX_THINKING_TOKENS: "0",
161
- };
162
- }
163
-
164
151
  // SON-495: structured output retry 를 1 로 고정한다. CC 가 attempt 를 reject 하고 retry 하면
165
152
  // `{rejected}{accepted}` 누적이 AI-SDK 의 JSON.parse 를 깨뜨리므로, retry 자체를 막아 그 경로를
166
153
  // 원천 차단한다. `0` 은 CC query loop(`callsThisQuery >= maxRetries`)가 첫 attempt emit 전에
@@ -206,11 +193,13 @@ export function buildClaudeArgs(opts: {
206
193
  system?: string;
207
194
  systemPromptFile?: string;
208
195
  effort?: string;
196
+ thinking?: boolean;
209
197
  jsonSchema?: string;
210
198
  sessionId: string;
211
199
  needsOneMillionSuffix?: boolean;
212
200
  includePartialMessages?: boolean;
213
201
  }): Array<string> {
202
+ const thinking = resolveAnthropicThinking(opts.model, opts.thinking, opts.effort);
214
203
  const useStructured = Boolean(opts.jsonSchema && opts.jsonSchema.length > 0);
215
204
  if (useStructured) assertAnthropicSchemaArgvSize(opts.jsonSchema!);
216
205
  // --tools "" 로 전체 차단, structured(jsonSchema 있음) 면 StructuredOutput 만 허용.
@@ -244,11 +233,10 @@ export function buildClaudeArgs(opts: {
244
233
  // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
245
234
  // 크기에 따라 --system-prompt(작음) / --system-prompt-file(큼)로 분기(systemArgs).
246
235
  ...systemArgs,
247
- // Fable 5 는 adaptive thinking 이 필수이고, Opus 5 는 공식 기본 adaptive 동작을 보존한다.
248
- ...(usesAdaptiveThinking(opts.model) ? [] : ["--thinking", "disabled"]),
249
- // Claude Code 어휘 밖의 effort(OpenAI 전용 ultra, 옛 none/minimal 등)는 조용히 버리고 qgrid 기본을 쓴다.
236
+ // One CLI control owns thinking; no competing adaptive/budget environment overrides.
237
+ ...(thinking.mode ? ["--thinking", thinking.mode] : []),
250
238
  "--effort",
251
- resolveAnthropicEffort(opts.effort) ?? ANTHROPIC_DEFAULT_EFFORT,
239
+ thinking.effort,
252
240
  "--disable-slash-commands",
253
241
  "--session-id",
254
242
  opts.sessionId,
@@ -269,6 +257,8 @@ export function runClaudeSession(
269
257
  // 직접 호출(테스트/미래 caller) 대비 방어적으로 한 번 더 통과시킨다(이미 canonical 이면 no-op).
270
258
  assertSupportedOneMillionSuffix(req.model);
271
259
  const model = canonicalAnthropicModel(req.model);
260
+ const thinking = resolveAnthropicThinking(model, req.thinking, req.effort);
261
+ assertAnthropicThinkingRuntime(thinking.extraBody);
272
262
  const supportsOneMillion = supports1MContext(model);
273
263
  const needsOneMillionSuffix = needsCli1mSuffix(model);
274
264
  const sessionId = randomUUID();
@@ -293,6 +283,7 @@ export function runClaudeSession(
293
283
  system: useSystemPromptFile ? undefined : req.system,
294
284
  systemPromptFile,
295
285
  effort: req.effort,
286
+ thinking: req.thinking,
296
287
  jsonSchema: req.jsonSchema,
297
288
  sessionId,
298
289
  needsOneMillionSuffix,
@@ -324,8 +315,8 @@ export function runClaudeSession(
324
315
  TMPDIR: process.env.TMPDIR,
325
316
  CLAUDE_CODE_OAUTH_TOKEN: req.token,
326
317
  CLAUDE_CONFIG_DIR: configDir,
318
+ ...(thinking.extraBody ? { CLAUDE_CODE_EXTRA_BODY: thinking.extraBody } : {}),
327
319
  CLAUDE_CODE_DISABLE_AUTO_MEMORY: "1",
328
- ...thinkingEnv(model),
329
320
  ...oneMillionEnv(supportsOneMillion),
330
321
  CLAUDE_CODE_DISABLE_CLAUDE_MDS: "1",
331
322
  CLAUDE_CODE_DISABLE_TERMINAL_TITLE: "1",
@@ -14,7 +14,7 @@ const logger = getLogger(["qgrid", "effort"]);
14
14
  */
15
15
 
16
16
  // Codex 구독 백엔드 모델 카탈로그(~/.codex/models_cache.json) 의 supported_reasoning_levels 합집합.
17
- // 공개 OpenAI API 의 `none`/`minimal` 은 이 경로에 없다. 모델별 상한은 model-cost.ts 의 카탈로그에 있다.
17
+ // Public effort options exclude none/minimal. thinking:false maps to wire-level none separately.
18
18
  export const OPENAI_EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultra"] as const;
19
19
  export type OpenAIEffort = (typeof OPENAI_EFFORTS)[number];
20
20
 
@@ -53,7 +53,11 @@ export interface GenerateRequest {
53
53
  model?: string;
54
54
  systemPrompt?: string;
55
55
  outputSchema?: JsonValue;
56
+ // A completed control message can yield to the caller before speculative later text.
57
+ // The transport must still drain to completion for usage, cancellation and errors.
58
+ stopAfterOutputMessage?: (text: string, phase?: string) => boolean;
56
59
  effort?: string;
60
+ thinking?: boolean;
57
61
  verbosity?: string;
58
62
  reasoningSummary?: string;
59
63
  serviceTier?: string;
@@ -1,6 +1,6 @@
1
1
  export class SmoothWeightedRoundRobin {
2
- private readonly weights = new Map<number, number>();
3
- private readonly currentScores = new Map<number, number>();
2
+ readonly weights = new Map<number, number>();
3
+ readonly currentScores = new Map<number, number>();
4
4
 
5
5
  setToken(tokenId: number, weight: number): void {
6
6
  if (!Number.isInteger(weight) || weight < 1 || weight > 100) {
@@ -31,7 +31,7 @@ export class SmoothWeightedRoundRobin {
31
31
  let selectedScore = Number.NEGATIVE_INFINITY;
32
32
  let totalWeight = 0;
33
33
 
34
- for (const tokenId of candidates) {
34
+ candidates.forEach((tokenId) => {
35
35
  const weight = this.weights.get(tokenId)!;
36
36
  totalWeight += weight;
37
37
  const score = (this.currentScores.get(tokenId) ?? 0) + weight;
@@ -40,7 +40,7 @@ export class SmoothWeightedRoundRobin {
40
40
  selected = tokenId;
41
41
  selectedScore = score;
42
42
  }
43
- }
43
+ });
44
44
 
45
45
  this.currentScores.set(selected, selectedScore - totalWeight);
46
46
  return selected;
@@ -0,0 +1,10 @@
1
+ import { BadRequestException } from "sonamu";
2
+
3
+ import { type LocalizedString } from "../../../i18n/sd.generated";
4
+
5
+ export class ThinkingValidationError extends BadRequestException {
6
+ constructor(message: string) {
7
+ super(message as LocalizedString);
8
+ this.name = "ThinkingValidationError";
9
+ }
10
+ }
@@ -61,7 +61,7 @@ export interface OpenAIResponsesOptions {
61
61
  toolChoice?: string | OpenAIResponseItem;
62
62
  parallelToolCalls?: boolean;
63
63
  reasoning?: {
64
- effort?: OpenAIEffort;
64
+ effort?: OpenAIEffort | "none";
65
65
  summary?: "auto" | "concise" | "detailed";
66
66
  };
67
67
  verbosity?: "low" | "medium" | "high";
@@ -170,7 +170,7 @@ export interface OpenAIUsage {
170
170
 
171
171
  export type OpenAINormalizedEvent =
172
172
  | { type: "created"; responseId?: string }
173
- | { type: "text-delta"; text: string }
173
+ | { type: "text-delta"; text: string; itemId?: string }
174
174
  | {
175
175
  type: "image";
176
176
  id?: string;
@@ -235,7 +235,11 @@ export function normalizeOpenAIEvent(raw: unknown): OpenAINormalizedEvent | unde
235
235
  return { type: "created", ...(response?.id ? { responseId: String(response.id) } : {}) };
236
236
  }
237
237
  if (type === "response.output_text.delta" && typeof event.delta === "string") {
238
- return { type: "text-delta", text: event.delta };
238
+ return {
239
+ type: "text-delta",
240
+ text: event.delta,
241
+ ...(typeof event.item_id === "string" ? { itemId: event.item_id } : {}),
242
+ };
239
243
  }
240
244
  if (
241
245
  (type === "response.output_item.added" || type === "response.output_item.done") &&
@@ -177,7 +177,7 @@ class MockSocket implements OpenAIWebSocketLike {
177
177
  }
178
178
 
179
179
  emit(event: string, ...args: unknown[]): void {
180
- for (const listener of this.listeners.get(event) ?? []) listener(...(args as never[]));
180
+ (this.listeners.get(event) ?? []).forEach((listener) => listener(...(args as never[])));
181
181
  }
182
182
 
183
183
  send(data: string, callback?: (error?: Error) => void): void {
@@ -534,10 +534,10 @@ describe("OpenAI direct Responses WebSocket client", () => {
534
534
  mock.sockets[2]!.emit("open");
535
535
  await expect(sendFailure).rejects.toThrow("send failed");
536
536
 
537
- for (const socket of mock.sockets) {
537
+ mock.sockets.forEach((socket) => {
538
538
  expect(socket.terminated).toBe(true);
539
539
  expect([...socket.listeners.values()].flat()).toHaveLength(0);
540
- }
540
+ });
541
541
  });
542
542
 
543
543
  it("terminates on iterator cancellation and when the receive buffer exceeds 1600 records", async () => {
@@ -31,7 +31,6 @@ export interface OpenAITransportRequest {
31
31
  clientRequestId: string;
32
32
  }
33
33
 
34
- /** Transport-independent streaming interface; HTTPS is the first implementation. */
35
34
  export interface OpenAIResponsesTransport {
36
35
  stream(request: OpenAITransportRequest): OpenAIEventStream;
37
36
  close?(): void;
@@ -150,7 +149,7 @@ function socketEvents(socket: OpenAIWebSocketLike, signal?: AbortSignal) {
150
149
  kind: "error" as const,
151
150
  error: new OpenAIProtocolError("OpenAI WebSocket event stream was closed"),
152
151
  };
153
- for (const waiter of waiters.splice(0)) waiter(closed);
152
+ waiters.splice(0).forEach((waiter) => waiter(closed));
154
153
  },
155
154
  detachAbort: () => signal?.removeEventListener("abort", onAbort),
156
155
  };
@@ -198,12 +197,12 @@ type AffinityConnection = {
198
197
  };
199
198
 
200
199
  export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
201
- private static readonly MAX_AFFINITY_CONNECTIONS = 16;
202
- private credentials: OpenAIDirectCredentials;
203
- private readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
204
- private readonly webSocketFactory: OpenAIWebSocketFactory;
205
- private readonly affinityConnections = new Map<string, AffinityConnection>();
206
- private readonly busyAffinities = new Set<string>();
200
+ static readonly MAX_AFFINITY_CONNECTIONS = 16;
201
+ credentials: OpenAIDirectCredentials;
202
+ readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
203
+ readonly webSocketFactory: OpenAIWebSocketFactory;
204
+ readonly affinityConnections = new Map<string, AffinityConnection>();
205
+ readonly busyAffinities = new Set<string>();
207
206
 
208
207
  constructor(options: OpenAIWebSocketTransportOptions) {
209
208
  this.credentials = options.credentials;
@@ -212,24 +211,17 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
212
211
  options.webSocketFactory ?? ((url, wsOptions) => new WsClient(url, wsOptions));
213
212
  }
214
213
 
215
- stream(request: OpenAITransportRequest): OpenAIEventStream {
216
- return this.run(request);
217
- }
218
-
219
214
  close(): void {
220
- for (const { socket, events } of this.affinityConnections.values()) {
215
+ this.affinityConnections.forEach(({ socket, events }) => {
221
216
  events.cleanup();
222
217
  socket.close(1000, "transport closed");
223
- }
218
+ });
224
219
  this.affinityConnections.clear();
225
220
  this.busyAffinities.clear();
226
221
  }
227
222
 
228
- /**
229
- * 캐시에 연결을 등록한다. 자리가 없으면 유휴 연결만 축출하고, 전부 사용 중이면
230
- * 진행 중인 응답을 끊는 대신 등록을 포기한다(false → 이 요청은 일회성 소켓으로 처리).
231
- */
232
- private rememberAffinityConnection(threadId: string, connection: AffinityConnection): boolean {
223
+ // 유휴 연결만 축출한다. 전부 사용 중이면 false를 반환해 일회성 소켓으로 처리한다.
224
+ rememberAffinityConnection(threadId: string, connection: AffinityConnection): boolean {
233
225
  if (
234
226
  !this.affinityConnections.has(threadId) &&
235
227
  this.affinityConnections.size >= OpenAIWebSocketTransport.MAX_AFFINITY_CONNECTIONS
@@ -257,7 +249,7 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
257
249
  return true;
258
250
  }
259
251
 
260
- private async *run(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
252
+ async *stream(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
261
253
  let refreshed = false;
262
254
  while (true) {
263
255
  if (request.signal?.aborted) throw abortError(request.signal);
@@ -270,7 +262,6 @@ export class OpenAIWebSocketTransport implements OpenAIResponsesTransport {
270
262
  entry.events.cleanup();
271
263
  }
272
264
  const cached = entry?.closed ? undefined : entry;
273
- // 캐시 등록에 실패하면 이 요청은 재사용 대상이 아니다.
274
265
  let retained = retainConnection;
275
266
  const socket =
276
267
  cached?.socket ??
@@ -392,9 +383,9 @@ async function responseError(response: Response): Promise<OpenAIProtocolError> {
392
383
  }
393
384
 
394
385
  export class OpenAIHttpsTransport implements OpenAIResponsesTransport {
395
- private credentials: OpenAIDirectCredentials;
396
- private readonly fetchImpl: typeof fetch;
397
- private readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
386
+ credentials: OpenAIDirectCredentials;
387
+ readonly fetchImpl: typeof fetch;
388
+ readonly refreshCredentials?: () => Promise<OpenAIDirectCredentials>;
398
389
 
399
390
  constructor(options: OpenAIHttpsTransportOptions) {
400
391
  this.credentials = options.credentials;
@@ -402,11 +393,7 @@ export class OpenAIHttpsTransport implements OpenAIResponsesTransport {
402
393
  this.refreshCredentials = options.refreshCredentials;
403
394
  }
404
395
 
405
- stream(request: OpenAITransportRequest): OpenAIEventStream {
406
- return this.run(request);
407
- }
408
-
409
- private async *run(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
396
+ async *stream(request: OpenAITransportRequest): AsyncGenerator<OpenAINormalizedEvent> {
410
397
  let refreshed = false;
411
398
 
412
399
  while (true) {
@@ -449,10 +436,10 @@ export interface OpenAIDirectClientOptions extends OpenAIHttpsTransportOptions {
449
436
  }
450
437
 
451
438
  export class OpenAIDirectClient {
452
- private readonly transport: OpenAIResponsesTransport;
453
- private imageCredentials: OpenAIDirectCredentials;
439
+ readonly transport: OpenAIResponsesTransport;
440
+ imageCredentials: OpenAIDirectCredentials;
454
441
 
455
- constructor(private readonly options: OpenAIDirectClientOptions) {
442
+ constructor(readonly options: OpenAIDirectClientOptions) {
456
443
  this.imageCredentials = options.credentials;
457
444
  this.transport =
458
445
  options.transport ??
@@ -484,7 +471,7 @@ export class OpenAIDirectClient {
484
471
  });
485
472
  }
486
473
 
487
- private async *standaloneImages(
474
+ async *standaloneImages(
488
475
  options: OpenAIResponsesOptions,
489
476
  signal?: AbortSignal,
490
477
  ): OpenAIEventStream {
@@ -51,6 +51,80 @@ async function tickTimer(): Promise<void> {
51
51
  }
52
52
 
53
53
  describe("OpenAIDispatcher direct runtime", () => {
54
+ it.each(["gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"])(
55
+ "disables thinking and omits summaries through generate/stream for %s",
56
+ async (model) => {
57
+ const run = vi.fn((options: OpenAIResponsesOptions) => {
58
+ expect(buildOpenAIResponsesRequest(options).reasoning).toEqual({ effort: "none" });
59
+ expect(options.outputSchema).toEqual({ schema: { type: "object" } });
60
+ return events({ type: "completed", responseId: "r" });
61
+ });
62
+ const d = dispatcher(run);
63
+ await d.onTokenAdded(1, "one", credentials);
64
+ const req = request({ model, thinking: false, effort: "high", reasoningSummary: "detailed", outputSchema: { type: "object" } });
65
+ await d.generate(req);
66
+ const complete = vi.fn();
67
+ await d.generateStream(req, { onDelta: vi.fn(), onComplete: complete, onError: vi.fn() });
68
+ expect(complete).toHaveBeenCalledOnce();
69
+ expect(run).toHaveBeenCalledTimes(2);
70
+ },
71
+ );
72
+
73
+ it.each([
74
+ ["gpt-6-astra", "requires thinking"],
75
+ ["gpt-6.1-sol", "requires thinking"],
76
+ ["gpt-5.4", "has not been verified"],
77
+ ])("rejects thinking:false for %s before token selection", async (model, reason) => {
78
+ const run = vi.fn(() => events());
79
+ // No tokens: validation must win over NO_OPENAI_WORKERS and never contact transport.
80
+ const d = dispatcher(run);
81
+ const req = request({ model, thinking: false });
82
+ await expect(d.generate(req)).rejects.toMatchObject({ statusCode: 400, message: expect.stringContaining(reason) });
83
+ const onError = vi.fn();
84
+ const onComplete = vi.fn();
85
+ await expect(d.generateStream(req, { onDelta: vi.fn(), onError, onComplete })).rejects.toThrow(model);
86
+ expect(onError).toHaveBeenCalledOnce();
87
+ expect(onComplete).not.toHaveBeenCalled();
88
+ expect(run).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it.each([true, undefined])("preserves effort and summary with thinking=%s on an off-unsupported model", async (thinking) => {
92
+ const run = vi.fn((options: OpenAIResponsesOptions) => {
93
+ expect(options.reasoning).toEqual({ effort: "high", summary: "detailed" });
94
+ return events({ type: "completed", responseId: "r" });
95
+ });
96
+ const d = dispatcher(run);
97
+ await d.onTokenAdded(1, "one", credentials);
98
+ await d.generate(request({ model: "gpt-6-astra", thinking, effort: "high", reasoningSummary: "detailed" }));
99
+ expect(run).toHaveBeenCalledOnce();
100
+ });
101
+
102
+ it("explicit true enables reasoning with absent or invalid effort without changing omission", async () => {
103
+ const observed: Array<OpenAIResponsesOptions["reasoning"]> = [];
104
+ const d = dispatcher((options) => {
105
+ observed.push(options.reasoning);
106
+ return events({ type: "completed", responseId: "r" });
107
+ });
108
+ await d.onTokenAdded(1, "one", credentials);
109
+ for (const effort of [undefined, "invalid"]) {
110
+ await d.generate(request({ model: "gpt-6-sol", thinking: true, effort }));
111
+ await d.generate(request({ model: "gpt-6-sol", effort }));
112
+ }
113
+ expect(observed).toEqual([{ effort: "low" }, undefined, { effort: "low" }, undefined]);
114
+ });
115
+
116
+ it("applies thinking:false to the automatic image text driver", async () => {
117
+ const run = vi.fn((options: OpenAIResponsesOptions) => {
118
+ expect(options.reasoning).toEqual({ effort: "none" });
119
+ expect(options.imageGeneration).toBe(true);
120
+ return events({ type: "text-delta", text: "hello" }, { type: "completed", responseId: "r" });
121
+ });
122
+ const d = dispatcher(run);
123
+ await d.onTokenAdded(1, "one", credentials);
124
+ await d.generate(request({ model: "gpt-6-sol", thinking: false, imageGeneration: "auto" }));
125
+ expect(run).toHaveBeenCalledOnce();
126
+ });
127
+
54
128
  it.each(["png", "jpeg", "webp"] as const)("forwards %s format and preserves returned MIME", async (outputFormat) => {
55
129
  const args = QueryInput.parse({ prompt: "draw", imageGeneration: true, imageGenerationOptions: { outputFormat } });
56
130
  let body: ReturnType<typeof buildOpenAIResponsesRequest> | undefined;
@@ -112,7 +186,7 @@ describe("OpenAIDispatcher direct runtime", () => {
112
186
  await d.onTokenAdded(1, "one", credentials);
113
187
  await expect(d.getRateLimitsByTokenId(1)).rejects.toThrow(`HTTP ${status}`);
114
188
  expect(refreshMock).not.toHaveBeenCalled();
115
- expect(d.tokenCount).toBe(1);
189
+ expect(d.countActiveTokens()).toBe(1);
116
190
  });
117
191
 
118
192
  it("propagates refresh failure instead of retrying rejected credentials", async () => {
@@ -275,18 +349,20 @@ describe("OpenAIDispatcher direct runtime", () => {
275
349
  });
276
350
  });
277
351
 
278
- it("streams deltas and reports completion", async () => {
352
+ it("streams final-answer deltas incrementally while checking message boundaries", async () => {
279
353
  const d = dispatcher(() =>
280
354
  events(
281
- { type: "text-delta", text: "a" },
282
- { type: "text-delta", text: "b" },
355
+ { type: "output-item", item: { id: "m", type: "message", phase: "final_answer", content: [] } },
356
+ { type: "text-delta", text: "a", itemId: "m" },
357
+ { type: "text-delta", text: "b", itemId: "m" },
358
+ { type: "output-item", completed: true, item: { id: "m", type: "message", phase: "final_answer", content: [{type: "output_text", text: "ab"}] } },
283
359
  { type: "completed", responseId: "r" },
284
360
  ),
285
361
  );
286
362
  await d.onTokenAdded(1, "one", credentials);
287
363
  const deltas: string[] = [];
288
364
  const complete = vi.fn();
289
- await d.generateStream(request(), {
365
+ await d.generateStream(request({stopAfterOutputMessage: () => false}), {
290
366
  onDelta: (v) => deltas.push(v),
291
367
  onComplete: complete,
292
368
  onError: vi.fn(),
@@ -295,6 +371,21 @@ describe("OpenAIDispatcher direct runtime", () => {
295
371
  expect(complete).toHaveBeenCalledWith(expect.objectContaining({ text: "ab" }));
296
372
  });
297
373
 
374
+ it("still rejects an upstream failure after accepting a completed output message", async () => {
375
+ const failure = new Error("upstream failed after tool request");
376
+ const d = dispatcher(() => events(
377
+ {type: "text-delta", text: "tool request"},
378
+ {type: "output-item", completed: true, item: {type: "message", content: [{type: "output_text", text: "tool request"}]}},
379
+ {type: "error", error: failure},
380
+ ));
381
+ await d.onTokenAdded(1, "one", credentials);
382
+ const complete = vi.fn();
383
+ const onError = vi.fn();
384
+ await expect(d.generateStream(request({stopAfterOutputMessage: () => true}), {onDelta: vi.fn(), onComplete: complete, onError})).rejects.toBe(failure);
385
+ expect(complete).not.toHaveBeenCalled();
386
+ expect(onError).toHaveBeenCalledWith(failure);
387
+ });
388
+
298
389
  it("returns multiple hosted images with one driver request", async () => {
299
390
  const run = vi.fn(() => events(
300
391
  { type: "image", id: "first", base64: "first", mimeType: "image/png" },
@@ -492,7 +583,7 @@ describe("OpenAIDispatcher direct runtime", () => {
492
583
  await vi.waitFor(() => expect(releases).toHaveLength(3));
493
584
  expect(d.inFlight).toBe(3);
494
585
 
495
- for (const release of releases.splice(0)) release();
586
+ releases.splice(0).forEach((release) => release());
496
587
  await Promise.all([first, second, third]);
497
588
  expect(d.inFlight).toBe(0);
498
589
  });