@theia/ai-codex 1.67.0-next.56

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.
@@ -0,0 +1,654 @@
1
+ // *****************************************************************************
2
+ // Copyright (C) 2025 EclipseSource GmbH.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import {
18
+ ChatAgent,
19
+ ChatAgentLocation,
20
+ ErrorChatResponseContentImpl,
21
+ MarkdownChatResponseContentImpl,
22
+ MutableChatRequestModel,
23
+ ThinkingChatResponseContentImpl,
24
+ ToolCallChatResponseContent,
25
+ } from '@theia/ai-chat';
26
+ import { TokenUsageService } from '@theia/ai-core';
27
+ import { PromptText } from '@theia/ai-core/lib/common/prompt-text';
28
+ import { generateUuid, nls } from '@theia/core';
29
+ import { URI } from '@theia/core/lib/common/uri';
30
+ import { inject, injectable } from '@theia/core/shared/inversify';
31
+ import type {
32
+ ItemStartedEvent,
33
+ ItemUpdatedEvent,
34
+ ItemCompletedEvent,
35
+ TurnCompletedEvent,
36
+ TurnFailedEvent,
37
+ ThreadEvent,
38
+ ThreadItem,
39
+ CommandExecutionItem,
40
+ FileChangeItem,
41
+ McpToolCallItem,
42
+ WebSearchItem,
43
+ Usage,
44
+ TodoListItem
45
+ } from '@openai/codex-sdk';
46
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
47
+ import { WorkspaceService } from '@theia/workspace/lib/browser';
48
+ import { ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
49
+ import { CodexToolCallChatResponseContent } from './codex-tool-call-content';
50
+ import { CodexFrontendService } from './codex-frontend-service';
51
+
52
+ export const CODEX_CHAT_AGENT_ID = 'Codex';
53
+ export const CODEX_INPUT_TOKENS_KEY = 'codexInputTokens';
54
+ export const CODEX_OUTPUT_TOKENS_KEY = 'codexOutputTokens';
55
+ export const CODEX_TOOL_CALLS_KEY = 'codexToolCalls';
56
+
57
+ const CODEX_FILE_CHANGE_ORIGINALS_KEY = 'codexFileChangeOriginals';
58
+ // const CODEX_CHANGESET_TITLE = nls.localize('theia/ai/codex/changeSetTitle', 'Codex Applied Changes');
59
+
60
+ type ToolInvocationItem = CommandExecutionItem | FileChangeItem | McpToolCallItem | WebSearchItem | TodoListItem;
61
+
62
+ /**
63
+ * Chat agent for OpenAI Codex integration.
64
+ */
65
+ @injectable()
66
+ export class CodexChatAgent implements ChatAgent {
67
+ id = CODEX_CHAT_AGENT_ID;
68
+ name = 'Codex';
69
+ description = nls.localize('theia/ai/codex/agentDescription',
70
+ 'OpenAI\'s coding assistant powered by Codex');
71
+ iconClass = 'codicon codicon-robot';
72
+ locations: ChatAgentLocation[] = ChatAgentLocation.ALL;
73
+ tags = [nls.localizeByDefault('Chat')];
74
+ variables: string[] = [];
75
+ prompts: [] = [];
76
+ languageModelRequirements: [] = [];
77
+ agentSpecificVariables: [] = [];
78
+ functions: string[] = [];
79
+ modes = [
80
+ { id: 'workspace-write', name: 'Workspace' },
81
+ { id: 'read-only', name: 'Read-Only' },
82
+ { id: 'danger-full-access', name: 'Full Access' }
83
+ ];
84
+
85
+ @inject(CodexFrontendService)
86
+ protected codexService: CodexFrontendService;
87
+
88
+ @inject(TokenUsageService)
89
+ protected tokenUsageService: TokenUsageService;
90
+
91
+ @inject(FileService)
92
+ protected readonly fileService: FileService;
93
+
94
+ @inject(WorkspaceService)
95
+ protected readonly workspaceService: WorkspaceService;
96
+
97
+ @inject(ChangeSetFileElementFactory)
98
+ protected readonly fileChangeFactory: ChangeSetFileElementFactory;
99
+
100
+ async invoke(request: MutableChatRequestModel): Promise<void> {
101
+ try {
102
+ const agentAddress = `${PromptText.AGENT_CHAR}${CODEX_CHAT_AGENT_ID}`;
103
+ let prompt = request.request.text.trim();
104
+ if (prompt.startsWith(agentAddress)) {
105
+ prompt = prompt.replace(agentAddress, '').trim();
106
+ }
107
+
108
+ const sessionId = request.session.id;
109
+ const sandboxMode = this.extractSandboxMode(request.request.modeId);
110
+ const streamResult = await this.codexService.send(
111
+ { prompt, sessionId, sandboxMode },
112
+ request.response.cancellationToken
113
+ );
114
+
115
+ for await (const event of streamResult) {
116
+ await this.handleEvent(event, request);
117
+ }
118
+
119
+ request.response.complete();
120
+ } catch (error) {
121
+ console.error('Codex error:', error);
122
+ request.response.response.addContent(
123
+ new ErrorChatResponseContentImpl(error)
124
+ );
125
+ request.response.error(error);
126
+ }
127
+ }
128
+
129
+ protected extractSandboxMode(modeId?: string): 'read-only' | 'workspace-write' | 'danger-full-access' {
130
+ if (modeId === 'read-only' || modeId === 'workspace-write' || modeId === 'danger-full-access') {
131
+ return modeId;
132
+ }
133
+ return 'workspace-write';
134
+ }
135
+
136
+ protected getToolCalls(request: MutableChatRequestModel): Map<string, CodexToolCallChatResponseContent> {
137
+ let toolCalls = request.getDataByKey(CODEX_TOOL_CALLS_KEY) as Map<string, CodexToolCallChatResponseContent> | undefined;
138
+ if (!toolCalls) {
139
+ toolCalls = new Map();
140
+ request.addData(CODEX_TOOL_CALLS_KEY, toolCalls);
141
+ }
142
+ return toolCalls;
143
+ }
144
+
145
+ protected async handleEvent(event: ThreadEvent, request: MutableChatRequestModel): Promise<void> {
146
+ if (event.type === 'item.started') {
147
+ await this.handleItemStarted(event, request);
148
+ } else if (event.type === 'item.updated') {
149
+ await this.handleItemUpdated(event, request);
150
+ } else if (event.type === 'item.completed') {
151
+ await this.handleItemCompleted(event, request);
152
+ } else if (event.type === 'turn.completed') {
153
+ this.handleTurnCompleted(event, request);
154
+ } else if (event.type === 'turn.failed') {
155
+ this.handleTurnFailed(event, request);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Type guard using discriminated union narrowing from SDK types.
161
+ */
162
+ protected isToolInvocation(item: ThreadItem): item is ToolInvocationItem {
163
+ return item.type === 'command_execution' ||
164
+ item.type === 'todo_list' ||
165
+ item.type === 'file_change' ||
166
+ item.type === 'mcp_tool_call' ||
167
+ item.type === 'web_search';
168
+ }
169
+
170
+ protected extractToolArguments(item: ToolInvocationItem): string {
171
+ const args: Record<string, unknown> = {};
172
+
173
+ if (item.type === 'command_execution') {
174
+ args.command = item.command;
175
+ args.status = item.status;
176
+ if (item.exit_code !== undefined) {
177
+ args.exit_code = item.exit_code;
178
+ }
179
+ } else if (item.type === 'file_change') {
180
+ args.changes = item.changes;
181
+ args.status = item.status;
182
+ } else if (item.type === 'mcp_tool_call') {
183
+ args.server = item.server;
184
+ args.tool = item.tool;
185
+ args.status = item.status;
186
+ } else if (item.type === 'web_search') {
187
+ args.query = item.query;
188
+ } else if (item.type === 'todo_list') {
189
+ args.id = item.id;
190
+ args.items = item.items;
191
+ }
192
+
193
+ return JSON.stringify(args);
194
+ }
195
+
196
+ /**
197
+ * Creates a pending tool call that will be updated when the item completes.
198
+ */
199
+ protected async handleItemStarted(event: ItemStartedEvent, request: MutableChatRequestModel): Promise<void> {
200
+ const item = event.item;
201
+
202
+ if (this.isToolInvocation(item)) {
203
+ if (item.type === 'file_change') {
204
+ await this.captureFileChangeOriginals(item, request);
205
+ return;
206
+ }
207
+ const toolCallId = generateUuid();
208
+ const args = this.extractToolArguments(item);
209
+
210
+ const toolCall = new CodexToolCallChatResponseContent(
211
+ toolCallId,
212
+ item.type,
213
+ args,
214
+ false,
215
+ undefined
216
+ );
217
+
218
+ this.getToolCalls(request).set(toolCallId, toolCall);
219
+ request.response.response.addContent(toolCall);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Updates the pending tool call with new data, especially for todo_list items.
225
+ */
226
+ protected async handleItemUpdated(event: ItemUpdatedEvent, request: MutableChatRequestModel): Promise<void> {
227
+ const item = event.item;
228
+
229
+ if (this.isToolInvocation(item)) {
230
+ const toolCalls = this.getToolCalls(request);
231
+ const match = this.findMatchingToolCall(item, toolCalls);
232
+
233
+ if (match) {
234
+ const [_, existingCall] = match;
235
+ existingCall.update(this.extractToolArguments(item));
236
+ request.response.response.responseContentChanged();
237
+ }
238
+ }
239
+ }
240
+
241
+ protected findMatchingToolCall(
242
+ item: ToolInvocationItem,
243
+ toolCalls: Map<string, CodexToolCallChatResponseContent>
244
+ ): [string, CodexToolCallChatResponseContent] | undefined {
245
+ let matchKey: string | undefined;
246
+ if (item.type === 'command_execution') {
247
+ matchKey = item.command;
248
+ } else if (item.type === 'web_search') {
249
+ matchKey = item.query;
250
+ } else if (item.type === 'mcp_tool_call') {
251
+ matchKey = `${item.server}:${item.tool}`;
252
+ } else if (item.type === 'todo_list') {
253
+ matchKey = item.id;
254
+ }
255
+
256
+ if (!matchKey) {
257
+ return undefined;
258
+ }
259
+
260
+ for (const [id, call] of toolCalls.entries()) {
261
+ const toolCallContent = call as ToolCallChatResponseContent;
262
+ if (toolCallContent.name !== item.type || toolCallContent.finished) {
263
+ continue;
264
+ }
265
+
266
+ try {
267
+ const args = toolCallContent.arguments ? JSON.parse(toolCallContent.arguments) : {};
268
+ let argKey: string | undefined;
269
+
270
+ if (item.type === 'command_execution') {
271
+ argKey = args.command;
272
+ } else if (item.type === 'web_search') {
273
+ argKey = args.query;
274
+ } else if (item.type === 'mcp_tool_call') {
275
+ argKey = `${args.server}:${args.tool}`;
276
+ } else if (item.type === 'todo_list') {
277
+ argKey = args.id;
278
+ }
279
+
280
+ if (argKey === matchKey) {
281
+ return [id, call];
282
+ }
283
+ } catch {
284
+ continue;
285
+ }
286
+ }
287
+
288
+ return undefined;
289
+ }
290
+
291
+ protected getFileChangeOriginals(request: MutableChatRequestModel): Map<string, Map<string, string>> {
292
+ let originals = request.getDataByKey(CODEX_FILE_CHANGE_ORIGINALS_KEY) as Map<string, Map<string, string>> | undefined;
293
+ if (!originals) {
294
+ originals = new Map();
295
+ request.addData(CODEX_FILE_CHANGE_ORIGINALS_KEY, originals);
296
+ }
297
+ return originals;
298
+ }
299
+
300
+ /**
301
+ * Snapshot the original contents for files that Codex is about to modify so we can populate the change set later.
302
+ */
303
+ protected async captureFileChangeOriginals(item: FileChangeItem, request: MutableChatRequestModel): Promise<void> {
304
+ const changes = item.changes;
305
+ if (!changes || changes.length === 0) {
306
+ return;
307
+ }
308
+
309
+ const rootUri = await this.getWorkspaceRootUri();
310
+ if (!rootUri) {
311
+ return;
312
+ }
313
+
314
+ const originals = this.getFileChangeOriginals(request);
315
+ let itemOriginals = originals.get(item.id);
316
+ if (!itemOriginals) {
317
+ itemOriginals = new Map();
318
+ originals.set(item.id, itemOriginals);
319
+ }
320
+
321
+ for (const change of changes) {
322
+ const rawPath = typeof change.path === 'string' ? change.path.trim() : '';
323
+ const path = this.normalizeRelativePath(rawPath, rootUri);
324
+ if (!path) {
325
+ continue;
326
+ }
327
+
328
+ const fileUri = this.resolveFileUri(rootUri, path);
329
+ if (!fileUri) {
330
+ continue;
331
+ }
332
+
333
+ // For additions we snapshot an empty original state; for deletions/updates we capture existing content if available.
334
+ if (change.kind === 'add') {
335
+ itemOriginals.set(path, '');
336
+ continue;
337
+ }
338
+
339
+ try {
340
+ if (await this.fileService.exists(fileUri)) {
341
+ const currentContent = await this.fileService.read(fileUri);
342
+ itemOriginals.set(path, currentContent.value.toString());
343
+ } else {
344
+ itemOriginals.set(path, '');
345
+ }
346
+ } catch (error) {
347
+ console.error('CodexChatAgent: Failed to capture original content for', path, error);
348
+ itemOriginals.set(path, '');
349
+ }
350
+ }
351
+ }
352
+
353
+ protected async handleFileChangeCompleted(item: FileChangeItem, request: MutableChatRequestModel): Promise<boolean> {
354
+ if (!item.changes || item.changes.length === 0) {
355
+ return false;
356
+ }
357
+
358
+ const originals = this.getFileChangeOriginals(request);
359
+
360
+ if (item.status === 'failed') {
361
+ const affectedPaths = item.changes
362
+ .map(change => change.path)
363
+ .filter(path => !!path)
364
+ .join(', ');
365
+ const message = affectedPaths.length > 0
366
+ ? nls.localize('theia/ai/codex/fileChangeFailed', 'Codex failed to apply changes for: {0}', affectedPaths)
367
+ : nls.localize('theia/ai/codex/fileChangeFailedGeneric', 'Codex failed to apply file changes.');
368
+ request.response.response.addContent(
369
+ new ErrorChatResponseContentImpl(new Error(message))
370
+ );
371
+ originals.delete(item.id);
372
+ return true;
373
+ }
374
+
375
+ // const rootUri = await this.getWorkspaceRootUri();
376
+ // if (!rootUri) {
377
+ // console.warn('CodexChatAgent: Unable to resolve workspace root for file change event.');
378
+ // return false;
379
+ // }
380
+
381
+ // const changeSet = request.session?.changeSet;
382
+ // if (!changeSet) {
383
+ // originals.delete(item.id);
384
+ // return false;
385
+ // }
386
+
387
+ // const itemOriginals = originals.get(item.id);
388
+ // let createdElement = false;
389
+
390
+ // for (const change of item.changes) {
391
+ // const rawPath = typeof change.path === 'string' ? change.path.trim() : '';
392
+ // const path = this.normalizeRelativePath(rawPath, rootUri);
393
+ // if (!path) {
394
+ // continue;
395
+ // }
396
+
397
+ // const fileUri = this.resolveFileUri(rootUri, path);
398
+ // if (!fileUri) {
399
+ // continue;
400
+ // }
401
+
402
+ // const originalState = itemOriginals?.get(path) ?? '';
403
+ // let targetState = '';
404
+
405
+ // if (change.kind !== 'delete') {
406
+ // const content = await this.readFileContentSafe(fileUri);
407
+ // if (content === undefined) {
408
+ // continue;
409
+ // }
410
+ // targetState = content;
411
+ // }
412
+
413
+ // const elementType = this.mapChangeKind(change.kind);
414
+ // const fileElement = this.fileChangeFactory({
415
+ // uri: fileUri,
416
+ // type: elementType,
417
+ // state: 'applied',
418
+ // targetState,
419
+ // originalState,
420
+ // requestId: request.id,
421
+ // chatSessionId: request.session.id
422
+ // });
423
+
424
+ // changeSet.addElements(fileElement);
425
+ // createdElement = true;
426
+ // }
427
+
428
+ originals.delete(item.id);
429
+
430
+ // if (createdElement) {
431
+ // changeSet.setTitle(CODEX_CHANGESET_TITLE);
432
+ // }
433
+ return false;
434
+ }
435
+
436
+ protected normalizeRelativePath(path: string, rootUri?: URI): string | undefined {
437
+ if (!path) {
438
+ return undefined;
439
+ }
440
+
441
+ let normalized = path.replace(/\\/g, '/').trim();
442
+ if (!normalized) {
443
+ return undefined;
444
+ }
445
+
446
+ if (normalized.includes('://')) {
447
+ try {
448
+ const uri = new URI(normalized);
449
+ normalized = uri.path.fsPath();
450
+ } catch {
451
+ }
452
+ }
453
+
454
+ if (/^[a-zA-Z]:\//.test(normalized)) {
455
+ normalized = `/${normalized}`;
456
+ }
457
+
458
+ if (rootUri) {
459
+ const candidates = [
460
+ this.ensureTrailingSlash(rootUri.path.normalize().toString()),
461
+ this.ensureTrailingSlash(rootUri.path.fsPath().replace(/\\/g, '/'))
462
+ ];
463
+
464
+ const lowerNormalized = normalized.toLowerCase();
465
+ for (const candidate of candidates) {
466
+ if (!candidate) {
467
+ continue;
468
+ }
469
+ const lowerCandidate = candidate.toLowerCase();
470
+ if (lowerNormalized.startsWith(lowerCandidate)) {
471
+ normalized = normalized.substring(candidate.length);
472
+ break;
473
+ }
474
+ }
475
+ }
476
+
477
+ if (normalized.startsWith('./')) {
478
+ normalized = normalized.substring(2);
479
+ }
480
+ while (normalized.startsWith('/')) {
481
+ normalized = normalized.substring(1);
482
+ }
483
+
484
+ normalized = normalized.trim();
485
+ return normalized || undefined;
486
+ }
487
+
488
+ protected ensureTrailingSlash(path: string): string {
489
+ if (!path) {
490
+ return '';
491
+ }
492
+ return path.endsWith('/') ? path : `${path}/`;
493
+ }
494
+
495
+ // protected async readFileContentSafe(fileUri: URI): Promise<string | undefined> {
496
+ // try {
497
+ // if (!await this.fileService.exists(fileUri)) {
498
+ // console.warn('CodexChatAgent: Skipping file change entry because file is missing', fileUri.toString());
499
+ // return undefined;
500
+ // }
501
+ // const fileContent = await this.fileService.read(fileUri);
502
+ // return fileContent.value.toString();
503
+ // } catch (error) {
504
+ // console.error('CodexChatAgent: Failed to read updated file content for', fileUri.toString(), error);
505
+ // return undefined;
506
+ // }
507
+ // }
508
+
509
+ // protected mapChangeKind(kind: FileChangeItem['changes'][number]['kind']): 'add' | 'delete' | 'modify' {
510
+ // switch (kind) {
511
+ // case 'add':
512
+ // return 'add';
513
+ // case 'delete':
514
+ // return 'delete';
515
+ // default:
516
+ // return 'modify';
517
+ // }
518
+ // }
519
+
520
+ protected resolveFileUri(rootUri: URI, relativePath: string): URI | undefined {
521
+ try {
522
+ const candidate = rootUri.resolve(relativePath);
523
+ const normalizedCandidate = candidate.withPath(candidate.path.normalize());
524
+ const normalizedRoot = rootUri.withPath(rootUri.path.normalize());
525
+ if (!normalizedRoot.isEqualOrParent(normalizedCandidate)) {
526
+ console.warn(`CodexChatAgent: Skipping file change outside workspace: ${relativePath}`);
527
+ return undefined;
528
+ }
529
+ return normalizedCandidate;
530
+ } catch (error) {
531
+ console.error('CodexChatAgent: Failed to resolve file URI for', relativePath, error);
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ protected async getWorkspaceRootUri(): Promise<URI | undefined> {
537
+ const roots = await this.workspaceService.roots;
538
+ if (roots && roots.length > 0) {
539
+ return roots[0].resource;
540
+ }
541
+ return undefined;
542
+ }
543
+
544
+ protected async handleItemCompleted(event: ItemCompletedEvent, request: MutableChatRequestModel): Promise<void> {
545
+ const item = event.item;
546
+
547
+ if (this.isToolInvocation(item)) {
548
+ if (item.type === 'file_change') {
549
+ const handled = await this.handleFileChangeCompleted(item, request);
550
+ if (handled) {
551
+ return;
552
+ }
553
+ }
554
+ const toolCalls = this.getToolCalls(request);
555
+ const match = this.findMatchingToolCall(item, toolCalls);
556
+
557
+ if (match) {
558
+ const [id, _] = match;
559
+ const updatedCall = new CodexToolCallChatResponseContent(
560
+ id,
561
+ item.type,
562
+ this.extractToolArguments(item),
563
+ true,
564
+ JSON.stringify(item)
565
+ );
566
+ toolCalls.set(id, updatedCall);
567
+ request.response.response.addContent(updatedCall);
568
+ } else {
569
+ const toolCallId = generateUuid();
570
+ const newToolCall = new CodexToolCallChatResponseContent(
571
+ toolCallId,
572
+ item.type,
573
+ this.extractToolArguments(item),
574
+ true,
575
+ JSON.stringify(item)
576
+ );
577
+ toolCalls.set(toolCallId, newToolCall);
578
+ request.response.response.addContent(newToolCall);
579
+ }
580
+ } else if (item.type === 'reasoning') {
581
+ request.response.response.addContent(
582
+ new ThinkingChatResponseContentImpl(item.text, '')
583
+ );
584
+
585
+ } else if (item.type === 'agent_message') {
586
+ request.response.response.addContent(
587
+ new MarkdownChatResponseContentImpl(item.text)
588
+ );
589
+ } else if (item.type === 'error') {
590
+ request.response.response.addContent(
591
+ new ErrorChatResponseContentImpl(new Error(item.message))
592
+ );
593
+ }
594
+ }
595
+
596
+ protected handleTurnCompleted(event: TurnCompletedEvent, request: MutableChatRequestModel): void {
597
+ const usage = event.usage;
598
+ this.updateTokens(request, usage.input_tokens, usage.output_tokens);
599
+ this.reportTokenUsage(request, usage);
600
+ }
601
+
602
+ protected handleTurnFailed(event: TurnFailedEvent, request: MutableChatRequestModel): void {
603
+ const errorMsg = event.error.message;
604
+ request.response.response.addContent(
605
+ new ErrorChatResponseContentImpl(new Error(errorMsg))
606
+ );
607
+ }
608
+
609
+ protected updateTokens(request: MutableChatRequestModel, inputTokens: number, outputTokens: number): void {
610
+ request.addData(CODEX_INPUT_TOKENS_KEY, inputTokens);
611
+ request.addData(CODEX_OUTPUT_TOKENS_KEY, outputTokens);
612
+ this.updateSessionSuggestion(request);
613
+ }
614
+
615
+ protected updateSessionSuggestion(request: MutableChatRequestModel): void {
616
+ const { inputTokens, outputTokens } = this.getSessionTotalTokens(request);
617
+ const formatTokens = (tokens: number): string => {
618
+ if (tokens >= 1000) {
619
+ return `${(tokens / 1000).toFixed(1)}K`;
620
+ }
621
+ return tokens.toString();
622
+ };
623
+ const suggestion = `↑ ${formatTokens(inputTokens)} | ↓ ${formatTokens(outputTokens)}`;
624
+ request.session.setSuggestions([suggestion]);
625
+ }
626
+
627
+ protected getSessionTotalTokens(request: MutableChatRequestModel): { inputTokens: number; outputTokens: number } {
628
+ const requests = request.session.getRequests();
629
+ let totalInputTokens = 0;
630
+ let totalOutputTokens = 0;
631
+
632
+ for (const req of requests) {
633
+ const inputTokens = req.getDataByKey(CODEX_INPUT_TOKENS_KEY) as number ?? 0;
634
+ const outputTokens = req.getDataByKey(CODEX_OUTPUT_TOKENS_KEY) as number ?? 0;
635
+ totalInputTokens += inputTokens;
636
+ totalOutputTokens += outputTokens;
637
+ }
638
+
639
+ return { inputTokens: totalInputTokens, outputTokens: totalOutputTokens };
640
+ }
641
+
642
+ protected async reportTokenUsage(request: MutableChatRequestModel, usage: Usage): Promise<void> {
643
+ try {
644
+ await this.tokenUsageService.recordTokenUsage('openai/codex', {
645
+ inputTokens: usage.input_tokens,
646
+ outputTokens: usage.output_tokens,
647
+ cachedInputTokens: usage.cached_input_tokens,
648
+ requestId: request.id
649
+ });
650
+ } catch (error) {
651
+ console.error('Failed to report token usage:', error);
652
+ }
653
+ }
654
+ }