@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.
- package/bundle/dist/application/qgrid/conv-routing.js +68 -0
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +2 -2
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +16 -15
- package/bundle/dist/application/qgrid/qgrid.frame.js +11 -4
- package/bundle/dist/application/qgrid/qgrid.types.js +12 -3
- package/bundle/dist/application/qgrid/tool-emulation.js +11 -6
- package/bundle/dist/utils/providers/common/model-cost.js +32 -17
- package/bundle/dist/utils/providers/openai/codex-worker.js +65 -21
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +65 -21
- package/bundle/src/application/qgrid/conv-routing.ts +97 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +2 -1
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +16 -5
- package/bundle/src/application/qgrid/qgrid.frame.ts +11 -3
- package/bundle/src/application/qgrid/qgrid.types.ts +14 -1
- package/bundle/src/application/qgrid/tool-emulation.ts +20 -5
- package/bundle/src/application/sonamu.generated.http +14 -2
- package/bundle/src/utils/providers/common/model-cost.ts +55 -9
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +29 -2
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +91 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +105 -19
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +104 -27
- package/bundle/web-dist/client/assets/cost-CpjVoAqp.js +1 -0
- package/bundle/web-dist/client/assets/{index-ZV-tHpAg.js → index-DeThumA3.js} +2 -2
- package/bundle/web-dist/client/assets/logs-CtmOVH58.js +1 -0
- package/bundle/web-dist/client/assets/{routes-BMRwPj6F.js → routes-Cfoe9J0I.js} +1 -1
- package/bundle/web-dist/client/assets/show-C6OofoAL.js +30 -0
- package/bundle/web-dist/client/assets/{tokens-Cg1f0Ga2.js → tokens-BAZq8sTo.js} +1 -1
- package/bundle/web-dist/client/index.html +1 -1
- package/bundle/web-dist/server/assets/cost-DzjfVtUr.js +24 -0
- package/bundle/web-dist/server/assets/{logs-Xt934T5f.js → logs-CHM0fhfO.js} +2 -7
- package/bundle/web-dist/server/assets/{routes-B7QYbNIv.js → routes-C9xFMJ_r.js} +1 -1
- package/bundle/web-dist/server/assets/{show-Dxz6UJnD.js → show-BZWqH09U.js} +2 -4
- package/bundle/web-dist/server/entry-server.generated.js +3 -3
- package/package.json +1 -1
- package/bundle/web-dist/client/assets/cost-DE_8cLD-.js +0 -1
- package/bundle/web-dist/client/assets/logs-HJYkbqom.js +0 -1
- package/bundle/web-dist/client/assets/show-DI6iWDzV.js +0 -30
- 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
|
-
|
|
23
|
-
"gpt-5.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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) *
|
|
63
|
-
(usage.outputTokens / 1_000_000) *
|
|
64
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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:
|
|
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
|
-
|
|
377
|
+
const meta = this.threadMeta.get(threadId);
|
|
378
|
+
if (meta) meta.lastUsedAt = Date.now();
|
|
379
|
+
return { turnId: turn.id };
|
|
358
380
|
}
|
|
359
381
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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:
|
|
365
|
+
cb: GenerateStreamCallbacks,
|
|
366
|
+
reuseThreadId?: string,
|
|
293
367
|
): Promise<void> {
|
|
294
|
-
const turnReq
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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({
|
|
309
|
-
|
|
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};
|