@cartanova/qgrid-cli 2.0.3 → 2.1.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/qgrid.frame.js +39 -25
- package/bundle/dist/application/request-log/request-log.model.js +1 -1
- package/bundle/dist/utils/providers/common/model-cost.js +6 -1
- package/bundle/dist/utils/providers/openai/codex-worker.js +100 -35
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +11 -1
- package/bundle/src/application/qgrid/qgrid.frame.ts +51 -28
- package/bundle/src/application/request-log/request-log.model.ts +1 -3
- package/bundle/src/utils/providers/common/model-cost.ts +1 -0
- package/bundle/src/utils/providers/openai/codex-worker.test.ts +72 -0
- package/bundle/src/utils/providers/openai/codex-worker.ts +129 -22
- package/bundle/src/utils/providers/openai/openai-dispatcher.test.ts +59 -0
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +12 -0
- package/package.json +1 -1
|
@@ -72,6 +72,14 @@ async function deleteOAuthState(state: string): Promise<void> {
|
|
|
72
72
|
await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
function getOAuthRedirectUri(): string {
|
|
76
|
+
const publicBaseUrl = process.env.QGRID_PUBLIC_BASE_URL?.replace(/\/+$/, "");
|
|
77
|
+
if (publicBaseUrl) return `${publicBaseUrl}/callback`;
|
|
78
|
+
|
|
79
|
+
const serverPort = process.env.PORT ?? "44900";
|
|
80
|
+
return `http://localhost:${serverPort}/callback`;
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
const logger = getLogger(["qgrid"]);
|
|
76
84
|
const oauthLogger = getLogger(["qgrid", "oauth"]);
|
|
77
85
|
|
|
@@ -101,37 +109,50 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
101
109
|
const result = await QgridDispatcher.query(args, args.timeout);
|
|
102
110
|
|
|
103
111
|
if (effectiveLogMode === "auto") {
|
|
104
|
-
|
|
105
|
-
const responseText = result.content
|
|
106
|
-
.filter((item) => item.type === "text")
|
|
107
|
-
.map((item) => item.text)
|
|
108
|
-
.join("\n");
|
|
109
|
-
|
|
110
|
-
RequestLogModel.save([
|
|
111
|
-
{
|
|
112
|
-
token_name: result.tokenName,
|
|
113
|
-
project_name: args.projectName?.length ? args.projectName : null,
|
|
114
|
-
model_name: result.model ?? null,
|
|
115
|
-
user_prompt: args.prompt,
|
|
116
|
-
system_prompt: args.system ?? null,
|
|
117
|
-
response: responseText,
|
|
118
|
-
input_tokens: result.usage.input_tokens,
|
|
119
|
-
output_tokens: result.usage.output_tokens,
|
|
120
|
-
cache_read_tokens: result.usage.cache_read_input_tokens,
|
|
121
|
-
cache_creation_tokens: result.usage.cache_creation_input_tokens,
|
|
122
|
-
duration_ms: result.durationMs,
|
|
123
|
-
cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
|
|
124
|
-
effort: args.effort ?? null,
|
|
125
|
-
history: args.history ? JSON.parse(args.history) : null,
|
|
126
|
-
status: "succeeded",
|
|
127
|
-
tool_call_count: toolCallCount,
|
|
128
|
-
},
|
|
129
|
-
]).catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
|
|
112
|
+
this.saveAutoRequestLog(args, result);
|
|
130
113
|
}
|
|
131
114
|
|
|
132
115
|
return result;
|
|
133
116
|
}
|
|
134
117
|
|
|
118
|
+
// logMode "auto" 단일 요청 로깅: run lifecycle(step 분해) 없이 request_log 1건만 남긴다.
|
|
119
|
+
// query와 queryStream 양쪽에서 사용.
|
|
120
|
+
private saveAutoRequestLog(args: QueryInput, result: QueryOutput): void {
|
|
121
|
+
const toolCallCount = result.content.filter((item) => item.type === "tool-call").length;
|
|
122
|
+
const responseText = result.content
|
|
123
|
+
.filter((item) => item.type === "text")
|
|
124
|
+
.map((item) => item.text)
|
|
125
|
+
.join("\n");
|
|
126
|
+
|
|
127
|
+
RequestLogModel.save([
|
|
128
|
+
{
|
|
129
|
+
token_name: result.tokenName,
|
|
130
|
+
project_name: args.projectName?.length ? args.projectName : null,
|
|
131
|
+
model_name: result.model ?? null,
|
|
132
|
+
user_prompt: args.prompt,
|
|
133
|
+
system_prompt: args.system ?? null,
|
|
134
|
+
response: responseText,
|
|
135
|
+
input_tokens: result.usage.input_tokens,
|
|
136
|
+
output_tokens: result.usage.output_tokens,
|
|
137
|
+
cache_read_tokens: result.usage.cache_read_input_tokens,
|
|
138
|
+
cache_creation_tokens: result.usage.cache_creation_input_tokens,
|
|
139
|
+
duration_ms: result.durationMs,
|
|
140
|
+
cost_usd: result.costUsd !== null ? Math.round(result.costUsd * MICRO_USD) : null,
|
|
141
|
+
effort: args.effort ?? null,
|
|
142
|
+
// malformed history가 와도 성공한 턴(특히 stream sse.end())을 깨지 않도록 방어.
|
|
143
|
+
history: ((): { type: string }[] | null => {
|
|
144
|
+
try {
|
|
145
|
+
return args.history ? JSON.parse(args.history) : null;
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
})(),
|
|
150
|
+
status: "succeeded",
|
|
151
|
+
tool_call_count: toolCallCount,
|
|
152
|
+
},
|
|
153
|
+
]).catch((e) => logger.error(`requestLog save failed: ${(e as Error).message}`));
|
|
154
|
+
}
|
|
155
|
+
|
|
135
156
|
@api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
|
|
136
157
|
async prepareStream(args: QueryInput): Promise<{ streamId: string }> {
|
|
137
158
|
const streamId = crypto.randomUUID();
|
|
@@ -214,6 +235,9 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
214
235
|
} catch (e) {
|
|
215
236
|
logger.error(`stream afterQuery failed: ${(e as Error).message}`);
|
|
216
237
|
}
|
|
238
|
+
} else if (effectiveLogMode === "auto") {
|
|
239
|
+
// run lifecycle을 안 타는 단일 stream 요청도 request_log 1건은 남긴다 (query와 대칭).
|
|
240
|
+
this.saveAutoRequestLog(args, streamResult);
|
|
217
241
|
}
|
|
218
242
|
if (!sse.closed) sse.publish("done", { ...streamResult, runContext });
|
|
219
243
|
} else if (streamError) {
|
|
@@ -360,8 +384,7 @@ class QgridFrameClass extends BaseFrameClass {
|
|
|
360
384
|
async oauthStart(name: string): Promise<OAuthStartResult> {
|
|
361
385
|
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
|
362
386
|
|
|
363
|
-
const
|
|
364
|
-
const redirectUri = `http://localhost:${serverPort}/callback`;
|
|
387
|
+
const redirectUri = getOAuthRedirectUri();
|
|
365
388
|
const authUrl = buildAuthUrl(codeChallenge, state, redirectUri);
|
|
366
389
|
|
|
367
390
|
await setOAuthState(state, { codeVerifier, name, redirectUri });
|
|
@@ -175,9 +175,7 @@ class RequestLogModelClass extends BaseModelClass<
|
|
|
175
175
|
cache_creation_tokens: 0,
|
|
176
176
|
duration_ms: 0,
|
|
177
177
|
tool_call_count: 0,
|
|
178
|
-
...(params.history !== undefined
|
|
179
|
-
? { history: params.history as { type: string }[] }
|
|
180
|
-
: {}),
|
|
178
|
+
...(params.history !== undefined ? { history: params.history as { type: string }[] } : {}),
|
|
181
179
|
});
|
|
182
180
|
return wdb.transaction(async (trx) => {
|
|
183
181
|
const ids = await trx.ubUpsert("request_logs");
|
|
@@ -43,6 +43,7 @@ const ANTHROPIC_COSTS: Record<string, ModelCosts> = {
|
|
|
43
43
|
"claude-opus-4-5": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
|
|
44
44
|
"claude-opus-4-6": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
|
|
45
45
|
"claude-opus-4-7": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
|
|
46
|
+
"claude-opus-4-8": { inputTokens: 5, outputTokens: 25, cachedInputTokens: 0.5 },
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
const DEFAULT_COSTS: ModelCosts = { inputTokens: 3, outputTokens: 15, cachedInputTokens: 0.3 };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { CodexAppServerWorker, type StreamCallbacks } from "./codex-worker";
|
|
4
|
+
|
|
5
|
+
function createWorkerWithFakeRpc() {
|
|
6
|
+
const worker = new CodexAppServerWorker({
|
|
7
|
+
tokenId: 1,
|
|
8
|
+
tokenName: "token",
|
|
9
|
+
accessToken: "access",
|
|
10
|
+
accountId: "account",
|
|
11
|
+
});
|
|
12
|
+
const handlers = new Map<string, (params: never) => void>();
|
|
13
|
+
const rpc = {
|
|
14
|
+
onNotification(method: string, handler: (params: never) => void) {
|
|
15
|
+
handlers.set(method, handler);
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
(
|
|
19
|
+
worker as unknown as {
|
|
20
|
+
rpc: typeof rpc;
|
|
21
|
+
}
|
|
22
|
+
).rpc = rpc;
|
|
23
|
+
return { worker, handlers };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("CodexAppServerWorker active turn cleanup", () => {
|
|
27
|
+
it("rejects a non-streaming turn when the worker process exits", async () => {
|
|
28
|
+
const { worker } = createWorkerWithFakeRpc();
|
|
29
|
+
const promise = (
|
|
30
|
+
worker as unknown as {
|
|
31
|
+
consumeTurnNotifications(threadId: string, model: string): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
).consumeTurnNotifications("thread-1", "gpt-test");
|
|
34
|
+
|
|
35
|
+
(
|
|
36
|
+
worker as unknown as {
|
|
37
|
+
failActiveTurn(error: Error): void;
|
|
38
|
+
}
|
|
39
|
+
).failActiveTurn(new Error("codex worker exited while turn was running"));
|
|
40
|
+
|
|
41
|
+
await expect(promise).rejects.toThrow("codex worker exited while turn was running");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("notifies stream callbacks when the worker process exits", async () => {
|
|
45
|
+
const { worker } = createWorkerWithFakeRpc();
|
|
46
|
+
const callbacks: StreamCallbacks = {
|
|
47
|
+
onDelta: vi.fn(),
|
|
48
|
+
onComplete: vi.fn(),
|
|
49
|
+
onError: vi.fn(),
|
|
50
|
+
};
|
|
51
|
+
const promise = (
|
|
52
|
+
worker as unknown as {
|
|
53
|
+
consumeStreamNotifications(
|
|
54
|
+
threadId: string,
|
|
55
|
+
model: string,
|
|
56
|
+
cb: StreamCallbacks,
|
|
57
|
+
): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
).consumeStreamNotifications("thread-1", "gpt-test", callbacks);
|
|
60
|
+
|
|
61
|
+
(
|
|
62
|
+
worker as unknown as {
|
|
63
|
+
failActiveTurn(error: Error): void;
|
|
64
|
+
}
|
|
65
|
+
).failActiveTurn(new Error("codex worker exited while turn was running"));
|
|
66
|
+
|
|
67
|
+
await expect(promise).rejects.toThrow("codex worker exited while turn was running");
|
|
68
|
+
expect(callbacks.onError).toHaveBeenCalledWith(
|
|
69
|
+
expect.objectContaining({ message: "codex worker exited while turn was running" }),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
|
|
4
4
|
import { getLogger } from "@logtape/logtape";
|
|
5
5
|
|
|
@@ -54,6 +54,9 @@ export interface StreamCallbacks {
|
|
|
54
54
|
onTurnId?: (turnId: string) => void;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
type RefreshableWorkerCredentials = Pick<WorkerConfig, "accessToken" | "accountId" | "planType">;
|
|
58
|
+
type ActiveTurnAbort = (error: Error) => void;
|
|
59
|
+
|
|
57
60
|
// thread/start 공통 옵션
|
|
58
61
|
const THREAD_DEFAULTS = {
|
|
59
62
|
sandbox: "read-only",
|
|
@@ -68,6 +71,31 @@ const BASE_INSTRUCTIONS = {
|
|
|
68
71
|
withSchema: "You are a helpful assistant. Respond using the provided output schema.",
|
|
69
72
|
} as const;
|
|
70
73
|
|
|
74
|
+
// codex가 매 요청에 자동 주입하는 내장 tool(shell/web_search/spawn_agent 등 14개)과
|
|
75
|
+
// instruction 블록(permissions/environment_context/skills, ~10KB)을 비활성화
|
|
76
|
+
// $CODEX_HOME/config.toml 로 써넣으면 codex 부팅 시 scan
|
|
77
|
+
// 통제불가: update_plan/request_user_input
|
|
78
|
+
const CODEX_CONFIG_TOML = `web_search = "disabled"
|
|
79
|
+
include_permissions_instructions = false
|
|
80
|
+
include_apps_instructions = false
|
|
81
|
+
include_environment_context = false
|
|
82
|
+
|
|
83
|
+
[features]
|
|
84
|
+
shell_tool = false
|
|
85
|
+
tool_search = false
|
|
86
|
+
tool_suggest = false
|
|
87
|
+
multi_agent = false
|
|
88
|
+
image_generation = false
|
|
89
|
+
apps = false
|
|
90
|
+
plugins = false
|
|
91
|
+
|
|
92
|
+
[tools]
|
|
93
|
+
view_image = false
|
|
94
|
+
|
|
95
|
+
[skills]
|
|
96
|
+
include_instructions = false
|
|
97
|
+
`;
|
|
98
|
+
|
|
71
99
|
// ── Worker ──────────────────────────────────────────────────────────
|
|
72
100
|
|
|
73
101
|
export class CodexAppServerWorker {
|
|
@@ -78,8 +106,11 @@ export class CodexAppServerWorker {
|
|
|
78
106
|
private ready = false;
|
|
79
107
|
private destroyed = false;
|
|
80
108
|
private busy = false;
|
|
109
|
+
private activeTurnAbort?: ActiveTurnAbort;
|
|
81
110
|
active = true;
|
|
82
111
|
onReady?: () => void;
|
|
112
|
+
// restart 시 rpc 객체가 새로 생성되므로, 핸들러를 보관했다가 매 spawn마다 재바인딩한다.
|
|
113
|
+
serverRequestHandler?: (method: string, params: unknown) => Promise<unknown>;
|
|
83
114
|
|
|
84
115
|
constructor(private config: WorkerConfig) {
|
|
85
116
|
const suffix = config.workerIndex !== undefined ? `-${config.workerIndex}` : "";
|
|
@@ -91,15 +122,26 @@ export class CodexAppServerWorker {
|
|
|
91
122
|
private async spawnAndInit(): Promise<void> {
|
|
92
123
|
const cwd = `${this.codexHome}/cwd`;
|
|
93
124
|
mkdirSync(cwd, { recursive: true });
|
|
125
|
+
// codex 내장 tool/web_search/instruction 블록 비활성화
|
|
126
|
+
writeFileSync(`${this.codexHome}/config.toml`, CODEX_CONFIG_TOML);
|
|
94
127
|
|
|
95
128
|
this.proc = spawn("codex", ["app-server", "--listen", "stdio://"], {
|
|
96
129
|
stdio: ["pipe", "pipe", "pipe"],
|
|
97
130
|
cwd,
|
|
98
|
-
env: {
|
|
131
|
+
env: {
|
|
132
|
+
PATH: process.env.PATH,
|
|
133
|
+
TMPDIR: process.env.TMPDIR,
|
|
134
|
+
CODEX_HOME: this.codexHome,
|
|
135
|
+
// environment 비활성화 → has_environment=false → shell/apply_patch/view_image 제거
|
|
136
|
+
CODEX_EXEC_SERVER_URL: "none",
|
|
137
|
+
},
|
|
99
138
|
});
|
|
100
139
|
|
|
101
140
|
this.proc.on("exit", (code) => {
|
|
102
141
|
logger.info(`worker ${this.config.tokenName} exited (code=${code})`);
|
|
142
|
+
this.failActiveTurn(
|
|
143
|
+
new Error(`codex worker exited while turn was running (code=${code ?? "unknown"})`),
|
|
144
|
+
);
|
|
103
145
|
this.ready = false;
|
|
104
146
|
this.rpc = null;
|
|
105
147
|
this.proc = null;
|
|
@@ -107,6 +149,8 @@ export class CodexAppServerWorker {
|
|
|
107
149
|
});
|
|
108
150
|
|
|
109
151
|
this.rpc = new CodexRpcClient(this.proc);
|
|
152
|
+
// restart 후에도 refresh server-request 핸들러를 유지 (없으면 codex 토큰 회전 실패 → session expired)
|
|
153
|
+
this.bindServerRequestHandler();
|
|
110
154
|
|
|
111
155
|
await this.rpc.request("initialize", {
|
|
112
156
|
clientInfo: { name: "qgrid", version: "1.0.0" },
|
|
@@ -179,6 +223,7 @@ export class CodexAppServerWorker {
|
|
|
179
223
|
async kill(): Promise<void> {
|
|
180
224
|
this.destroyed = true;
|
|
181
225
|
this.ready = false;
|
|
226
|
+
this.failActiveTurn(new Error("codex worker stopped"));
|
|
182
227
|
this.rpc?.destroy();
|
|
183
228
|
|
|
184
229
|
if (this.proc) {
|
|
@@ -213,6 +258,24 @@ export class CodexAppServerWorker {
|
|
|
213
258
|
return this.config.tokenName;
|
|
214
259
|
}
|
|
215
260
|
|
|
261
|
+
canReuseForToken(_tokenName: string, credentials: RefreshableWorkerCredentials): boolean {
|
|
262
|
+
return (
|
|
263
|
+
this.config.accountId === credentials.accountId &&
|
|
264
|
+
(this.config.planType ?? undefined) === (credentials.planType ?? undefined)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
updateTokenState(tokenName: string, credentials: RefreshableWorkerCredentials): void {
|
|
269
|
+
this.config.tokenName = tokenName;
|
|
270
|
+
this.config.accessToken = credentials.accessToken;
|
|
271
|
+
this.config.accountId = credentials.accountId;
|
|
272
|
+
if (credentials.planType) {
|
|
273
|
+
this.config.planType = credentials.planType;
|
|
274
|
+
} else {
|
|
275
|
+
delete this.config.planType;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
216
279
|
// ── Rate limits ─────────────────────────────────────────────────
|
|
217
280
|
|
|
218
281
|
async getRateLimits(): Promise<unknown> {
|
|
@@ -223,6 +286,14 @@ export class CodexAppServerWorker {
|
|
|
223
286
|
// ── Server-request delegation ───────────────────────────────────
|
|
224
287
|
|
|
225
288
|
setServerRequestHandler(handler: (method: string, params: unknown) => Promise<unknown>): void {
|
|
289
|
+
this.serverRequestHandler = handler;
|
|
290
|
+
this.bindServerRequestHandler();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// spawnAndInit에서 rpc 생성 직후 호출. restart로 rpc가 새로 만들어져도 핸들러가 유지된다.
|
|
294
|
+
bindServerRequestHandler(): void {
|
|
295
|
+
const handler = this.serverRequestHandler;
|
|
296
|
+
if (!handler) return;
|
|
226
297
|
this.rpc?.onServerRequest("account/chatgptAuthTokens/refresh", (params) =>
|
|
227
298
|
handler("account/chatgptAuthTokens/refresh", params),
|
|
228
299
|
);
|
|
@@ -305,11 +376,18 @@ export class CodexAppServerWorker {
|
|
|
305
376
|
} catch {}
|
|
306
377
|
}
|
|
307
378
|
|
|
379
|
+
private failActiveTurn(error: Error): void {
|
|
380
|
+
const abort = this.activeTurnAbort;
|
|
381
|
+
this.activeTurnAbort = undefined;
|
|
382
|
+
abort?.(error);
|
|
383
|
+
}
|
|
384
|
+
|
|
308
385
|
private consumeTurnNotifications(threadId: string, model: string): Promise<TurnResult> {
|
|
309
386
|
return new Promise<TurnResult>((resolve, reject) => {
|
|
387
|
+
let settled = false;
|
|
388
|
+
let abort: ActiveTurnAbort;
|
|
310
389
|
const timeout = setTimeout(() => {
|
|
311
|
-
|
|
312
|
-
reject(new Error("turn timeout (600s)"));
|
|
390
|
+
finishWithError(new Error("turn timeout (600s)"));
|
|
313
391
|
}, 600_000);
|
|
314
392
|
|
|
315
393
|
let text = "";
|
|
@@ -324,6 +402,7 @@ export class CodexAppServerWorker {
|
|
|
324
402
|
|
|
325
403
|
const cleanup = () => {
|
|
326
404
|
clearTimeout(timeout);
|
|
405
|
+
if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
|
|
327
406
|
for (const n of [
|
|
328
407
|
"item/completed",
|
|
329
408
|
"item/agentMessage/delta",
|
|
@@ -335,6 +414,23 @@ export class CodexAppServerWorker {
|
|
|
335
414
|
}
|
|
336
415
|
};
|
|
337
416
|
|
|
417
|
+
const finishWithError = (error: Error) => {
|
|
418
|
+
if (settled) return;
|
|
419
|
+
settled = true;
|
|
420
|
+
cleanup();
|
|
421
|
+
reject(error);
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const finishWithResult = (result: TurnResult) => {
|
|
425
|
+
if (settled) return;
|
|
426
|
+
settled = true;
|
|
427
|
+
cleanup();
|
|
428
|
+
resolve(result);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
abort = finishWithError;
|
|
432
|
+
this.activeTurnAbort = abort;
|
|
433
|
+
|
|
338
434
|
this.rpc!.onNotification("item/completed", (p) => {
|
|
339
435
|
if (p.threadId !== threadId) return;
|
|
340
436
|
if (p.item.type === "agentMessage") text = p.item.text;
|
|
@@ -348,16 +444,14 @@ export class CodexAppServerWorker {
|
|
|
348
444
|
this.rpc!.onNotification("turn/completed", (p) => {
|
|
349
445
|
if (p.threadId !== threadId) return;
|
|
350
446
|
durationMs = p.turn.durationMs ?? 0;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
else reject(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
|
|
447
|
+
if (p.turn.status === "completed") finishWithResult({ text, usage, durationMs, model });
|
|
448
|
+
else finishWithError(new Error(`turn failed: ${JSON.stringify(p.turn.error)}`));
|
|
354
449
|
});
|
|
355
450
|
|
|
356
451
|
this.rpc!.onNotification("error", (p) => {
|
|
357
452
|
if (p.threadId !== threadId) return;
|
|
358
453
|
if (!p.willRetry) {
|
|
359
|
-
|
|
360
|
-
reject(new Error(`codex error: ${p.error.message}`));
|
|
454
|
+
finishWithError(new Error(`codex error: ${p.error.message}`));
|
|
361
455
|
}
|
|
362
456
|
});
|
|
363
457
|
});
|
|
@@ -369,10 +463,10 @@ export class CodexAppServerWorker {
|
|
|
369
463
|
cb: StreamCallbacks,
|
|
370
464
|
): Promise<void> {
|
|
371
465
|
return new Promise<void>((resolve, reject) => {
|
|
466
|
+
let settled = false;
|
|
467
|
+
let abort: ActiveTurnAbort;
|
|
372
468
|
const timeout = setTimeout(() => {
|
|
373
|
-
|
|
374
|
-
cb.onError(new Error("turn timeout (600s)"));
|
|
375
|
-
reject(new Error("turn timeout (600s)"));
|
|
469
|
+
finishWithError(new Error("turn timeout (600s)"));
|
|
376
470
|
}, 600_000);
|
|
377
471
|
|
|
378
472
|
let text = "";
|
|
@@ -386,6 +480,7 @@ export class CodexAppServerWorker {
|
|
|
386
480
|
|
|
387
481
|
const cleanup = () => {
|
|
388
482
|
clearTimeout(timeout);
|
|
483
|
+
if (this.activeTurnAbort === abort) this.activeTurnAbort = undefined;
|
|
389
484
|
for (const n of [
|
|
390
485
|
"item/completed",
|
|
391
486
|
"item/agentMessage/delta",
|
|
@@ -397,6 +492,25 @@ export class CodexAppServerWorker {
|
|
|
397
492
|
}
|
|
398
493
|
};
|
|
399
494
|
|
|
495
|
+
const finishWithError = (error: Error) => {
|
|
496
|
+
if (settled) return;
|
|
497
|
+
settled = true;
|
|
498
|
+
cleanup();
|
|
499
|
+
cb.onError(error);
|
|
500
|
+
reject(error);
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const finishWithComplete = (result: TurnResult) => {
|
|
504
|
+
if (settled) return;
|
|
505
|
+
settled = true;
|
|
506
|
+
cleanup();
|
|
507
|
+
cb.onComplete(result);
|
|
508
|
+
resolve();
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
abort = finishWithError;
|
|
512
|
+
this.activeTurnAbort = abort;
|
|
513
|
+
|
|
400
514
|
this.rpc!.onNotification("item/agentMessage/delta", (p) => {
|
|
401
515
|
if (p.threadId !== threadId) return;
|
|
402
516
|
text += p.delta;
|
|
@@ -415,24 +529,17 @@ export class CodexAppServerWorker {
|
|
|
415
529
|
|
|
416
530
|
this.rpc!.onNotification("turn/completed", (p) => {
|
|
417
531
|
if (p.threadId !== threadId) return;
|
|
418
|
-
cleanup();
|
|
419
532
|
if (p.turn.status === "completed") {
|
|
420
|
-
|
|
421
|
-
resolve();
|
|
533
|
+
finishWithComplete({ text, usage, durationMs: p.turn.durationMs ?? 0, model });
|
|
422
534
|
} else {
|
|
423
|
-
|
|
424
|
-
cb.onError(err);
|
|
425
|
-
reject(err);
|
|
535
|
+
finishWithError(new Error(`turn ${p.turn.status}: ${JSON.stringify(p.turn.error)}`));
|
|
426
536
|
}
|
|
427
537
|
});
|
|
428
538
|
|
|
429
539
|
this.rpc!.onNotification("error", (p) => {
|
|
430
540
|
if (p.threadId !== threadId) return;
|
|
431
541
|
if (!p.willRetry) {
|
|
432
|
-
|
|
433
|
-
const err = new Error(`codex error: ${p.error.message}`);
|
|
434
|
-
cb.onError(err);
|
|
435
|
-
reject(err);
|
|
542
|
+
finishWithError(new Error(`codex error: ${p.error.message}`));
|
|
436
543
|
}
|
|
437
544
|
});
|
|
438
545
|
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type OpenAICredentials } from "../../../application/token/token.types";
|
|
4
|
+
import { type CodexAppServerWorker } from "./codex-worker";
|
|
5
|
+
import { OpenAIDispatcher } from "./openai-dispatcher";
|
|
6
|
+
|
|
7
|
+
function credentials(overrides: Partial<OpenAICredentials> = {}): OpenAICredentials {
|
|
8
|
+
return {
|
|
9
|
+
accessToken: "access",
|
|
10
|
+
refreshToken: "refresh",
|
|
11
|
+
accessTokenExpiresAt: Date.now() + 60_000,
|
|
12
|
+
accountId: "account",
|
|
13
|
+
planType: "plus",
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fakeWorker(reusable: boolean): CodexAppServerWorker {
|
|
19
|
+
return {
|
|
20
|
+
kill: vi.fn(async () => {}),
|
|
21
|
+
canReuseForToken: vi.fn(() => reusable),
|
|
22
|
+
updateTokenState: vi.fn(),
|
|
23
|
+
} as unknown as CodexAppServerWorker;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("OpenAIDispatcher token updates", () => {
|
|
27
|
+
it("keeps existing workers when only OpenAI auth tokens rotate", async () => {
|
|
28
|
+
const dispatcher = new OpenAIDispatcher();
|
|
29
|
+
const worker = fakeWorker(true);
|
|
30
|
+
const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
31
|
+
dispatcher.workerPool.set(1, [worker]);
|
|
32
|
+
|
|
33
|
+
const rotated = credentials({
|
|
34
|
+
accessToken: "new-access",
|
|
35
|
+
refreshToken: "new-refresh",
|
|
36
|
+
idToken: "new-id",
|
|
37
|
+
accessTokenExpiresAt: Date.now() + 120_000,
|
|
38
|
+
});
|
|
39
|
+
await dispatcher.onTokenUpdated(1, "renamed-token", rotated);
|
|
40
|
+
|
|
41
|
+
expect(worker.kill).not.toHaveBeenCalled();
|
|
42
|
+
expect(worker.updateTokenState).toHaveBeenCalledWith("renamed-token", rotated);
|
|
43
|
+
expect(spawnWorkers).not.toHaveBeenCalled();
|
|
44
|
+
expect(dispatcher.workerPool.get(1)).toEqual([worker]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("restarts workers when the OpenAI login identity changes", async () => {
|
|
48
|
+
const dispatcher = new OpenAIDispatcher();
|
|
49
|
+
const worker = fakeWorker(false);
|
|
50
|
+
const spawnWorkers = vi.spyOn(dispatcher, "spawnWorkers").mockResolvedValue(undefined);
|
|
51
|
+
dispatcher.workerPool.set(1, [worker]);
|
|
52
|
+
|
|
53
|
+
const changedIdentity = credentials({ accountId: "other-account" });
|
|
54
|
+
await dispatcher.onTokenUpdated(1, "token", changedIdentity);
|
|
55
|
+
|
|
56
|
+
expect(worker.kill).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(spawnWorkers).toHaveBeenCalledWith(1, "token", changedIdentity);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -92,6 +92,18 @@ export class OpenAIDispatcher implements ProviderDispatcher {
|
|
|
92
92
|
|
|
93
93
|
async onTokenUpdated(id: number, name: string, credentials: OpenAICredentials): Promise<void> {
|
|
94
94
|
const existing = this.workerPool.get(id) ?? [];
|
|
95
|
+
if (existing.length === 0) {
|
|
96
|
+
await this.spawnWorkers(id, name, credentials);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (existing.every((w) => w.canReuseForToken(name, credentials))) {
|
|
101
|
+
existing.forEach((w) => w.updateTokenState(name, credentials));
|
|
102
|
+
logger.info(`workers updated in-place for token ${id} (${name})`);
|
|
103
|
+
this.drainQueue();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
95
107
|
this.workerPool.delete(id);
|
|
96
108
|
await Promise.allSettled(existing.map((w) => w.kill()));
|
|
97
109
|
await this.spawnWorkers(id, name, credentials);
|