@cartanova/qgrid-cli 2.3.4 → 2.3.6

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 (93) hide show
  1. package/bundle/dist/application/qgrid/oauth.js +15 -12
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +23 -10
  5. package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
  6. package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
  7. package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
  8. package/bundle/dist/application/request-log/request-log.model.js +12 -7
  9. package/bundle/dist/application/sonamu.generated.js +11 -3
  10. package/bundle/dist/application/sonamu.generated.sso.js +7 -2
  11. package/bundle/dist/application/token/token.model.js +10 -2
  12. package/bundle/dist/application/token/token.types.js +5 -3
  13. package/bundle/dist/i18n/sd.generated.js +4 -1
  14. package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
  15. package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
  16. package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
  17. package/bundle/dist/sonamu.config.js +13 -6
  18. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
  19. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
  20. package/bundle/dist/utils/providers/anthropic/claude-session.js +37 -14
  21. package/bundle/dist/utils/providers/common/ttft.js +25 -0
  22. package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
  23. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
  24. package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
  25. package/bundle/src/application/qgrid/oauth.ts +17 -7
  26. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
  27. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
  28. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
  29. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
  30. package/bundle/src/application/qgrid/qgrid.frame.test.ts +128 -0
  31. package/bundle/src/application/qgrid/qgrid.frame.ts +41 -20
  32. package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
  33. package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
  34. package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
  35. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
  36. package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
  37. package/bundle/src/application/request-log/request-log.entity.json +3 -0
  38. package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
  39. package/bundle/src/application/request-log/request-log.model.ts +20 -6
  40. package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
  41. package/bundle/src/application/sonamu.generated.http +7 -2
  42. package/bundle/src/application/sonamu.generated.sso.ts +5 -0
  43. package/bundle/src/application/sonamu.generated.ts +11 -1
  44. package/bundle/src/application/token/token.entity.json +8 -2
  45. package/bundle/src/application/token/token.model.test.ts +69 -0
  46. package/bundle/src/application/token/token.model.ts +8 -1
  47. package/bundle/src/application/token/token.types.test.ts +34 -0
  48. package/bundle/src/application/token/token.types.ts +5 -0
  49. package/bundle/src/i18n/sd.generated.ts +3 -0
  50. package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
  51. package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
  52. package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
  53. package/bundle/src/sonamu.config.ts +8 -1
  54. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
  55. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
  56. package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
  57. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
  58. package/bundle/src/utils/providers/anthropic/claude-session.test.ts +40 -0
  59. package/bundle/src/utils/providers/anthropic/claude-session.ts +58 -6
  60. package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
  61. package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
  62. package/bundle/src/utils/providers/common/ttft.ts +26 -0
  63. package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
  64. package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
  65. package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
  66. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
  67. package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
  68. package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
  69. package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
  70. package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
  71. package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
  72. package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
  73. package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
  74. package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
  75. package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
  76. package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
  77. package/bundle/web-dist/client/index.html +3 -3
  78. package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
  79. package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
  80. package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
  81. package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
  82. package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
  83. package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
  84. package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
  85. package/bundle/web-dist/server/entry-server.generated.js +6 -7
  86. package/dist/cli.js +67 -15
  87. package/package.json +1 -1
  88. package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
  89. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
  90. package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
  91. package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
  92. package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
  93. /package/bundle/web-dist/server/assets/{cost-DzjfVtUr.js → cost-CSH_t17I.js} +0 -0
@@ -0,0 +1,27 @@
1
+ import { fetchUsageWithMeta } from "../../../application/qgrid/oauth";
2
+
3
+ export type AnthropicQuotaUsageResult =
4
+ | { kind: "ok"; utilizationPct: number; cacheAgeMs: number }
5
+ | { kind: "lookup_failed"; reason: string };
6
+
7
+ export async function readAnthropicQuotaUsage(
8
+ accessToken: string,
9
+ ): Promise<AnthropicQuotaUsageResult> {
10
+ try {
11
+ const { data, cachedAt } = await fetchUsageWithMeta(accessToken);
12
+ if (data.error) return { kind: "lookup_failed", reason: data.error };
13
+
14
+ const utilization = data.five_hour?.utilization;
15
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) {
16
+ return { kind: "lookup_failed", reason: "five_hour utilization unavailable" };
17
+ }
18
+
19
+ return {
20
+ kind: "ok",
21
+ utilizationPct: utilization,
22
+ cacheAgeMs: Math.max(Date.now() - cachedAt, 0),
23
+ };
24
+ } catch (e) {
25
+ return { kind: "lookup_failed", reason: (e as Error).message };
26
+ }
27
+ }
@@ -12,6 +12,7 @@ import {
12
12
  oneMillionEnv,
13
13
  runClaudeSession,
14
14
  structuredOutputRetriesEnv,
15
+ SYSTEM_PROMPT_ARGV_MAX_BYTES,
15
16
  withSessionLock,
16
17
  } from "./claude-session";
17
18
  import { buildStreamJsonInput } from "./stream-json-adapter";
@@ -238,6 +239,45 @@ describe("buildClaudeArgs (멀티턴/격리/structured)", () => {
238
239
  expect(args).toContain("--system-prompt");
239
240
  expect(args[args.indexOf("--system-prompt") + 1]).toBe("you are X");
240
241
  });
242
+
243
+ // E2BIG 회피: 대형 시스템 프롬프트는 파일 경로로 넘겨 Linux argv 한계(128KB)를 피한다.
244
+ it("systemPromptFile 이 오면 --system-prompt-file 을 쓰고 inline --system-prompt 는 안 쓴다", () => {
245
+ const args = buildClaudeArgs({
246
+ model: "m",
247
+ systemPromptFile: "/cfg/7/system-prompt-u.txt",
248
+ sessionId: "u",
249
+ });
250
+ expect(args).toContain("--system-prompt-file");
251
+ expect(args[args.indexOf("--system-prompt-file") + 1]).toBe("/cfg/7/system-prompt-u.txt");
252
+ expect(args).not.toContain("--system-prompt");
253
+ });
254
+
255
+ it("systemPromptFile 이 inline system 보다 우선한다 (둘 다 와도 file 경로만)", () => {
256
+ const args = buildClaudeArgs({
257
+ model: "m",
258
+ system: "ignored inline",
259
+ systemPromptFile: "/cfg/7/sp.txt",
260
+ sessionId: "u",
261
+ });
262
+ expect(args).toContain("--system-prompt-file");
263
+ expect(args).not.toContain("--system-prompt");
264
+ expect(args).not.toContain("ignored inline");
265
+ });
266
+
267
+ it("임계값(64KB)을 넘는 시스템 프롬프트가 argv 에 통째로 들어가지 않는다 (E2BIG 회귀 방지)", () => {
268
+ const huge = "x".repeat(SYSTEM_PROMPT_ARGV_MAX_BYTES + 1);
269
+ // 실제 호출부(runClaudeSession)는 huge 를 파일로 빼고 systemPromptFile 만 넘긴다.
270
+ const args = buildClaudeArgs({
271
+ model: "m",
272
+ systemPromptFile: "/cfg/7/sp.txt",
273
+ sessionId: "u",
274
+ });
275
+ // 어떤 단일 인자도 임계값을 넘지 않아야 한다(=거대 프롬프트가 argv 에 없음).
276
+ for (const a of args) {
277
+ expect(Buffer.byteLength(a, "utf8")).toBeLessThanOrEqual(SYSTEM_PROMPT_ARGV_MAX_BYTES);
278
+ }
279
+ expect(args).not.toContain(huge);
280
+ });
241
281
  });
242
282
 
243
283
  describe("decorateAndSerialize (envelope, U2 P1-#3 위임분)", () => {
@@ -16,11 +16,12 @@
16
16
 
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomUUID } from "node:crypto";
19
- import { mkdirSync, writeFileSync } from "node:fs";
19
+ import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
 
21
21
  import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
22
22
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
23
23
  import { type UserInput } from "../../../codex-protocol/v2/UserInput";
24
+ import { createTtftTracker } from "../common/ttft";
24
25
  import {
25
26
  ANTHROPIC_CLAUDE_CWD,
26
27
  anthropicConfigDir,
@@ -111,6 +112,7 @@ export interface ClaudeSessionRequest {
111
112
  export interface ClaudeSessionResult extends ClaudeStreamResult {
112
113
  sessionId: string;
113
114
  workerId: number;
115
+ ttftMs: number | null;
114
116
  }
115
117
 
116
118
  // per-token CLAUDE_CONFIG_DIR 보장(격리). 경로 규칙은 anthropicConfigDir(순수 함수)이 소유 —
@@ -156,9 +158,21 @@ export function structuredOutputRetriesEnv(raw = process.env.MAX_STRUCTURED_OUTP
156
158
  return { MAX_STRUCTURED_OUTPUT_RETRIES: String(Math.max(parsed, 1)) };
157
159
  }
158
160
 
161
+ // Linux 단일 argv 인자 한계(MAX_ARG_STRLEN = PAGE_SIZE×32 = 128KB, 커널 하드코딩 상수)
162
+ // 시스템 프롬프트가 이를 넘으면 execve 가 E2BIG 로 거부된다. deti 등 대형 프롬프트는 실측
163
+ // 평균 343KB·최대 485KB(opus-4-8)라 일상적으로 초과한다. 한계의 절반(64KB)을 임계값으로 잡아
164
+ // UTF-8 멀티바이트·예측 못 한 여유분까지 안전 마진을 둔다.
165
+ export const SYSTEM_PROMPT_ARGV_MAX_BYTES = 64 * 1024;
166
+
159
167
  export function buildClaudeArgs(opts: {
160
168
  model: string;
169
+ // 시스템 프롬프트 전달은 크기로 분기한다(호출부가 결정):
170
+ // - 작으면 system(inline) → --system-prompt <text> (파일 I/O 없음, 기존 경로)
171
+ // - 크면 systemPromptFile(경로) → --system-prompt-file <path> (argv 한계 회피)
172
+ // --system-prompt-file 은 --system-prompt 와 동일하게 default 를 replace 한다(append 아님 —
173
+ // R7 격리 보존, 실측 확인). 둘 다 없으면 빈 --system-prompt(default 23k 주입 방지).
161
174
  system?: string;
175
+ systemPromptFile?: string;
162
176
  effort?: string;
163
177
  jsonSchema?: string;
164
178
  sessionId: string;
@@ -171,6 +185,12 @@ export function buildClaudeArgs(opts: {
171
185
  ? ["--tools", "", "--allowed-tools", "StructuredOutput"]
172
186
  : ["--tools", ""];
173
187
 
188
+ // 시스템 프롬프트: 파일 경로가 오면 --system-prompt-file, 아니면 inline --system-prompt.
189
+ // (호출부가 크기로 분기해 둘 중 하나만 채운다. 둘 다 없으면 빈 --system-prompt.)
190
+ const systemArgs = opts.systemPromptFile
191
+ ? ["--system-prompt-file", opts.systemPromptFile]
192
+ : ["--system-prompt", opts.system ?? ""];
193
+
174
194
  const args: Array<string> = [
175
195
  "-p",
176
196
  ...toolArgs,
@@ -189,8 +209,8 @@ export function buildClaudeArgs(opts: {
189
209
  "--model",
190
210
  applyOneMillionSuffix(opts.model, opts.needsOneMillionSuffix ?? false),
191
211
  // inline [System] 금지 — 정식 채널만(R7). 생략 시 CC default(23k) 주입되므로 반드시 명시.
192
- "--system-prompt",
193
- opts.system ?? "",
212
+ // 크기에 따라 --system-prompt(작음) / --system-prompt-file(큼)로 분기(systemArgs).
213
+ ...systemArgs,
194
214
  "--thinking",
195
215
  "disabled",
196
216
  "--effort",
@@ -225,9 +245,19 @@ export function runClaudeSession(
225
245
  ensureCwd();
226
246
  const configDir = ensureConfigDir(req.tokenId);
227
247
 
248
+ // 시스템 프롬프트를 크기로 분기: 128KB(argv 한계)의 절반을 넘으면 파일로 넘겨 E2BIG 회피.
249
+ // 작은 프롬프트는 파일 I/O 없이 기존 inline 경로를 그대로 쓴다(대부분의 호출).
250
+ // 실제 파일 쓰기는 abort 체크 이후(Promise 안)로 미뤄, 이미 취소된 요청엔 쓰지 않는다.
251
+ const system = req.system ?? "";
252
+ const useSystemPromptFile = Buffer.byteLength(system, "utf8") > SYSTEM_PROMPT_ARGV_MAX_BYTES;
253
+ const systemPromptFile = useSystemPromptFile
254
+ ? `${configDir}/system-prompt-${sessionId}.txt`
255
+ : undefined;
256
+
228
257
  const args = buildClaudeArgs({
229
258
  model,
230
- system: req.system,
259
+ system: useSystemPromptFile ? undefined : req.system,
260
+ systemPromptFile,
231
261
  effort: req.effort,
232
262
  jsonSchema: req.jsonSchema,
233
263
  sessionId,
@@ -247,6 +277,9 @@ export function runClaudeSession(
247
277
  return;
248
278
  }
249
279
 
280
+ // 대형 시스템 프롬프트는 spawn 직전에 파일로 기록(argv E2BIG 회피). 종료 시 정리한다.
281
+ if (systemPromptFile) writeFileSync(systemPromptFile, system);
282
+
250
283
  const child = spawn("claude", args, {
251
284
  // stderr 도 캡처: invalid OAuth / 잘못된 플래그 / CLI 검증 실패가
252
285
  // "closed without result" 로 뭉개지지 않게 close/error 에 stderr 를 실어 보낸다.
@@ -274,6 +307,8 @@ export function runClaudeSession(
274
307
  let settled = false;
275
308
  let buffer = "";
276
309
  const streamState: ClaudeStreamJsonState = {};
310
+ const ttftTracker = createTtftTracker();
311
+ const onTrackedDelta = ttftTracker.wrapDelta(onDelta);
277
312
  // bounded stderr 버퍼(최근 ~4KB만 유지 — 무한 누적 방지).
278
313
  let stderrBuf = "";
279
314
  const STDERR_CAP = 4096;
@@ -281,11 +316,23 @@ export function runClaudeSession(
281
316
  stderrBuf = (stderrBuf + d.toString()).slice(-STDERR_CAP);
282
317
  });
283
318
  const stderrSuffix = () => (stderrBuf.trim() ? ` — stderr: ${stderrBuf.trim()}` : "");
319
+ // spawn 시 쓴 임시 시스템 프롬프트 파일 정리(한 번만). 모든 종료 경로에서 호출한다.
320
+ // 누락돼도 다음 ensureConfigDir 가 디렉토리를 재사용할 뿐이지만, sessionId 별 파일이라
321
+ // 쌓이면 디스크가 차므로 settle 시 즉시 지운다.
322
+ const cleanupSpawnFiles = () => {
323
+ if (!systemPromptFile) return;
324
+ try {
325
+ unlinkSync(systemPromptFile);
326
+ } catch {
327
+ // 이미 없거나 권한 문제면 무시 — 정리는 best-effort.
328
+ }
329
+ };
284
330
  let cleanupAbort: (() => void) | undefined;
285
331
  const timer = setTimeout(() => {
286
332
  if (settled) return;
287
333
  settled = true;
288
334
  cleanupAbort?.();
335
+ cleanupSpawnFiles();
289
336
  child.kill();
290
337
  reject(new Error(`Claude session timeout after ${req.timeoutMs / 1000}s`));
291
338
  }, req.timeoutMs);
@@ -295,7 +342,8 @@ export function runClaudeSession(
295
342
  settled = true;
296
343
  clearTimeout(timer);
297
344
  cleanupAbort?.();
298
- resolve({ ...result, sessionId, workerId });
345
+ cleanupSpawnFiles();
346
+ resolve({ ...result, sessionId, workerId, ttftMs: ttftTracker.value() });
299
347
  };
300
348
 
301
349
  const onAbort = () => {
@@ -303,6 +351,7 @@ export function runClaudeSession(
303
351
  settled = true;
304
352
  clearTimeout(timer);
305
353
  cleanupAbort?.();
354
+ cleanupSpawnFiles();
306
355
  child.kill();
307
356
  reject(new Error("aborted"));
308
357
  };
@@ -315,7 +364,7 @@ export function runClaudeSession(
315
364
  const lines = buffer.split("\n");
316
365
  buffer = lines.pop() ?? "";
317
366
  for (const line of lines) {
318
- const result = handleStreamJsonLine(line, onDelta, {
367
+ const result = handleStreamJsonLine(line, onTrackedDelta, {
319
368
  structuredOutput: useStructured,
320
369
  state: streamState,
321
370
  });
@@ -331,6 +380,7 @@ export function runClaudeSession(
331
380
  settled = true;
332
381
  clearTimeout(timer);
333
382
  cleanupAbort?.();
383
+ cleanupSpawnFiles();
334
384
  reject(new Error(`Claude session closed without result${stderrSuffix()}`));
335
385
  });
336
386
  child.on("error", (err) => {
@@ -338,10 +388,12 @@ export function runClaudeSession(
338
388
  settled = true;
339
389
  clearTimeout(timer);
340
390
  cleanupAbort?.();
391
+ cleanupSpawnFiles();
341
392
  reject(new Error(`Claude session spawn error: ${err.message}`));
342
393
  });
343
394
 
344
395
  // stdin 으로 입력 JSONL 흘리고 닫는다.
396
+ ttftTracker.markStart();
345
397
  child.stdin?.write(stdinPayload);
346
398
  child.stdin?.end();
347
399
  });
@@ -49,6 +49,7 @@ export interface GenerateResult {
49
49
  tokenName: string;
50
50
  usage: ProviderTokenUsageBreakdown;
51
51
  durationMs: number;
52
+ ttftMs?: number | null;
52
53
  // Provider 가 직접 산출한 비용. Anthropic Claude Code 는 total_cost_usd 를 주므로 이 값을
53
54
  // 우선 사용하고, 없으면 상위가 모델별 가격표로 계산한다.
54
55
  costUsd?: number;
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { createTtftTracker } from "./ttft";
4
+
5
+ describe("createTtftTracker", () => {
6
+ it("records the first delta once relative to the marked start", () => {
7
+ let now = 100;
8
+ const tracker = createTtftTracker(() => now);
9
+ const deltas: string[] = [];
10
+ const onDelta = tracker.wrapDelta((text) => deltas.push(text));
11
+
12
+ tracker.markStart();
13
+ now = 135;
14
+ onDelta("a");
15
+ now = 200;
16
+ onDelta("b");
17
+
18
+ expect(tracker.value()).toBe(35);
19
+ expect(deltas).toEqual(["a", "b"]);
20
+ });
21
+
22
+ it("keeps null when no delta is recorded", () => {
23
+ const tracker = createTtftTracker(() => 100);
24
+
25
+ tracker.markStart();
26
+
27
+ expect(tracker.value()).toBeNull();
28
+ });
29
+
30
+ it("does not treat a delta before start as zero", () => {
31
+ const tracker = createTtftTracker(() => 100);
32
+
33
+ tracker.recordFirstDelta();
34
+
35
+ expect(tracker.value()).toBeNull();
36
+ });
37
+ });
@@ -0,0 +1,26 @@
1
+ export function createTtftTracker(now: () => number = Date.now): {
2
+ markStart: () => void;
3
+ recordFirstDelta: () => void;
4
+ value: () => number | null;
5
+ wrapDelta: (onDelta: (text: string) => void) => (text: string) => void;
6
+ } {
7
+ let startedAt: number | null = null;
8
+ let ttftMs: number | null = null;
9
+
10
+ const recordFirstDelta = () => {
11
+ if (startedAt === null || ttftMs !== null) return;
12
+ ttftMs = Math.max(0, now() - startedAt);
13
+ };
14
+
15
+ return {
16
+ markStart: () => {
17
+ startedAt = now();
18
+ },
19
+ recordFirstDelta,
20
+ value: () => ttftMs,
21
+ wrapDelta: (onDelta) => (text) => {
22
+ recordFirstDelta();
23
+ onDelta(text);
24
+ },
25
+ };
26
+ }
@@ -53,6 +53,41 @@ function createWorkerWithRequestSpy() {
53
53
  return { worker, request };
54
54
  }
55
55
 
56
+ function createWorkerWithNotificationRpc(
57
+ onRequest: (
58
+ method: string,
59
+ params: unknown,
60
+ handlers: Map<string, (params: never) => void>,
61
+ ) => Promise<unknown>,
62
+ ) {
63
+ const worker = new CodexAppServerWorker({
64
+ tokenId: 1,
65
+ tokenName: "token",
66
+ accessToken: "access",
67
+ accountId: "account",
68
+ });
69
+ const handlers = new Map<string, (params: never) => void>();
70
+ const request = vi.fn((method: string, params?: unknown) => onRequest(method, params, handlers));
71
+ const rpc = {
72
+ request,
73
+ onNotification(method: string, handler: (params: never) => void) {
74
+ handlers.set(method, handler);
75
+ },
76
+ };
77
+ (
78
+ worker as unknown as {
79
+ rpc: typeof rpc;
80
+ ready: boolean;
81
+ }
82
+ ).rpc = rpc;
83
+ (
84
+ worker as unknown as {
85
+ ready: boolean;
86
+ }
87
+ ).ready = true;
88
+ return { worker, request, handlers };
89
+ }
90
+
56
91
  describe("CodexAppServerWorker active turn cleanup", () => {
57
92
  it("rejects a non-streaming turn when the worker process exits", async () => {
58
93
  const { worker } = createWorkerWithFakeRpc();
@@ -101,6 +136,119 @@ describe("CodexAppServerWorker active turn cleanup", () => {
101
136
  });
102
137
  });
103
138
 
139
+ describe("CodexAppServerWorker TTFT", () => {
140
+ it("captures fast non-stream deltas registered before turn/start completes", async () => {
141
+ let now = 1_000;
142
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
143
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
144
+ if (method === "thread/start") {
145
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
146
+ }
147
+ if (method === "turn/start") {
148
+ now = 1_025;
149
+ handlers.get("item/agentMessage/delta")?.({
150
+ threadId: "thread-1",
151
+ delta: "h",
152
+ } as never);
153
+ now = 1_050;
154
+ handlers.get("item/agentMessage/delta")?.({
155
+ threadId: "thread-1",
156
+ delta: "i",
157
+ } as never);
158
+ handlers.get("turn/completed")?.({
159
+ threadId: "thread-1",
160
+ turn: { status: "completed", durationMs: 90 },
161
+ } as never);
162
+ return { turn: { id: "turn-1" } };
163
+ }
164
+ throw new Error(`unexpected request: ${method}`);
165
+ });
166
+
167
+ try {
168
+ const result = await worker.executeTurn({
169
+ input: [{ type: "text", text: "hi", text_elements: [] }],
170
+ });
171
+
172
+ expect(result.text).toBe("hi");
173
+ expect(result.ttftMs).toBe(25);
174
+ } finally {
175
+ nowSpy.mockRestore();
176
+ }
177
+ });
178
+
179
+ it("keeps non-stream ttft null when no delta arrives", async () => {
180
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
181
+ if (method === "thread/start") {
182
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
183
+ }
184
+ if (method === "turn/start") {
185
+ handlers.get("turn/completed")?.({
186
+ threadId: "thread-1",
187
+ turn: { status: "completed", durationMs: 90 },
188
+ } as never);
189
+ return { turn: { id: "turn-1" } };
190
+ }
191
+ throw new Error(`unexpected request: ${method}`);
192
+ });
193
+
194
+ const result = await worker.executeTurn({
195
+ input: [{ type: "text", text: "hi", text_elements: [] }],
196
+ });
197
+
198
+ expect(result.ttftMs).toBeNull();
199
+ });
200
+
201
+ it("captures stream ttft from the first delta only", async () => {
202
+ let now = 2_000;
203
+ const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
204
+ const { worker } = createWorkerWithNotificationRpc(async (method, _params, handlers) => {
205
+ if (method === "thread/start") {
206
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
207
+ }
208
+ if (method === "turn/start") {
209
+ now = 2_030;
210
+ handlers.get("item/agentMessage/delta")?.({
211
+ threadId: "thread-1",
212
+ delta: "a",
213
+ } as never);
214
+ now = 2_080;
215
+ handlers.get("item/agentMessage/delta")?.({
216
+ threadId: "thread-1",
217
+ delta: "b",
218
+ } as never);
219
+ handlers.get("turn/completed")?.({
220
+ threadId: "thread-1",
221
+ turn: { status: "completed", durationMs: 120 },
222
+ } as never);
223
+ return { turn: { id: "turn-1" } };
224
+ }
225
+ throw new Error(`unexpected request: ${method}`);
226
+ });
227
+ const callbacks: StreamCallbacks = {
228
+ onDelta: vi.fn(),
229
+ onComplete: vi.fn(),
230
+ onError: vi.fn(),
231
+ };
232
+
233
+ try {
234
+ await worker.executeTurnStream(
235
+ {
236
+ input: [{ type: "text", text: "hi", text_elements: [] }],
237
+ },
238
+ callbacks,
239
+ );
240
+
241
+ expect(callbacks.onDelta).toHaveBeenCalledWith("a");
242
+ expect(callbacks.onDelta).toHaveBeenCalledWith("b");
243
+ expect(callbacks.onComplete).toHaveBeenCalledWith(
244
+ expect.objectContaining({ text: "ab", ttftMs: 30 }),
245
+ );
246
+ } finally {
247
+ nowSpy.mockRestore();
248
+ }
249
+ });
250
+ });
251
+
104
252
  describe("CodexAppServerWorker prompt prefix", () => {
105
253
  it("uses the same base instructions with and without an output schema", async () => {
106
254
  type WorkerInternals = {
@@ -10,6 +10,7 @@ import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageB
10
10
  import { type TurnStartParams } from "../../../codex-protocol/v2/TurnStartParams";
11
11
  import { type TurnStartResponse } from "../../../codex-protocol/v2/TurnStartResponse";
12
12
  import { type StreamCallbacks as CommonStreamCallbacks } from "../common/provider-dispatcher";
13
+ import { createTtftTracker } from "../common/ttft";
13
14
  import { CodexRpcClient } from "./codex-rpc";
14
15
 
15
16
  const logger = getLogger(["qgrid", "codex-worker"]);
@@ -44,6 +45,7 @@ export interface TurnResult {
44
45
  text: string;
45
46
  usage: TokenUsageBreakdown;
46
47
  durationMs: number;
48
+ ttftMs: number | null;
47
49
  model: string;
48
50
  }
49
51
 
@@ -387,8 +389,17 @@ export class CodexAppServerWorker {
387
389
  } else {
388
390
  ({ threadId, model } = await this.createThread(req));
389
391
  }
390
- await this.startTurnOnThread(threadId, req);
391
- const result = await this.consumeTurnNotifications(threadId, model);
392
+ const ttftTracker = createTtftTracker();
393
+ const resultPromise = this.consumeTurnNotifications(threadId, model, ttftTracker);
394
+ try {
395
+ ttftTracker.markStart();
396
+ await this.startTurnOnThread(threadId, req);
397
+ } catch (e) {
398
+ this.failActiveTurn(e as Error);
399
+ await resultPromise.catch(() => {});
400
+ throw e;
401
+ }
402
+ const result = await resultPromise;
392
403
  return { ...result, threadId };
393
404
  }
394
405
 
@@ -405,10 +416,20 @@ export class CodexAppServerWorker {
405
416
  } else {
406
417
  ({ threadId, model } = await this.createThread(req));
407
418
  }
408
- const { turnId } = await this.startTurnOnThread(threadId, req);
409
419
  cb.onThreadId?.(threadId);
420
+ const ttftTracker = createTtftTracker();
421
+ const resultPromise = this.consumeStreamNotifications(threadId, model, cb, ttftTracker);
422
+ let turnId: string;
423
+ try {
424
+ ttftTracker.markStart();
425
+ ({ turnId } = await this.startTurnOnThread(threadId, req));
426
+ } catch (e) {
427
+ this.failActiveTurn(e as Error);
428
+ await resultPromise.catch(() => {});
429
+ throw e;
430
+ }
410
431
  cb.onTurnId?.(turnId);
411
- await this.consumeStreamNotifications(threadId, model, cb);
432
+ await resultPromise;
412
433
  return { threadId };
413
434
  }
414
435
 
@@ -456,7 +477,11 @@ export class CodexAppServerWorker {
456
477
  abort?.(error);
457
478
  }
458
479
 
459
- private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
480
+ private consumeTurnNotifications(
481
+ threadId: string,
482
+ model: string,
483
+ ttftTracker = createTtftTracker(),
484
+ ): Promise<TurnResult> {
460
485
  return new Promise<TurnResult>((resolve, reject) => {
461
486
  let settled = false;
462
487
  let abort: ActiveTurnAbort;
@@ -510,6 +535,12 @@ export class CodexAppServerWorker {
510
535
  if (p.item.type === "agentMessage") text = p.item.text;
511
536
  });
512
537
 
538
+ this.rpc!.onNotification("item/agentMessage/delta", (p) => {
539
+ if (p.threadId !== threadId) return;
540
+ ttftTracker.recordFirstDelta();
541
+ text += p.delta;
542
+ });
543
+
513
544
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
514
545
  if (p.threadId !== threadId) return;
515
546
  // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
@@ -521,8 +552,11 @@ export class CodexAppServerWorker {
521
552
  this.rpc!.onNotification("turn/completed", (p) => {
522
553
  if (p.threadId !== threadId) return;
523
554
  durationMs = p.turn.durationMs ?? 0;
524
- if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
525
- else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
555
+ if (p.turn.status === "completed") {
556
+ finishWithResult({ text, usage, durationMs, ttftMs: ttftTracker.value(), model });
557
+ } else {
558
+ finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
559
+ }
526
560
  });
527
561
 
528
562
  this.rpc!.onNotification("error", (p) => {
@@ -538,6 +572,7 @@ export class CodexAppServerWorker {
538
572
  threadId: string,
539
573
  model: string,
540
574
  cb: StreamCallbacks,
575
+ ttftTracker = createTtftTracker(),
541
576
  ): Promise<void> {
542
577
  return new Promise<void>((resolve, reject) => {
543
578
  let settled = false;
@@ -590,6 +625,7 @@ export class CodexAppServerWorker {
590
625
 
591
626
  this.rpc!.onNotification("item/agentMessage/delta", (p) => {
592
627
  if (p.threadId !== threadId) return;
628
+ ttftTracker.recordFirstDelta();
593
629
  text += p.delta;
594
630
  cb.onDelta(p.delta);
595
631
  });
@@ -610,7 +646,13 @@ export class CodexAppServerWorker {
610
646
  this.rpc!.onNotification("turn/completed", (p) => {
611
647
  if (p.threadId !== threadId) return;
612
648
  if (p.turn.status === "completed") {
613
- finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
649
+ finishWithComplete({
650
+ text,
651
+ usage,
652
+ durationMs: p.turn.durationMs ?? 0,
653
+ ttftMs: ttftTracker.value(),
654
+ model,
655
+ });
614
656
  } else {
615
657
  finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
616
658
  }