@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.
- package/bundle/dist/application/qgrid/oauth.js +15 -12
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +2 -1
- package/bundle/dist/application/qgrid/qgrid.frame.js +23 -10
- package/bundle/dist/application/qgrid/qgrid.types.js +8 -2
- package/bundle/dist/application/qgrid/token-subscriber.js +16 -8
- package/bundle/dist/application/qgrid/token-trigger-setup.js +3 -2
- package/bundle/dist/application/request-log/request-log.model.js +12 -7
- package/bundle/dist/application/sonamu.generated.js +11 -3
- package/bundle/dist/application/sonamu.generated.sso.js +7 -2
- package/bundle/dist/application/token/token.model.js +10 -2
- package/bundle/dist/application/token/token.types.js +5 -3
- package/bundle/dist/i18n/sd.generated.js +4 -1
- package/bundle/dist/migrations/20260630223950_alter_tokens_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105327_alter_request_logs_add1_alter1.js +16 -0
- package/bundle/dist/migrations/20260701105328_alter_request_log_steps_add1_alter1.js +16 -0
- package/bundle/dist/sonamu.config.js +13 -6
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +99 -12
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +31 -0
- package/bundle/dist/utils/providers/anthropic/claude-session.js +37 -14
- package/bundle/dist/utils/providers/common/ttft.js +25 -0
- package/bundle/dist/utils/providers/openai/codex-worker.js +35 -7
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +237 -39
- package/bundle/dist/utils/providers/openai/openai-quota.js +30 -0
- package/bundle/src/application/qgrid/oauth.ts +17 -7
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +96 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +16 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +1 -0
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +128 -0
- package/bundle/src/application/qgrid/qgrid.frame.ts +41 -20
- package/bundle/src/application/qgrid/qgrid.types.ts +6 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +89 -0
- package/bundle/src/application/qgrid/token-subscriber.ts +40 -11
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -1
- package/bundle/src/application/qgrid/tool-emulation.test.ts +7 -3
- package/bundle/src/application/request-log/request-log.entity.json +3 -0
- package/bundle/src/application/request-log/request-log.model.test.ts +92 -0
- package/bundle/src/application/request-log/request-log.model.ts +20 -6
- package/bundle/src/application/request-log-step/request-log-step.entity.json +3 -0
- package/bundle/src/application/sonamu.generated.http +7 -2
- package/bundle/src/application/sonamu.generated.sso.ts +5 -0
- package/bundle/src/application/sonamu.generated.ts +11 -1
- package/bundle/src/application/token/token.entity.json +8 -2
- package/bundle/src/application/token/token.model.test.ts +69 -0
- package/bundle/src/application/token/token.model.ts +8 -1
- package/bundle/src/application/token/token.types.test.ts +34 -0
- package/bundle/src/application/token/token.types.ts +5 -0
- package/bundle/src/i18n/sd.generated.ts +3 -0
- package/bundle/src/migrations/20260630223950_alter_tokens_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105327_alter_request_logs_add1_alter1.ts +15 -0
- package/bundle/src/migrations/20260701105328_alter_request_log_steps_add1_alter1.ts +15 -0
- package/bundle/src/sonamu.config.ts +8 -1
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.test.ts +183 -15
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +142 -12
- package/bundle/src/utils/providers/anthropic/anthropic-quota.test.ts +89 -0
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +27 -0
- package/bundle/src/utils/providers/anthropic/claude-session.test.ts +40 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +58 -6
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +1 -0
- package/bundle/src/utils/providers/common/ttft.test.ts +37 -0
- package/bundle/src/utils/providers/common/ttft.ts +26 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +148 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +50 -8
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +378 -6
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +332 -37
- package/bundle/src/utils/providers/openai/openai-quota.test.ts +85 -0
- package/bundle/src/utils/providers/openai/openai-quota.ts +46 -0
- package/bundle/web-dist/client/assets/index-5WdFEqZT.css +1 -0
- package/bundle/web-dist/client/assets/{index-DeThumA3.js → index-jnUY7nJ-.js} +3 -3
- package/bundle/web-dist/client/assets/logs-B0pWpDrg.js +1 -0
- package/bundle/web-dist/client/assets/routes-7XhVuG7o.js +5 -0
- package/bundle/web-dist/client/assets/{sd.generated-7jzAQL2_.js → sd.generated-DXhjjL2f.js} +1 -1
- package/bundle/web-dist/client/assets/{services.generated-Bx1Eh7ot.js → services.generated-B7u0U1wC.js} +1 -1
- package/bundle/web-dist/client/assets/{show-C6OofoAL.js → show-CcjZ9nr7.js} +3 -3
- package/bundle/web-dist/client/assets/tokens-DlwAm0v4.js +1 -0
- package/bundle/web-dist/client/index.html +3 -3
- package/bundle/web-dist/server/assets/{lazyRouteComponent-BEaI4mOq.js → lazyRouteComponent-B0LkbiYf.js} +1 -2
- package/bundle/web-dist/server/assets/{logs-CHM0fhfO.js → logs-dLx6gMXT.js} +17 -5
- package/bundle/web-dist/server/assets/{routes-C9xFMJ_r.js → routes-DD3Hb9xO.js} +201 -13
- package/bundle/web-dist/server/assets/{sd.generated-BeUHLWKH.js → sd.generated-DdfpD_iN.js} +131 -1
- package/bundle/web-dist/server/assets/{services.generated-C39hdSv0.js → services.generated-D3ZCvIkt.js} +5 -4
- package/bundle/web-dist/server/assets/{show-BZWqH09U.js → show-BQjeyv6A.js} +76 -25
- package/bundle/web-dist/server/assets/{tokens-IPwv3pNB.js → tokens-CSOsBpP7.js} +4 -3
- package/bundle/web-dist/server/entry-server.generated.js +6 -7
- package/dist/cli.js +67 -15
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/index-B5DSo5uH.css +0 -1
- package/bundle/web-dist/client/assets/logs-CtmOVH58.js +0 -1
- package/bundle/web-dist/client/assets/routes-Cfoe9J0I.js +0 -5
- package/bundle/web-dist/client/assets/tokens-BAZq8sTo.js +0 -1
- package/bundle/web-dist/server/assets/shim-B9ZrFt3Z.js +0 -129
- /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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
391
|
-
const
|
|
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
|
|
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(
|
|
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")
|
|
525
|
-
|
|
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({
|
|
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
|
}
|