@cartanova/qgrid-cli 2.1.1 → 2.2.1

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 (38) hide show
  1. package/bundle/dist/application/qgrid/conv-routing.js +68 -0
  2. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
  3. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +16 -15
  4. package/bundle/dist/application/qgrid/qgrid.frame.js +11 -4
  5. package/bundle/dist/application/qgrid/qgrid.types.js +12 -3
  6. package/bundle/dist/application/qgrid/tool-emulation.js +11 -6
  7. package/bundle/dist/utils/providers/common/model-cost.js +32 -17
  8. package/bundle/dist/utils/providers/openai/codex-worker.js +65 -21
  9. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +65 -21
  10. package/bundle/src/application/qgrid/conv-routing.ts +97 -0
  11. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +2 -1
  12. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
  13. package/bundle/src/application/qgrid/qgrid.frame.ts +11 -3
  14. package/bundle/src/application/qgrid/qgrid.types.ts +14 -1
  15. package/bundle/src/application/qgrid/tool-emulation.ts +20 -5
  16. package/bundle/src/application/sonamu.generated.http +14 -2
  17. package/bundle/src/utils/providers/common/model-cost.ts +55 -9
  18. package/bundle/src/utils/providers/common/provider-dispatcher.ts +29 -2
  19. package/bundle/src/utils/providers/openai/codex-worker.test.ts +91 -0
  20. package/bundle/src/utils/providers/openai/codex-worker.ts +105 -19
  21. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +104 -27
  22. package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +1 -0
  23. package/bundle/web-dist/client/assets/{index-ZV-tHpAg.js → index-DeThumA3.js} +2 -2
  24. package/bundle/web-dist/client/assets/logs-CtmOVH58.js +1 -0
  25. package/bundle/web-dist/client/assets/{routes-BMRwPj6F.js → routes-Cfoe9J0I.js} +1 -1
  26. package/bundle/web-dist/client/assets/show-C6OofoAL.js +30 -0
  27. package/bundle/web-dist/client/assets/{tokens-Cg1f0Ga2.js → tokens-BAZq8sTo.js} +1 -1
  28. package/bundle/web-dist/client/index.html +1 -1
  29. package/bundle/web-dist/server/assets/cost-DzjfVtUr.js +24 -0
  30. package/bundle/web-dist/server/assets/{logs-Xt934T5f.js → logs-CHM0fhfO.js} +2 -7
  31. package/bundle/web-dist/server/assets/{routes-B7QYbNIv.js → routes-C9xFMJ_r.js} +1 -1
  32. package/bundle/web-dist/server/assets/{show-Dxz6UJnD.js → show-BZWqH09U.js} +2 -4
  33. package/bundle/web-dist/server/entry-server.generated.js +3 -3
  34. package/package.json +1 -1
  35. package/bundle/web-dist/client/assets/cost-DE_8cLD-.js +0 -1
  36. package/bundle/web-dist/client/assets/logs-HJYkbqom.js +0 -1
  37. package/bundle/web-dist/client/assets/show-DI6iWDzV.js +0 -30
  38. package/bundle/web-dist/server/assets/cost-DdHTzQDR.js +0 -14
@@ -14,16 +14,53 @@ export interface ModelCosts {
14
14
  inputTokens: number;
15
15
  outputTokens: number;
16
16
  cachedInputTokens: number;
17
+ /**
18
+ * long-context 할증. 전체 입력 토큰(input_tokens, cache_read 포함)이 threshold 초과 시
19
+ * 초과분만이 아니라 요청 전체(full session)에 배율 적용.
20
+ */
21
+ longContext?: {
22
+ threshold: number;
23
+ inputMultiplier: number;
24
+ cachedInputMultiplier: number;
25
+ outputMultiplier: number;
26
+ };
17
27
  }
18
28
 
19
29
  // ── OpenAI — codex app-server 에서 사용 가능한 모델 ─────────────────
30
+ //
31
+ // 가격 출처: https://openai.com/api/pricing (2026-06-11 확인)
32
+ // 신모델 출시마다 단가가 바뀌므로(5.2→5.4→5.5) 모델 추가 시 반드시 공식 페이지 재확인해야함
33
+
34
+ // GPT-5.4에서 처음 도입된 long-context 할증 (5.2/5.3-codex는 해당 없음, 5.4-mini는 미확인)
35
+ // 272K 초과 시 input 2x / cached 2x / output 1.5x — 초과분만이 아닌 세션 전체에 적용됨
36
+ // @see https://developers.openai.com/api/docs/models/gpt-5.5 ("prompts with >272K input tokens")
37
+ const LONG_CONTEXT_272K: NonNullable<ModelCosts["longContext"]> = {
38
+ threshold: 272_000,
39
+ inputMultiplier: 2,
40
+ cachedInputMultiplier: 2,
41
+ outputMultiplier: 1.5,
42
+ };
20
43
 
21
44
  const OPENAI_COSTS: Record<string, ModelCosts> = {
22
- "gpt-5.5": { inputTokens: 2, outputTokens: 8, cachedInputTokens: 0.5 },
23
- "gpt-5.4": { inputTokens: 2, outputTokens: 8, cachedInputTokens: 0.5 },
24
- "gpt-5.4-mini": { inputTokens: 0.4, outputTokens: 1.6, cachedInputTokens: 0.1 },
25
- "gpt-5.3-codex": { inputTokens: 2, outputTokens: 8, cachedInputTokens: 0.5 },
26
- "gpt-5.2": { inputTokens: 2, outputTokens: 8, cachedInputTokens: 0.5 },
45
+ // https://openai.com/index/introducing-gpt-5-5/ (2026-04 출시)
46
+ "gpt-5.5": {
47
+ inputTokens: 5,
48
+ outputTokens: 30,
49
+ cachedInputTokens: 0.5,
50
+ longContext: LONG_CONTEXT_272K,
51
+ },
52
+ // https://openai.com/index/introducing-gpt-5-4/ (2026-03 출시)
53
+ "gpt-5.4": {
54
+ inputTokens: 2.5,
55
+ outputTokens: 15,
56
+ cachedInputTokens: 0.25,
57
+ longContext: LONG_CONTEXT_272K,
58
+ },
59
+ "gpt-5.4-mini": { inputTokens: 0.75, outputTokens: 4.5, cachedInputTokens: 0.075 },
60
+ // gpt-5.2와 동일 단가 (cached = input의 10%)
61
+ "gpt-5.3-codex": { inputTokens: 1.75, outputTokens: 14, cachedInputTokens: 0.175 },
62
+ // https://openai.com/index/introducing-gpt-5-2/ (cached input 90% 할인 명시)
63
+ "gpt-5.2": { inputTokens: 1.75, outputTokens: 14, cachedInputTokens: 0.175 },
27
64
  };
28
65
 
29
66
  // ── Anthropic ───────────────────────────────────────────────────────
@@ -57,10 +94,19 @@ export function calculateCostUsd(
57
94
  usage: { inputTokens: number; outputTokens: number; cachedInputTokens?: number },
58
95
  ): number {
59
96
  const costs = getModelCosts(model);
60
- const nonCachedInput = usage.inputTokens - (usage.cachedInputTokens ?? 0);
97
+ const cachedInput = usage.cachedInputTokens ?? 0;
98
+ const nonCachedInput = usage.inputTokens - cachedInput;
99
+
100
+ // long-context 할증: 전체 입력(input_tokens, cache 포함)이 threshold 초과 시 요청 전체에 배율 적용
101
+ const lc = costs.longContext;
102
+ const isLongContext = lc !== undefined && usage.inputTokens > lc.threshold;
103
+ const inputRate = costs.inputTokens * (isLongContext ? lc.inputMultiplier : 1);
104
+ const cachedRate = costs.cachedInputTokens * (isLongContext ? lc.cachedInputMultiplier : 1);
105
+ const outputRate = costs.outputTokens * (isLongContext ? lc.outputMultiplier : 1);
106
+
61
107
  return (
62
- (nonCachedInput / 1_000_000) * costs.inputTokens +
63
- (usage.outputTokens / 1_000_000) * costs.outputTokens +
64
- ((usage.cachedInputTokens ?? 0) / 1_000_000) * costs.cachedInputTokens
108
+ (nonCachedInput / 1_000_000) * inputRate +
109
+ (usage.outputTokens / 1_000_000) * outputRate +
110
+ (cachedInput / 1_000_000) * cachedRate
65
111
  );
66
112
  }
@@ -8,17 +8,32 @@ import { type JsonValue } from "../../../codex-protocol/serde_json/JsonValue";
8
8
  import { type TokenUsageBreakdown } from "../../../codex-protocol/v2/TokenUsageBreakdown";
9
9
  import { type UserInput } from "../../../codex-protocol/v2/UserInput";
10
10
 
11
+ // thread 재사용 라우팅 좌표. 상위(qgrid.dispatcher)에서 conv 핸들 검증을 통과한 경우에만 전달.
12
+ // dispatcher 는 이 좌표가 가리키는 worker 의 기존 thread 에 turn 만 실행한다.
13
+ export interface ReuseThreadCoord {
14
+ workerId: number;
15
+ threadId: string;
16
+ epoch: number;
17
+ }
18
+
11
19
  export interface GenerateRequest {
12
20
  model: string;
13
- input: Array<UserInput>;
14
21
  systemPrompt?: string;
15
22
  outputSchema?: JsonValue;
16
23
  effort?: string;
17
24
  verbosity?: string;
18
25
  reasoningSummary?: string;
19
26
  serviceTier?: string;
20
- history?: Array<JsonValue>;
21
27
  abortSignal?: AbortSignal;
28
+ // 첫 turn / 재사용 폴백 시 보낼 input — 전체 prompt. 항상 설정.
29
+ coldInput: Array<UserInput>;
30
+ // 첫 turn / 폴백 시 inject 할 전체 history.
31
+ coldHistory?: Array<JsonValue>;
32
+ // 재사용 좌표 + delta input. 둘은 한 쌍 — 검증 통과 시에만 설정한다.
33
+ // dispatcher 가 worker/thread 생존을 재검증해 성공하면 reuseInput(delta)을, 실패하면
34
+ // coldInput + coldHistory 로 폴백한다(전체 history 로 문맥 복구).
35
+ reuse?: ReuseThreadCoord;
36
+ reuseInput?: Array<UserInput>;
22
37
  }
23
38
 
24
39
  export interface GenerateResult {
@@ -27,6 +42,18 @@ export interface GenerateResult {
27
42
  usage: TokenUsageBreakdown;
28
43
  durationMs: number;
29
44
  model: string;
45
+ // 이번 turn 이 사용한 thread 좌표. 상위가 conv 핸들을 발급/갱신하는 데 쓴다.
46
+ threadCoord: ReuseThreadCoord;
47
+ }
48
+
49
+ // 상위(qgrid.dispatcher)로 가는 스트림 콜백. onComplete 는 non-stream 의 GenerateResult 와
50
+ // 동일 shape(tokenName/threadCoord 포함)를 받아, 두 경로가 같은 일급 타입을 공유한다.
51
+ export interface GenerateStreamCallbacks {
52
+ onDelta: (text: string) => void;
53
+ onComplete: (result: GenerateResult) => void;
54
+ onError: (error: Error) => void;
55
+ onThreadId?: (threadId: string) => void;
56
+ onTurnId?: (turnId: string) => void;
30
57
  }
31
58
 
32
59
  export interface ProviderDispatcher {
@@ -23,6 +23,36 @@ function createWorkerWithFakeRpc() {
23
23
  return { worker, handlers };
24
24
  }
25
25
 
26
+ function createWorkerWithRequestSpy() {
27
+ const worker = new CodexAppServerWorker({
28
+ tokenId: 1,
29
+ tokenName: "token",
30
+ accessToken: "access",
31
+ accountId: "account",
32
+ });
33
+ const request = vi.fn(async (method: string, _params?: unknown) => {
34
+ if (method === "thread/start") {
35
+ return { thread: { id: "thread-1" }, model: "gpt-test" };
36
+ }
37
+ if (method === "turn/start") {
38
+ return { turn: { id: "turn-1" } };
39
+ }
40
+ throw new Error(`unexpected request: ${method}`);
41
+ });
42
+ (
43
+ worker as unknown as {
44
+ rpc: { request: typeof request };
45
+ ready: boolean;
46
+ }
47
+ ).rpc = { request };
48
+ (
49
+ worker as unknown as {
50
+ ready: boolean;
51
+ }
52
+ ).ready = true;
53
+ return { worker, request };
54
+ }
55
+
26
56
  describe("CodexAppServerWorker active turn cleanup", () => {
27
57
  it("rejects a non-streaming turn when the worker process exits", async () => {
28
58
  const { worker } = createWorkerWithFakeRpc();
@@ -70,3 +100,64 @@ describe("CodexAppServerWorker active turn cleanup", () => {
70
100
  );
71
101
  });
72
102
  });
103
+
104
+ describe("CodexAppServerWorker prompt prefix", () => {
105
+ it("uses the same base instructions with and without an output schema", async () => {
106
+ type WorkerInternals = {
107
+ createThread(req: unknown): Promise<{ threadId: string; model: string }>;
108
+ startTurnOnThread(threadId: string, req: unknown): Promise<{ turnId: string }>;
109
+ };
110
+
111
+ const textWorker = createWorkerWithRequestSpy();
112
+ {
113
+ const w = textWorker.worker as unknown as WorkerInternals;
114
+ const req = {
115
+ input: [{ type: "text", text: "hello", text_elements: [] }],
116
+ developerInstructions: "fixed system prompt",
117
+ };
118
+ const { threadId } = await w.createThread(req);
119
+ await w.startTurnOnThread(threadId, req);
120
+ }
121
+
122
+ const schemaWorker = createWorkerWithRequestSpy();
123
+ const outputSchema = {
124
+ type: "object",
125
+ additionalProperties: false,
126
+ properties: { answer: { type: "string" } },
127
+ required: ["answer"],
128
+ };
129
+ {
130
+ const w = schemaWorker.worker as unknown as WorkerInternals;
131
+ const req = {
132
+ input: [{ type: "text", text: "hello", text_elements: [] }],
133
+ developerInstructions: "fixed system prompt",
134
+ outputSchema,
135
+ };
136
+ const { threadId } = await w.createThread(req);
137
+ await w.startTurnOnThread(threadId, req);
138
+ }
139
+
140
+ const textThreadStart = textWorker.request.mock.calls.find(
141
+ ([method]) => method === "thread/start",
142
+ )?.[1] as { baseInstructions?: string; developerInstructions?: string } | undefined;
143
+ const schemaThreadStart = schemaWorker.request.mock.calls.find(
144
+ ([method]) => method === "thread/start",
145
+ )?.[1] as { baseInstructions?: string; developerInstructions?: string } | undefined;
146
+ expect(textThreadStart).toEqual(
147
+ expect.objectContaining({
148
+ baseInstructions: schemaThreadStart?.baseInstructions,
149
+ developerInstructions: "fixed system prompt",
150
+ }),
151
+ );
152
+ expect(schemaThreadStart).toEqual(
153
+ expect.objectContaining({
154
+ developerInstructions: "fixed system prompt",
155
+ }),
156
+ );
157
+
158
+ const schemaTurnStart = schemaWorker.request.mock.calls.find(
159
+ ([method]) => method === "turn/start",
160
+ )?.[1];
161
+ expect(schemaTurnStart).toEqual(expect.objectContaining({ outputSchema }));
162
+ });
163
+ });
@@ -66,10 +66,8 @@ const THREAD_DEFAULTS = {
66
66
  },
67
67
  } satisfies Partial<ThreadStartParams>;
68
68
 
69
- const BASE_INSTRUCTIONS = {
70
- text: "You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.",
71
- withSchema: "You are a helpful assistant. Respond using the provided output schema.",
72
- } as const;
69
+ const BASE_INSTRUCTIONS =
70
+ "You are a helpful assistant. Do not use any tools such as shell, file operations, or web search. Respond with text only.";
73
71
 
74
72
  // codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
75
73
  // instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
@@ -112,6 +110,14 @@ export class CodexAppServerWorker {
112
110
  // restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
113
111
  serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
114
112
 
113
+ // thread 재사용(prompt cache): worker 가 생성한 thread 들의 메타.
114
+ // ephemeral thread 는 이 프로세스 메모리에만 존재하므로 restart 시 전부 무효.
115
+ private threadMeta = new Map<string, { lastUsedAt: number }>();
116
+ // spawn 카운터. restart 마다 증가 → conv 핸들의 epoch 와 대조해 stale thread 감지.
117
+ private epochCounter = 0;
118
+ private static readonly THREAD_IDLE_TTL_MS = 10 * 60_000;
119
+ private static readonly MAX_THREADS_PER_WORKER = 16;
120
+
115
121
  constructor(private config: WorkerConfig) {
116
122
  const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
117
123
  this.codexHome = `/tmp/qgrid-codex/${config.tokenId}${suffix}`;
@@ -120,6 +126,9 @@ export class CodexAppServerWorker {
120
126
  // ── Lifecycle ───────────────────────────────────────────────────
121
127
 
122
128
  private async spawnAndInit(): Promise<void> {
129
+ // 새 codex 프로세스 → 기존 ephemeral thread 전부 무효. epoch 증가로 stale 감지.
130
+ this.epochCounter++;
131
+ this.threadMeta.clear();
123
132
  const cwd = `${this.codexHome}/cwd`;
124
133
  mkdirSync(cwd, { recursive: true });
125
134
  // codex 내장 tool/web_search/instruction 블록 비활성화
@@ -254,6 +263,9 @@ export class CodexAppServerWorker {
254
263
  get tokenId(): number {
255
264
  return this.config.tokenId;
256
265
  }
266
+ get workerIndex(): number {
267
+ return this.config.workerIndex ?? 0;
268
+ }
257
269
  get tokenName(): string {
258
270
  return this.config.tokenName;
259
271
  }
@@ -313,10 +325,12 @@ export class CodexAppServerWorker {
313
325
  return !this.busy;
314
326
  }
315
327
 
316
- async startThread(
317
- req: TurnRequest,
318
- ): Promise<{ threadId: string; turnId: string; model: string }> {
328
+ // 새 thread 생성 (thread/start + 필요 시 history inject). 첫 turn / 폴백 경로용.
329
+ // 후속 turn 은 createThread 없이 startTurnOnThread 만 호출해 conversation_id 를 고정한다.
330
+ // thread 기본 model 반환 (req.model 미지정 fallback).
331
+ async createThread(req: TurnRequest): Promise<{ threadId: string; model: string }> {
319
332
  if (!this.rpc || !this.ready) throw new Error("worker not ready");
333
+ this.sweepIdleThreads();
320
334
 
321
335
  const threadConfig: ThreadStartParams["config"] = {
322
336
  ...THREAD_DEFAULTS.config,
@@ -327,7 +341,7 @@ export class CodexAppServerWorker {
327
341
  {
328
342
  ephemeral: true,
329
343
  cwd: `${this.codexHome}/cwd`,
330
- baseInstructions: req.outputSchema ? BASE_INSTRUCTIONS.withSchema : BASE_INSTRUCTIONS.text,
344
+ baseInstructions: BASE_INSTRUCTIONS,
331
345
  developerInstructions: req.developerInstructions ?? "",
332
346
  sandbox: THREAD_DEFAULTS.sandbox,
333
347
  approvalPolicy: THREAD_DEFAULTS.approvalPolicy,
@@ -336,13 +350,20 @@ export class CodexAppServerWorker {
336
350
  );
337
351
 
338
352
  const threadId = thread.id;
339
- const model = req.model ?? threadModel;
340
353
  if (req.history?.length) {
341
354
  await this.rpc.request("thread/inject_items", {
342
355
  threadId,
343
356
  items: req.history,
344
357
  } satisfies ThreadInjectItemsParams);
345
358
  }
359
+ this.threadMeta.set(threadId, { lastUsedAt: Date.now() });
360
+
361
+ return { threadId, model: req.model ?? threadModel };
362
+ }
363
+
364
+ // 기존 thread 에 turn 만 실행 (inject 없음). conversation_id 유지 → prompt cache 적중.
365
+ private async startTurnOnThread(threadId: string, req: TurnRequest): Promise<{ turnId: string }> {
366
+ if (!this.rpc || !this.ready) throw new Error("worker not ready");
346
367
 
347
368
  const { turn } = await this.rpc.request<TurnStartResponse>("turn/start", {
348
369
  threadId,
@@ -353,20 +374,79 @@ export class CodexAppServerWorker {
353
374
  ...(req.reasoningSummary ? { summary: req.reasoningSummary } : {}),
354
375
  ...(req.serviceTier ? { serviceTier: req.serviceTier } : {}),
355
376
  });
356
-
357
- return { threadId, turnId: turn.id, model };
377
+ const meta = this.threadMeta.get(threadId);
378
+ if (meta) meta.lastUsedAt = Date.now();
379
+ return { turnId: turn.id };
358
380
  }
359
381
 
360
- async executeTurn(req: TurnRequest): Promise<TurnResult> {
361
- const { threadId, model } = await this.startThread(req);
362
- return this.consumeTurnNotifications(threadId, model);
382
+ // existingThreadId 있으면 그 thread 재사용(후속 turn), 없으면 새 thread 생성(첫 turn).
383
+ // 호출 후 사용한 threadId 반환해 dispatcher conv 핸들을 발급한다.
384
+ async executeTurn(
385
+ req: TurnRequest,
386
+ existingThreadId?: string,
387
+ ): Promise<TurnResult & { threadId: string }> {
388
+ let threadId: string;
389
+ let model: string;
390
+ if (existingThreadId) {
391
+ threadId = existingThreadId;
392
+ model = req.model ?? "";
393
+ } else {
394
+ ({ threadId, model } = await this.createThread(req));
395
+ }
396
+ await this.startTurnOnThread(threadId, req);
397
+ const result = await this.consumeTurnNotifications(threadId, model);
398
+ return { ...result, threadId };
363
399
  }
364
400
 
365
- async executeTurnStream(req: TurnRequest, cb: StreamCallbacks): Promise<void> {
366
- const { threadId, turnId, model } = await this.startThread(req);
401
+ async executeTurnStream(
402
+ req: TurnRequest,
403
+ cb: StreamCallbacks,
404
+ existingThreadId?: string,
405
+ ): Promise<{ threadId: string }> {
406
+ let threadId: string;
407
+ let model: string;
408
+ if (existingThreadId) {
409
+ threadId = existingThreadId;
410
+ model = req.model ?? "";
411
+ } else {
412
+ ({ threadId, model } = await this.createThread(req));
413
+ }
414
+ const { turnId } = await this.startTurnOnThread(threadId, req);
367
415
  cb.onThreadId?.(threadId);
368
416
  cb.onTurnId?.(turnId);
369
- return this.consumeStreamNotifications(threadId, model, cb);
417
+ await this.consumeStreamNotifications(threadId, model, cb);
418
+ return { threadId };
419
+ }
420
+
421
+ // ── thread 재사용 지원 ───────────────────────────────────────────
422
+
423
+ get epoch(): number {
424
+ return this.epochCounter;
425
+ }
426
+
427
+ hasThread(threadId: string): boolean {
428
+ return this.threadMeta.has(threadId);
429
+ }
430
+
431
+ // idle TTL 초과 / 개수 상한 초과 thread 를 정리. createThread 직전 lazy 호출.
432
+ // codex ephemeral thread 는 명시적 close RPC 없이 맵에서 제거 → 더 이상 turn 안 보냄.
433
+ sweepIdleThreads(): void {
434
+ const now = Date.now();
435
+ for (const [id, meta] of this.threadMeta) {
436
+ if (now - meta.lastUsedAt > CodexAppServerWorker.THREAD_IDLE_TTL_MS) {
437
+ this.threadMeta.delete(id);
438
+ }
439
+ }
440
+ // createThread 직전 호출 → 새 thread 1 개가 곧 추가된다. 추가 후에도 MAX 를 넘지 않도록
441
+ // MAX-1 이하로 줄여 둔다(그래야 생성 직후 정확히 MAX). LRU: lastUsedAt 오름차순으로 제거.
442
+ const limit = CodexAppServerWorker.MAX_THREADS_PER_WORKER - 1;
443
+ if (this.threadMeta.size > limit) {
444
+ const sorted = [...this.threadMeta.entries()].toSorted(
445
+ (a, b) => a[1].lastUsedAt - b[1].lastUsedAt,
446
+ );
447
+ const over = this.threadMeta.size - limit;
448
+ for (let i = 0; i < over; i++) this.threadMeta.delete(sorted[i]![0]);
449
+ }
370
450
  }
371
451
 
372
452
  async interruptTurn(threadId: string, turnId: string): Promise<void> {
@@ -438,7 +518,10 @@ export class CodexAppServerWorker {
438
518
 
439
519
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
440
520
  if (p.threadId !== threadId) return;
441
- usage = p.tokenUsage.total;
521
+ // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
522
+ // 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
523
+ // .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
524
+ usage = p.tokenUsage.last;
442
525
  });
443
526
 
444
527
  this.rpc!.onNotification("turn/completed", (p) => {
@@ -524,7 +607,10 @@ export class CodexAppServerWorker {
524
607
 
525
608
  this.rpc!.onNotification("thread/tokenUsage/updated", (p) => {
526
609
  if (p.threadId !== threadId) return;
527
- usage = p.tokenUsage.total;
610
+ // thread 재사용 시 .total 은 대화 전체 누적이라, cold 였던 이전 turn 까지 섞여
611
+ // 이 요청 자체의 cache 적중률이 희석된다(예: turn1 cold 0% + turn2 90% → 48% 로 기록).
612
+ // .last 는 이번 turn 만의 usage 라 request_log 가 그 요청의 실제 토큰을 정확히 반영한다.
613
+ usage = p.tokenUsage.last;
528
614
  });
529
615
 
530
616
  this.rpc!.onNotification("turn/completed", (p) => {
@@ -13,14 +13,10 @@ import { type OpenAICredentials } from "../../../application/token/token.types";
13
13
  import {
14
14
  type GenerateRequest,
15
15
  type GenerateResult,
16
+ type GenerateStreamCallbacks,
16
17
  type ProviderDispatcher,
17
18
  } from "../common/provider-dispatcher";
18
- import {
19
- CodexAppServerWorker,
20
- type StreamCallbacks,
21
- type TurnRequest,
22
- type TurnResult,
23
- } from "./codex-worker";
19
+ import { CodexAppServerWorker, type StreamCallbacks, type TurnRequest } from "./codex-worker";
24
20
  import { handleChatgptAuthTokensRefresh } from "./openai-refresh";
25
21
 
26
22
  const logger = getLogger(["qgrid", "openai-dispatcher"]);
@@ -35,6 +31,17 @@ const QUEUE_TIMEOUT_MS = 60_000;
35
31
  const MAX_QUEUE_SIZE = 50;
36
32
  const SPAWN_INTERVAL_MS = 500;
37
33
 
34
+ // thread 재사용(prompt cache 고정). 끄면 기존 "매 turn 새 thread + history inject" 동작.
35
+ const THREAD_REUSE_ENABLED = process.env.QGRID_OPENAI_THREAD_REUSE !== "false";
36
+ // 좌표 worker 가 busy 일 때 free 를 기다리는 최대 시간. 초과 시 새 thread 로 폴백.
37
+ const REUSE_WORKER_WAIT_MS = 5_000;
38
+ const REUSE_WORKER_POLL_MS = 50;
39
+
40
+ // workerId 합성: tokenId 와 workerIndex(0..4) 를 하나의 안정 숫자로 인코딩.
41
+ function makeWorkerId(tokenId: number, workerIndex: number): number {
42
+ return tokenId * 10 + workerIndex;
43
+ }
44
+
38
45
  function sleep(ms: number): Promise<void> {
39
46
  return new Promise((resolve) => setTimeout(resolve, ms));
40
47
  }
@@ -130,6 +137,17 @@ export class OpenAIDispatcher implements ProviderDispatcher {
130
137
  // ── Generate ────────────────────────────────────────────────────
131
138
 
132
139
  async generate(req: GenerateRequest): Promise<GenerateResult> {
140
+ // thread 재사용 경로: 좌표 worker 를 점유해 기존 thread 에 turn 만 실행.
141
+ const reuseWorker = await this.acquireReuseWorker(req);
142
+ if (reuseWorker) {
143
+ logger.info(
144
+ `↻ ${reuseWorker.tokenName}[${reuseWorker.tokenId}] (reuse thread, ${req.model})`,
145
+ );
146
+ return this.executeAndRelease(reuseWorker, (w) =>
147
+ this.executeTurn(w, req, req.reuse!.threadId),
148
+ );
149
+ }
150
+
133
151
  const worker = this.acquireIdleWorker();
134
152
  if (worker) {
135
153
  logger.info(`→ ${worker.tokenName}[${worker.tokenId}] (model: ${req.model})`);
@@ -141,7 +159,15 @@ export class OpenAIDispatcher implements ProviderDispatcher {
141
159
  });
142
160
  }
143
161
 
144
- async generateStream(req: GenerateRequest, cb: StreamCallbacks): Promise<void> {
162
+ async generateStream(req: GenerateRequest, cb: GenerateStreamCallbacks): Promise<void> {
163
+ const reuseWorker = await this.acquireReuseWorker(req);
164
+ if (reuseWorker) {
165
+ logger.info(`↻ ${reuseWorker.tokenName}[${reuseWorker.tokenId}] (reuse thread, [stream])`);
166
+ return this.executeAndRelease(reuseWorker, (w) =>
167
+ this.executeStreamTurn(w, req, cb, req.reuse!.threadId),
168
+ );
169
+ }
170
+
145
171
  const worker = this.acquireIdleWorker();
146
172
  if (worker) {
147
173
  logger.info(`→ ${worker.tokenName}[${worker.tokenId}] (model: ${req.model}, [stream])`);
@@ -153,6 +179,37 @@ export class OpenAIDispatcher implements ProviderDispatcher {
153
179
  });
154
180
  }
155
181
 
182
+ // reuse 좌표가 유효하면 그 worker 를 busy 점유해 반환. 없거나 폴백 대상이면 null.
183
+ // 폴백 사유: 기능 off / 좌표 없음 / worker 부재·죽음 / epoch 불일치(restart) / thread 소멸 /
184
+ // busy 대기 타임아웃. null 반환 시 호출부는 새 thread 경로로 진행.
185
+ async acquireReuseWorker(req: GenerateRequest): Promise<CodexAppServerWorker | null> {
186
+ if (!THREAD_REUSE_ENABLED || !req.reuse) return null;
187
+ const { workerId, threadId, epoch } = req.reuse;
188
+
189
+ const worker = this.findWorkerById(workerId);
190
+ if (!worker || !worker.isReady || !worker.active) return null;
191
+ if (worker.epoch !== epoch) return null;
192
+ if (!worker.hasThread(threadId)) return null;
193
+
194
+ const deadline = Date.now() + REUSE_WORKER_WAIT_MS;
195
+ for (;;) {
196
+ // restart(epoch 변경) / thread 소멸이 대기 중에 발생하면 폴백.
197
+ if (worker.epoch !== epoch || !worker.hasThread(threadId)) return null;
198
+ if (worker.tryAcquireTurn()) return worker;
199
+ if (Date.now() >= deadline) return null;
200
+ await sleep(REUSE_WORKER_POLL_MS);
201
+ }
202
+ }
203
+
204
+ findWorkerById(workerId: number): CodexAppServerWorker | null {
205
+ for (const workers of this.workerPool.values()) {
206
+ for (const w of workers) {
207
+ if (makeWorkerId(w.tokenId, w.workerIndex) === workerId) return w;
208
+ }
209
+ }
210
+ return null;
211
+ }
212
+
156
213
  async interruptWorkerTurn(threadId: string, turnId: string): Promise<void> {
157
214
  const allWorkers = [...this.workerPool.values()].flat().filter((w) => w.isReady);
158
215
  await Promise.allSettled(allWorkers.map((w) => w.interruptTurn(threadId, turnId)));
@@ -264,53 +321,73 @@ export class OpenAIDispatcher implements ProviderDispatcher {
264
321
  }
265
322
  }
266
323
 
267
- async executeTurn(worker: CodexAppServerWorker, req: GenerateRequest): Promise<GenerateResult> {
268
- const turnReq: TurnRequest = {
269
- input: req.input,
324
+ // reuseThreadId 있으면 기존 thread 에 delta(reuseInput)만, 없으면 새 thread 에 전체
325
+ // prompt(coldInput) + history inject. reuse 가 dispatcher 단에서 폴백되면 후자로 떨어진다.
326
+ private buildTurnRequest(req: GenerateRequest, reuseThreadId?: string): TurnRequest {
327
+ const reusing = reuseThreadId !== undefined;
328
+ return {
329
+ input: reusing && req.reuseInput ? req.reuseInput : req.coldInput,
330
+ history: reusing ? undefined : req.coldHistory,
270
331
  developerInstructions: req.systemPrompt,
271
332
  outputSchema: req.outputSchema,
272
333
  effort: req.effort ?? DEFAULT_EFFORT,
273
334
  model: req.model,
274
- history: req.history,
275
335
  verbosity: req.verbosity,
276
336
  reasoningSummary: req.reasoningSummary,
277
337
  serviceTier: req.serviceTier,
278
338
  };
279
- const result: TurnResult = await worker.executeTurn(turnReq);
339
+ }
340
+
341
+ async executeTurn(
342
+ worker: CodexAppServerWorker,
343
+ req: GenerateRequest,
344
+ reuseThreadId?: string,
345
+ ): Promise<GenerateResult> {
346
+ const turnReq = this.buildTurnRequest(req, reuseThreadId);
347
+ const result = await worker.executeTurn(turnReq, reuseThreadId);
280
348
  return {
281
349
  text: result.text,
282
350
  tokenName: worker.tokenName,
283
351
  usage: result.usage,
284
352
  durationMs: result.durationMs,
285
353
  model: result.model,
354
+ threadCoord: {
355
+ workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
356
+ threadId: result.threadId,
357
+ epoch: worker.epoch,
358
+ },
286
359
  };
287
360
  }
288
361
 
289
362
  async executeStreamTurn(
290
363
  worker: CodexAppServerWorker,
291
364
  req: GenerateRequest,
292
- cb: StreamCallbacks,
365
+ cb: GenerateStreamCallbacks,
366
+ reuseThreadId?: string,
293
367
  ): Promise<void> {
294
- const turnReq: TurnRequest = {
295
- input: req.input,
296
- developerInstructions: req.systemPrompt,
297
- outputSchema: req.outputSchema,
298
- effort: req.effort ?? DEFAULT_EFFORT,
299
- model: req.model,
300
- history: req.history,
301
- verbosity: req.verbosity,
302
- reasoningSummary: req.reasoningSummary,
303
- serviceTier: req.serviceTier,
304
- };
368
+ const turnReq = this.buildTurnRequest(req, reuseThreadId);
369
+ // worker 의 TurnResult 에 tokenName/threadCoord 를 붙여 상위(GenerateResult)로 올린다.
370
+ // threadId 는 새 thread 면 onThreadId 로 확정되므로 미리 보관해 두고 onComplete 때 읽는다.
371
+ let resolvedThreadId = reuseThreadId ?? "";
305
372
  const wrappedCb: StreamCallbacks = {
306
373
  ...cb,
374
+ onThreadId: (threadId) => {
375
+ resolvedThreadId = threadId;
376
+ cb.onThreadId?.(threadId);
377
+ },
307
378
  onComplete: (result) => {
308
- cb.onComplete({ ...result, tokenName: worker.tokenName } as TurnResult & {
309
- tokenName: string;
379
+ cb.onComplete({
380
+ ...result,
381
+ tokenName: worker.tokenName,
382
+ threadCoord: {
383
+ workerId: makeWorkerId(worker.tokenId, worker.workerIndex),
384
+ threadId: resolvedThreadId,
385
+ epoch: worker.epoch,
386
+ },
310
387
  });
311
388
  },
312
389
  };
313
- await worker.executeTurnStream(turnReq, wrappedCb);
390
+ await worker.executeTurnStream(turnReq, wrappedCb, reuseThreadId);
314
391
  }
315
392
 
316
393
  // ── Spawn ───────────────────────────────────────────────────────
@@ -0,0 +1 @@
1
+ var e=1e6,t=4;function n(t){return t/e}function r(e){return`$${e.toFixed(t)}`}function i(e){return r(n(e))}function a(e){return e.input_tokens<=0?`—`:`${Math.round(e.cache_read_tokens/e.input_tokens*100)}%`}export{i as n,r,a as t};