-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexchange-projector.js
More file actions
1415 lines (1342 loc) · 62.4 KB
/
Copy pathexchange-projector.js
File metadata and controls
1415 lines (1342 loc) · 62.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// @ts-check
import { isAbsolute } from 'node:path'
import { createUsagePolicyResolver, USAGE_POLICY_DROP } from '../../../../src/core/usage-policy/index.js'
import { redactRemoteUserinfo } from './git-remote.js'
import {
copyNumberAlias,
firstString,
mergeJsonObjects,
netInputUsage,
numberValue,
reasoningMessageFromPayload,
setIfString,
stampUsageOnLastAssistant,
textBlocksFromContent,
toolResultBlockFromPayload,
toolUseBlockFromPayload,
} from './response-items.js'
import { canonicalJson, isPlainObject, parseMaybeJson, sha256Hex, stringValue } from 'hypaware/core/util'
/**
* @import { AiGatewayExchangeInput, AiGatewayExchangeProjector, AiGatewayProjectedExchange, AiGatewayProjectedMessage, JsonObject, JsonValue } from '../../../../hypaware-plugin-kernel-types.js'
* @import { UsagePolicyResolver } from '../../../../src/core/usage-policy/types.js'
* @import { RolloutCwdResolver } from './types.js'
*/
/**
* Build the `@hypaware/codex` adapter's full exchange projector. The
* single projector subsumes three transport flavors that all flow
* through the Codex client:
*
* - OpenAI Chat (`/v1/chat/completions`): non-streaming JSON.
* - OpenAI Responses (`/v1/responses`): JSON or SSE.
* - ChatGPT Codex (`/backend-api/codex/*`): SSE, with Codex-specific
* turn metadata, workspace, and identity headers.
*
* The match function is intentionally permissive across these paths
* so the gateway can route a single Codex install (whether API-key or
* ChatGPT subscription mode) through one projector without exposing
* provider semantics to the gateway core.
*
* @param {{ resolver?: UsagePolicyResolver, rolloutCwd?: RolloutCwdResolver, localOnlyListPath?: string }} [opts]
* @returns {AiGatewayExchangeProjector}
*/
export function createCodexExchangeProjector(opts = {}) {
// One `.hypignore` resolver per projector instance (one per started
// listener): the per-cwd cache then spans the listener's lifetime so the
// capture hot path adds no unbounded fs work (LLP 0049 R6).
// @ref LLP 0103 [implements]: the machine-local list is the resolver's second
// source, so a `--private` (`ignore`) dir drops at capture, not just export.
const resolver = opts.resolver ?? createUsagePolicyResolver({ localOnlyListPath: opts.localOnlyListPath })
// @ref LLP 0083 [implements]: the ChatGPT-subscription route carries no
// in-band cwd, so fall back to the session rollout's session_meta.cwd. Left
// undefined (no fallback) when the caller does not wire it, e.g. unit tests
// that don't exercise the rollout path; the plugin wires it in index.js.
const rolloutCwd = opts.rolloutCwd
return {
name: 'codex-exchange',
priority: 100,
/** @param {AiGatewayExchangeInput} input */
match(input) {
const path = input.path ?? ''
if (isOpenAiChatPath(path)) return true
if (isOpenAiResponsesPath(path)) return true
if (isCodexNamespacePath(path)) return true
// Any Codex client tags a turn-metadata-carrying request with
// `x-codex-turn-metadata`, even when the path looks generic, so accept the
// header as a sufficient match signal. It is NOT a Desktop-only signal.
// @ref LLP 0151#real-header-names [constrained-by]: every Codex client
// emits it, so the match is client-independent.
if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true
// NOTE this gate deliberately does not consult the body, while
// `resolveCodexContext` treats a Codex-owned `client_metadata` as a
// sufficient Codex signal. The two only stay consistent because the path
// set above covers every route Codex posts to, so a body-only Codex
// request is always matched here first. A test pins that: see
// `test/plugins/codex-exchange-projector.test.js` ("every route Codex
// posts to is matched..."). Widen the path set, do not start reading the
// body here, if Codex adds a route.
return false
},
/**
* @param {AiGatewayExchangeInput} input
* @param {{
* log?: {
* info?: (m: string, f?: Record<string, unknown>) => void,
* warn?: (m: string, f?: Record<string, unknown>) => void,
* },
* isSessionIgnored?: (sessionId: string) => boolean,
* }} [ctx]
*/
project(input, ctx) {
const reqBody = parseMaybeJson(input.request_body)
if (!isPlainObject(reqBody)) return undefined
const path = input.path ?? ''
const provider = resolveProvider(input, reqBody, path)
const codexContext = resolveCodexContext(input, provider, path, reqBody)
// `resolveConversationId` needs nothing from the built messages, so it
// (and the session id derived from it) is resolved here, ABOVE the cwd
// check: the session id keys BOTH the rollout cwd fallback below and the
// session opt-out drop, and both drop checks run before message-shaping.
const conversationId = resolveConversationId(reqBody, input, provider, path, codexContext)
// @ref LLP 0030#decision: session_id is the partition key (always
// non-null): Codex's `metadata.session_id`, falling back to the
// thread (conversation_id) when no session id was captured. Keep
// conversation_id = the thread; both can be set for Codex.
const sessionId = stringValue(codexContext?.session_id) ?? conversationId
// @ref LLP 0050 [implements]: capture-seam drop, symmetric to the
// @hypaware/claude projector. Once this exchange's cwd is resolved, an
// ancestor `.hypignore` of class `ignore` drops the exchange by returning
// the terminal `USAGE_POLICY_DROP` sentinel (the gateway source's
// `messageRows.length > 0` write guard then persists nothing). The
// sentinel (NOT a bare `undefined`) stops the dispatcher's projector walk
// so no later overlapping projector can record the suppressed exchange,
// and is logged as a drop rather than a `no_projector_match` miss. The
// response has already streamed, so the live call is untouched: only
// persistence is suppressed (LLP 0049 R1/R2). This is the same cwd
// `resolveRecordedContext` stamps on the row.
//
// @ref LLP 0083 [implements]: the in-band cwd (Responses `metadata`, the
// API-key route) is the fast path; only when it is absent (the
// ChatGPT-subscription route) fall back to the session rollout's
// session_meta.cwd so `.hypignore` coverage is client-independent and live
// rows carry the same cwd the codex backfill reads. The `??` keeps the
// rollout lookup LAZY (a fresh in-band cwd never scans), and it is keyed on
// a Codex thread id (only a real Codex thread has a rollout), so
// non-codex traffic never scans.
const cwd = usableInBandCwd(firstString(codexContext?.cwd, readRecordedCwd(reqBody)), ctx)
?? resolveRolloutCwd(rolloutCwd, codexContext)
// @ref LLP 0083#decision [implements]: a refused workspace substitution is
// observable, not silent - it means the gate is measuring a different
// directory than it would have. Paths are hashed: this seam sees LLM traffic.
if (codexContext?.refused_workspace_cwd) {
ctx?.log?.warn?.('plugin.codex.usage_policy_workspace_cwd_refused', {
component: 'codex',
operation: 'usage_policy_workspace_cwd_refused',
error_kind: 'workspace_cwd_mismatch',
workspace_sha256: sha256Hex(codexContext.refused_workspace_cwd).slice(0, 16),
cwd_sha256: cwd ? sha256Hex(cwd).slice(0, 16) : undefined,
exchange_id: input.exchange_id,
})
}
if (cwd) {
const policy = resolver.resolve(cwd)
if (policy.class === 'ignore') {
// `declared` distinguishes an intended `ignore` from a fail-safe clamp
// of an unimplemented token; on a clamp escalate to warn (R3 SHOULD).
ctx?.log?.[policy.warn ? 'warn' : 'info']?.('plugin.codex.usage_policy_drop', {
component: 'codex',
operation: 'usage_policy_drop',
class: policy.class,
declared: policy.declared,
governed_by: policy.governedBy,
cwd_sha256: sha256Hex(cwd).slice(0, 16),
...(policy.warn ? { warn: policy.warn } : {}),
})
return USAGE_POLICY_DROP
}
}
// @ref LLP 0066#enforcement [implements]: session opt-out drop. Keyed on
// the stamped session_id (metadata.session_id ?? thread id), the exact
// value the row would be stamped with (R5). NOTE the documented
// over-drop (LLP 0066#scope): one Codex session_id contains multiple
// conversation_id threads, so an ignored session suppresses ALL of
// them; per-thread grain is a spec non-goal.
// @ref LLP 0050: second match key, same adapter seam as the .hypignore
// drop above; either match suppresses (R7), they do not interact.
if (ctx?.isSessionIgnored?.(sessionId)) {
ctx?.log?.info?.('plugin.codex.usage_policy_drop', {
component: 'codex',
operation: 'usage_policy_drop',
policy_source: 'session_opt_out',
session_id: sessionId,
exchange_id: input.exchange_id,
})
return USAGE_POLICY_DROP
}
const responseBody = parseMaybeJson(input.response_body)
const streamEvents = Array.isArray(input.stream_events) ? input.stream_events : []
const messages = messagesForTransport({ provider, path, reqBody, responseBody, streamEvents })
if (messages.length === 0) return undefined
const recordedContext = resolveRecordedContext(reqBody, codexContext, cwd)
/** @type {JsonObject} */
const codexAttributes = codexContext?.attributes ? { ...codexContext.attributes } : {}
// The projector never supplies message_id today, so every row
// takes the gateway's fallback identity. Stamp the codex-side
// signal for symmetry with the @hypaware/claude adapter.
codexAttributes.identity_source = 'gateway_fallback'
const projectionAttributes = Object.keys(codexAttributes).length > 0
? { codex: codexAttributes }
: undefined
/** @type {AiGatewayProjectedExchange} */
const projection = {
provider,
session_id: sessionId,
conversation_id: conversationId,
conversation_started_at: input.ts_start,
conversation_source: resolveConversationSource(provider),
cwd: recordedContext.cwd,
git_branch: recordedContext.git_branch,
// @ref LLP 0032#capture: repo identity for the graph bridge (Repo/Commit).
// repo_root is intentionally omitted for Codex (left null). See
// resolveCodexContext. @ref LLP 0032#codex-repo-root
git_remote: codexContext?.git_remote,
head_sha: codexContext?.head_sha,
client_name: recordedContext.client_name,
client_version: recordedContext.client_version,
entrypoint: recordedContext.entrypoint,
user_type: recordedContext.user_type,
permission_mode: recordedContext.permission_mode,
is_sidechain: recordedContext.is_sidechain,
parent_thread_id: codexContext?.parent_thread_id,
user_id: resolveUserId(reqBody, provider),
request_id: resolveRequestId(input),
prompt_id: codexContext?.turn_id,
model: resolveModel(reqBody, responseBody),
system_text: extractSystemText(reqBody.system ?? reqBody.instructions),
tools: /** @type {any} */ (reqBody.tools),
attributes: projectionAttributes,
messages,
}
return stripUndefined(projection)
},
}
}
/**
* The rollout cwd fallback's lookup key.
*
* A Codex rollout is one **thread's** file and its name embeds that thread's id
* (`session_meta.payload.id`), NOT the session container (`payload.session_id`)
* the row partitions and the session opt-out drops on
* (@ref LLP 0030#decision). The two are the same uuid on a root thread, so
* handing over the container looked correct: a **subagent** thread inherits its
* root's container but mints its own thread id, so the container resolved the
* ROOT thread's rollout and the subagent turn was judged against a directory it
* never ran in. `.hypignore` is directory-scoped, so that recorded turns that
* should have been dropped.
* @ref LLP 0083#decision [implements]: the thread is what selects the rollout
*
* When the client states no thread id the container is still the right key for a
* ROOT thread (there the two are one uuid). That was once the common
* subscription-route shape, a bare `session-id` header; it is not any more, and
* that header name is not one this file reads on any path
* (@ref LLP 0151#real-header-names). The name is real on Codex's compaction and
* websocket paths, so leaving it unread is a decision rather than an oversight:
* those requests state the same ids in the turn-metadata blob
* (@ref LLP 0165#header-audit-correction). Since the adapter began reading the
* body's flat `client_metadata` map, which Codex fills with BOTH ids on every request
* (@ref LLP 0151#body-is-authority), an ordinary Codex turn states its thread and
* is answered by the branch above. What is left for the two lines below is a turn
* that names a container on a Codex-owned surface while naming no `thread_id` on
* any of them, which no `codex-rs` surface is known to produce.
*
* **Which lineage counts, and why it cannot be `thread_source` alone.**
* `thread_source` and `parent_thread_id` are read out of `x-codex-turn-metadata`,
* and that blob states `session_id` and `thread_id` as a pair or not at all (both
* gated on the same `has_turn_identity`), so it can never supply the container
* this fallback needs while withholding the thread that pre-empts it. Note the
* blob is NOT what answers such a turn: for the one kind with no turn identity
* (memory consolidation) the blob states the lineage and neither id, and the turn
* is answered by the body map, which carries both ids ungated. A refusal keyed
* only on those blob fields therefore cannot fire for a real Codex turn, but the
* reason is the map, not the blob. The lineage that
* would survive a turn stating no thread id is the lineage Codex sends as a
* DIRECT header, gated on nothing else: `x-codex-parent-thread-id` and
* `x-openai-subagent` (see `subagent_signal` in `resolveCodexContext`). Those are
* what make this refusal reachable at all, so both are consulted here.
*
* A turn stating a container, no thread id, and no lineage of any kind is then
* taken as the root thread it claims to be. That is a bounded residual: it can
* only mis-resolve for a client that both withholds its thread id and withholds
* every lineage signal on a subagent turn, and Codex withholds neither together.
* The mirror residual is the refusal itself: `subagent_signal` is value-blind, so
* `review`, `compact` and `memory_consolidation` (same-workspace sub-threads,
* where the root's cwd is the correct answer) refuse a container the root would
* have resolved, and LLP 0049 then fails OPEN and records the turn. Both residuals
* need the same unobserved shape (a container with no thread id anywhere), so
* neither is reachable from Codex traffic as `codex-rs` is documented to emit it.
* Dropping the fallback is not the safer half of that trade: it returns every turn
* that states only a container, root threads included, to `cwd = NULL`, which
* fails `.hypignore` open for that whole traffic class and is the regression
* LLP 0083 exists to prevent. @ref LLP 0083#container-fallback-gap [constrained-by]
*
* @param {RolloutCwdResolver | undefined} rolloutCwd
* @param {ReturnType<typeof resolveCodexContext>} codexContext
* @returns {string | undefined}
*/
function resolveRolloutCwd(rolloutCwd, codexContext) {
if (!rolloutCwd || !codexContext) return undefined
if (codexContext.thread_id) return rolloutCwd.resolve(codexContext.thread_id)
if (codexContext.thread_source === 'subagent' || codexContext.subagent_signal) return undefined
return codexContext.session_id ? rolloutCwd.resolve(codexContext.session_id) : undefined
}
// ---------------------------------------------------------------------
// Provider routing
// ---------------------------------------------------------------------
/**
* Promote a request to a provider label the projection can carry. We
* trust the gateway-routed `input.provider` first (it comes from the
* preset that won routing) and only fall back to path inference for
* exchanges that arrived without a preset hint.
*
* @param {AiGatewayExchangeInput} input
* @param {Record<string, unknown>} reqBody
* @param {string} path
* @returns {string}
*/
function resolveProvider(input, reqBody, path) {
const direct = stringValue(input.provider ?? undefined)
if (direct) return direct
const upstream = stringValue(input.upstream)
if (upstream === 'openai' || upstream === 'chatgpt') return upstream
if (isCodexNamespacePath(path)) return 'chatgpt'
if (isOpenAiChatPath(path) || isOpenAiResponsesPath(path)) return 'openai'
return upstream || 'openai'
}
/** @param {string} path */
function isOpenAiChatPath(path) {
return path === '/v1/chat/completions' ||
path === '/chat/completions' ||
path.endsWith('/chat/completions') ||
path.startsWith('/v1/chat/completions/') ||
path.startsWith('/chat/completions/')
}
/** @param {string} path */
function isOpenAiResponsesPath(path) {
return path === '/v1/responses' ||
path === '/responses' ||
path.endsWith('/responses') ||
path.startsWith('/v1/responses/') ||
path.startsWith('/responses/') ||
path === '/v1/models' ||
path.startsWith('/v1/models/')
}
/** @param {string} path */
function isCodexNamespacePath(path) {
return path === '/backend-api/codex' ||
path.startsWith('/backend-api/codex/')
}
// ---------------------------------------------------------------------
// Message extraction per transport
// ---------------------------------------------------------------------
/**
* @param {{
* provider: string,
* path: string,
* reqBody: Record<string, unknown>,
* responseBody: unknown,
* streamEvents: Array<{ event: string, data: string }>,
* }} ctx
* @returns {AiGatewayProjectedMessage[]}
*/
function messagesForTransport(ctx) {
// Chat-completions request bodies carry `messages: [...]`. Responses
// bodies carry `input: ...` (string or array). Treat path AND body
// shape as joint signals so a chat-shaped request mis-routed onto a
// responses path still parses correctly.
if (isOpenAiChatPath(ctx.path) || Array.isArray(ctx.reqBody.messages)) {
return openAiChatMessages(ctx.reqBody, ctx.responseBody)
}
return openAiResponsesMessages(ctx.reqBody, ctx.responseBody, ctx.streamEvents)
}
/**
* @param {Record<string, unknown>} reqBody
* @param {unknown} responseBody
* @returns {AiGatewayProjectedMessage[]}
*/
function openAiChatMessages(reqBody, responseBody) {
const requestMessages = Array.isArray(reqBody.messages) ? reqBody.messages : []
/** @type {AiGatewayProjectedMessage[]} */
const messages = []
for (const raw of requestMessages) {
if (!isPlainObject(raw)) continue
const projected = openAiChatMessageToProjected(raw)
if (projected) messages.push(projected)
}
const choice = firstChoice(responseBody)
if (choice) {
const responseMessage = isPlainObject(choice.message) ? choice.message : undefined
if (responseMessage) {
const assistant = openAiChatMessageToProjected(responseMessage)
if (assistant) {
const finish = stringValue(choice.finish_reason)
if (finish) assistant.raw_frame = { ...assistant.raw_frame, finish_reason: finish }
const usageAttributes = openAiUsageAttributes(readOpenAiUsage(responseBody))
if (usageAttributes) assistant.attributes = mergeJsonObjects(assistant.attributes, usageAttributes)
messages.push(assistant)
}
}
}
return messages
}
/**
* @param {Record<string, unknown>} message
* @returns {AiGatewayProjectedMessage | undefined}
*/
function openAiChatMessageToProjected(message) {
const role = stringValue(message.role) ?? 'user'
if (role === 'tool') {
const toolCallId = stringValue(message.tool_call_id)
const text = typeof message.content === 'string'
? message.content
: textFromBlocks(textBlocksFromContent(message.content))
return {
role,
content: [{
type: 'tool_result',
...(toolCallId ? { tool_use_id: toolCallId } : {}),
...(text ? { content: text } : {}),
}],
}
}
/** @type {JsonObject[]} */
const content = textBlocksFromContent(message.content)
if (Array.isArray(message.tool_calls)) {
for (const call of message.tool_calls) {
if (!isPlainObject(call)) continue
const fn = isPlainObject(call.function) ? call.function : {}
const id = stringValue(call.id)
const name = stringValue(fn.name)
if (!id || !name) continue
content.push({
type: 'tool_use',
id,
name,
input: /** @type {JsonValue} */ (parseMaybeJson(fn.arguments) ?? null),
})
}
}
if (content.length === 0) return undefined
return { role, content }
}
/**
* @param {Record<string, unknown>} reqBody
* @param {unknown} responseBody
* @param {Array<{ event: string, data: string }>} streamEvents
* @returns {AiGatewayProjectedMessage[]}
*/
function openAiResponsesMessages(reqBody, responseBody, streamEvents) {
/** @type {AiGatewayProjectedMessage[]} */
const messages = responsesInputMessages(reqBody.input)
let assistant = responsesAssistantMessagesFromBody(responseBody)
if (assistant.length === 0) assistant = responsesAssistantMessagesFromStream(streamEvents)
const usageAttributes = openAiUsageAttributes(
readOpenAiUsage(responseBody) ?? readOpenAiUsageFromResponsesStream(streamEvents)
)
stampUsageOnLastAssistant(assistant, usageAttributes)
for (const msg of assistant) messages.push(msg)
return messages
}
/**
* Fan items out so each `function_call` / `function_call_output` /
* `reasoning` becomes its own projected message: the same per-item
* projection the backfill applies to rollout items (shared via
* `response-items.js`), so a turn-2 input replay hashes equal to the
* backfilled session.
*
* @param {unknown} input
* @returns {AiGatewayProjectedMessage[]}
*/
function responsesInputMessages(input) {
if (typeof input === 'string') {
if (input.length === 0) return []
return [{ role: 'user', content: [{ type: 'text', text: input }] }]
}
if (!Array.isArray(input)) return []
/** @type {AiGatewayProjectedMessage[]} */
const out = []
for (const item of input) {
if (!isPlainObject(item)) continue
const itemType = stringValue(item.type)
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
const block = toolUseBlockFromPayload(item)
if (block) out.push({ role: 'assistant', content: [block] })
continue
}
if (itemType === 'function_call_output' || itemType === 'custom_tool_call_output') {
const block = toolResultBlockFromPayload(item)
if (block) out.push({ role: 'tool', content: [block] })
continue
}
if (itemType === 'reasoning') {
// A replayed reasoning item carries no `role`; without this case it
// would fall through below and project as a `user` text message,
// diverging from the backfill's assistant `thinking` shape.
const msg = reasoningMessageFromPayload(item)
if (msg) out.push(msg)
continue
}
const role = stringValue(item.role) ?? 'user'
const blocks = textBlocksFromContent(item.content)
if (blocks.length === 0) continue
out.push({ role, content: blocks })
}
return out
}
/**
* Fan out response `output[]` items so each becomes its own assistant
* message: same per-item shape `responsesInputMessages` produces for
* replayed input items, so turn-1 response rows hash equal to turn-2
* input rows in the kernel's content-hash dedupe.
*
* @param {unknown} responseBody
* @returns {AiGatewayProjectedMessage[]}
*/
function responsesAssistantMessagesFromBody(responseBody) {
if (!isPlainObject(responseBody)) return []
/** @type {AiGatewayProjectedMessage[]} */
const out = []
let sawMessage = false
const output = Array.isArray(responseBody.output) ? responseBody.output : []
for (const item of output) {
if (!isPlainObject(item)) continue
const itemType = stringValue(item.type)
if (itemType === 'function_call' || itemType === 'custom_tool_call') {
const block = toolUseBlockFromPayload(item)
if (block) out.push({ role: 'assistant', content: [block] })
} else if (itemType === 'message' || item.role === 'assistant') {
const blocks = textBlocksFromContent(item.content)
if (blocks.length > 0) {
out.push({ role: 'assistant', content: blocks })
sawMessage = true
}
}
}
if (!sawMessage) {
const outputText = stringValue(responseBody.output_text)
if (outputText) out.unshift({ role: 'assistant', content: [{ type: 'text', text: outputText }] })
}
return out
}
/**
* Stitch streamed Responses assistant messages from SSE events. When
* `response.completed` arrives, its body is preferred (already per-item
* via `responsesAssistantMessagesFromBody`); streamed text and tool_uses
* not represented there are merged in so a truncated completed body
* cannot silently drop captured content.
*
* @param {Array<{ event: string, data: string }>} streamEvents
* @returns {AiGatewayProjectedMessage[]}
*/
function responsesAssistantMessagesFromStream(streamEvents) {
let text = ''
/** @type {string | undefined} */
let responseId
/** @type {Map<string, JsonObject>} */
const toolUsesByCallId = new Map()
/** @type {AiGatewayProjectedMessage[]} */
let completedMessages = []
for (const row of streamEvents) {
const payload = parseEventData(row.data)
if (!isPlainObject(payload)) continue
const type = stringValue(payload.type) ?? stringValue(row.event)
if (type === 'response.output_text.delta' || type === 'response.output_text.annotation.added') {
const delta = stringValue(payload.delta)
if (delta) text += delta
} else if (type === 'response.output_item.done') {
const item = isPlainObject(payload.item) ? payload.item : undefined
if (item) {
const block = toolUseBlockFromPayload(item)
if (block) {
const id = stringValue(block.id)
if (id && !toolUsesByCallId.has(id)) toolUsesByCallId.set(id, block)
}
}
} else if (type === 'response.completed') {
const response = isPlainObject(payload.response) ? payload.response : payload
completedMessages = responsesAssistantMessagesFromBody(response)
const maybeId = stringValue(payload.id) ?? stringValue(/** @type {Record<string, unknown>} */ (response).id)
if (maybeId) responseId = maybeId
} else if (type === 'response.created' && !responseId) {
const maybeId = stringValue(payload.id) ??
stringValue(/** @type {Record<string, unknown>} */ (isPlainObject(payload.response) ? payload.response : {}).id)
if (maybeId) responseId = maybeId
}
}
/** @type {AiGatewayProjectedMessage[]} */
let messages
if (completedMessages.length > 0) {
messages = [...completedMessages]
/** @type {Set<string>} */
const seenCallIds = new Set()
let hasTextMessage = false
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const block of msg.content) {
const blockType = stringValue(block.type)
if (blockType === 'text') hasTextMessage = true
if (blockType === 'tool_use') {
const id = stringValue(block.id)
if (id) seenCallIds.add(id)
}
}
}
if (!hasTextMessage && text) {
messages.unshift({ role: 'assistant', content: [{ type: 'text', text }] })
}
for (const block of toolUsesByCallId.values()) {
const id = stringValue(block.id)
if (id && !seenCallIds.has(id)) messages.push({ role: 'assistant', content: [block] })
}
} else {
messages = []
if (text) messages.push({ role: 'assistant', content: [{ type: 'text', text }] })
for (const block of toolUsesByCallId.values()) messages.push({ role: 'assistant', content: [block] })
}
if (messages.length === 0) return []
if (responseId) {
for (const msg of messages) msg.raw_frame = { ...msg.raw_frame, response_id: responseId }
}
return messages
}
// ---------------------------------------------------------------------
// Usage extraction
// ---------------------------------------------------------------------
/**
* @param {unknown} responseBody
* @returns {Record<string, unknown> | undefined}
*/
function readOpenAiUsage(responseBody) {
if (!isPlainObject(responseBody)) return undefined
const usage = readKey(responseBody, 'usage')
return isPlainObject(usage) ? usage : undefined
}
/**
* Pull usage from terminal Responses streaming events. The public
* Responses API carries usage on `response.completed.response.usage`;
* ChatGPT Codex has used the same event family while sometimes placing
* the response fields directly on the payload, so accept both shapes.
*
* @param {Array<{ event: string, data: string }>} streamEvents
* @returns {Record<string, unknown> | undefined}
*/
function readOpenAiUsageFromResponsesStream(streamEvents) {
/** @type {Record<string, unknown> | undefined} */
let found
for (const row of streamEvents) {
const payload = parseEventData(row.data)
if (!isPlainObject(payload)) continue
const type = stringValue(payload.type) ?? stringValue(row.event)
if (type !== 'response.completed' && type !== 'response.incomplete' && type !== 'response.failed') continue
const response = isPlainObject(payload.response) ? payload.response : payload
const usage = readOpenAiUsage(response)
if (usage) found = usage
}
return found
}
/**
* Normalize OpenAI Chat Completions and Responses usage into the
* `attributes.usage` shape already used by Claude rows. The provider's
* usage object is response-scoped, so callers stamp it onto exactly one
* response assistant message (the LAST one) rather than every fanned-out
* output item. @ref LLP 0035#one-carrier
*
* @param {Record<string, unknown> | undefined} rawUsage
* @returns {JsonObject | undefined}
*/
function openAiUsageAttributes(rawUsage) {
if (!isPlainObject(rawUsage)) return undefined
// OpenAI input_tokens/prompt_tokens are gross: they include the cached
// reads reported in *_tokens_details (@ref LLP 0035#net-input,
// netInputUsage).
const inputDetails = firstPlainObject(
readKey(rawUsage, 'input_tokens_details'),
readKey(rawUsage, 'prompt_tokens_details')
)
const usage = netInputUsage(
numberValue(rawUsage.input_tokens) ?? numberValue(rawUsage.prompt_tokens),
inputDetails ? numberValue(inputDetails.cached_tokens) : undefined
)
copyNumberAlias(rawUsage, usage, 'output_tokens', 'output_tokens')
copyNumberAlias(rawUsage, usage, 'completion_tokens', 'output_tokens')
copyNumberAlias(rawUsage, usage, 'total_tokens', 'total_tokens')
if (inputDetails) {
copyNumberAlias(inputDetails, usage, 'audio_tokens', 'input_audio_tokens')
}
const outputDetails = firstPlainObject(
readKey(rawUsage, 'output_tokens_details'),
readKey(rawUsage, 'completion_tokens_details')
)
if (outputDetails) {
copyNumberAlias(outputDetails, usage, 'reasoning_tokens', 'reasoning_tokens')
copyNumberAlias(outputDetails, usage, 'audio_tokens', 'output_audio_tokens')
copyNumberAlias(outputDetails, usage, 'accepted_prediction_tokens', 'accepted_prediction_tokens')
copyNumberAlias(outputDetails, usage, 'rejected_prediction_tokens', 'rejected_prediction_tokens')
}
return Object.keys(usage).length === 0 ? undefined : { usage }
}
/**
* @param {unknown[]} values
* @returns {Record<string, unknown> | undefined}
*/
function firstPlainObject(...values) {
return values.find(isPlainObject)
}
// ---------------------------------------------------------------------
// Codex header + workspace metadata
// ---------------------------------------------------------------------
// The Codex-owned request headers this file may read. `compatibility_headers` in
// `codex-rs/core/src/responses_metadata.rs` builds exactly four names
// (`x-codex-window-id`, `x-codex-turn-metadata`, `x-codex-parent-thread-id`,
// `x-openai-subagent`), and `readHeader` matches a full name, so any other
// spelling can never match. The other names read in this file (`originator`,
// `user-agent`, `x-client-request-id`, and the response's `x-oai-request-id`)
// are real too, from the shared default client and `codex-api`.
// @ref LLP 0151#real-header-names [constrained-by]: named constants so a
// fictional header name cannot be reintroduced by a typo.
const X_CODEX_TURN_METADATA = 'x-codex-turn-metadata'
const X_CODEX_WINDOW_ID = 'x-codex-window-id'
const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id'
/**
* @param {AiGatewayExchangeInput} input
* @param {string} provider
* @param {string} path
* @param {Record<string, unknown>} reqBody
*/
function resolveCodexContext(input, provider, path, reqBody) {
// @ref LLP 0151#body-is-a-codex-signal [implements]: a Codex-owned body map
// identifies the exchange on its own, so the API-key route's generic
// `/v1/responses` resolves with no Codex header at all.
const transportIsCodex = hasCodexTransportSignal(input, provider, path)
// @ref LLP 0165#flat-pair-corroboration [implements]: naming the client and
// trusting its unnamespaced identity pair are two different questions, and the
// user-agent may only answer the first. Passing the narrow predicate here is
// the whole of that split.
const flatPairIsCorroborated = hasCodexNamespaceSignal(input, provider, path)
// @ref LLP 0151#body-is-authority [implements]: the flat body map first, the
// turn-metadata blob second. Both are projections of one Codex snapshot, so
// they agree whenever both are present; the body is preferred because it is
// the only one present for every request kind.
const clientMetadata = readCodexClientMetadata(reqBody, flatPairIsCorroborated)
if (!transportIsCodex && clientMetadata === undefined) return undefined
const metadata = readCodexTurnMetadata(input, clientMetadata)
const userAgent = readHeader(input.request_headers, 'user-agent')
const client = codexClientFromUserAgent(userAgent)
const inBandCwd = firstString(readRecordedCwd(reqBody), readStringKey(metadata, 'cwd'))
const workspace = selectCodexWorkspace(metadata, inBandCwd)
const workspaceInfo = workspace?.info
const remoteUrls = isPlainObject(workspaceInfo?.associated_remote_urls)
? workspaceInfo.associated_remote_urls
: undefined
const thread_id = firstString(
readStringKey(clientMetadata, 'thread_id'),
readStringKey(metadata, 'thread_id'),
)
const session_id = firstString(
readStringKey(clientMetadata, 'session_id'),
readStringKey(metadata, 'session_id'),
)
const turn_id = firstString(
readStringKey(clientMetadata, 'turn_id'),
readStringKey(metadata, 'turn_id'),
)
const thread_source = readStringKey(metadata, 'thread_source')
// Subagent lineage: the parent thread that spawned this one. Set for subagent
// turns, absent on a root thread. Codex projects it onto all three surfaces
// under two different spellings: `x-codex-parent-thread-id` in the body map
// and as a header, `parent_thread_id` inside the turn-metadata blob.
const parent_thread_id = firstString(
readStringKey(clientMetadata, X_CODEX_PARENT_THREAD_ID),
readStringKey(metadata, 'parent_thread_id'),
readHeader(input.request_headers, X_CODEX_PARENT_THREAD_ID),
)
// Any evidence at all that this turn is a subagent's, in the names Codex's own
// source defines rather than only the ones read above. `codex-rs`
// `CodexResponsesMetadata::compatibility_headers` emits
// `x-codex-parent-thread-id` and `x-openai-subagent` (`review`, `compact`,
// `collab_spawn`, `memory_consolidation`) as DIRECT headers, gated only on
// their own value and NOT on the turn-metadata blob, so they are the one
// lineage signal that survives a turn stating no `thread_id`. That makes them
// the only usable guard on the container fallback below: every field read
// above travels inside `x-codex-turn-metadata`, which also carries `thread_id`,
// so a refusal keyed on those alone can never fire before the thread-id path
// has already returned. Deliberately NOT mirrored into `attributes` or the
// `parent_thread_id` column: widening what a row records is a separate change
// with its own migration story, and this value only has to gate a fallback.
// @ref LLP 0083#container-fallback-gap [implements]: the container fallback is
// refused on lineage Codex states as a header, not only in the metadata blob
const subagent_signal = firstString(
parent_thread_id,
readHeader(input.request_headers, 'x-codex-parent-thread-id'),
readHeader(input.request_headers, 'x-openai-subagent'),
)
const originator = firstString(
readHeader(input.request_headers, 'originator'),
client.entrypoint,
)
const sandbox = readStringKey(metadata, 'sandbox')
const turn_started_at_unix_ms = numberValue(readKey(metadata, 'turn_started_at_unix_ms'))
const window_id = firstString(
readHeader(input.request_headers, X_CODEX_WINDOW_ID),
readStringKey(clientMetadata, X_CODEX_WINDOW_ID),
)
// Which surface stated the identity this row is keyed on. Recorded so a
// future Codex version that stops sending one of them is visible in a query
// instead of showing up as a silent drift in `conversation_id`.
// @ref LLP 0151#lineage-source [implements]: make version drift queryable.
const lineage_source = lineageSource(clientMetadata, metadata)
// The precedence above trusts the two surfaces to agree. Nothing here can
// verify that, so when they do not, say so on the row.
// @ref LLP 0151#lineage-conflict [implements]: the tie-break leaves evidence.
const lineage_conflict = lineageConflict(clientMetadata, metadata)
// Strip any credential userinfo at ingress, before it reaches the first-class
// `git_remote` field or the `attributes.codex.git_origin_url` mirror.
// @ref LLP 0032#remote-redaction
const git_origin_url = redactRemoteUserinfo(readStringKey(remoteUrls, 'origin'))
const git_commit = readStringKey(workspaceInfo, 'latest_git_commit_hash')
const has_changes = typeof workspaceInfo?.has_changes === 'boolean'
? workspaceInfo.has_changes
: undefined
/** @type {JsonObject} */
const attributes = {}
setIfString(attributes, 'thread_id', thread_id)
setIfString(attributes, 'session_id', session_id)
setIfString(attributes, 'parent_thread_id', parent_thread_id)
setIfString(attributes, 'turn_id', turn_id)
setIfString(attributes, 'thread_source', thread_source)
setIfString(attributes, 'originator', originator)
setIfString(attributes, 'window_id', window_id)
setIfString(attributes, 'sandbox', sandbox)
setIfString(attributes, 'lineage_source', lineage_source)
setIfString(attributes, 'lineage_conflict', lineage_conflict)
if (turn_started_at_unix_ms !== undefined) attributes.turn_started_at_unix_ms = turn_started_at_unix_ms
setIfString(attributes, 'workspace', workspace?.path)
setIfString(attributes, 'git_origin_url', git_origin_url)
setIfString(attributes, 'git_commit', git_commit)
if (has_changes !== undefined) attributes.has_changes = has_changes
return {
thread_id,
session_id,
parent_thread_id,
subagent_signal,
turn_id,
thread_source,
// @ref LLP 0083#decision [implements]: an explicit in-band cwd outranks the
// workspace key for the ONE resolved cwd (gate + stamp). `selectCodexWorkspace`
// substitutes the first `workspaces` key when none matches, which is a guess
// about a directory the session may never have run in, so it must not decide
// a `.hypignore` verdict. The key still enriches (`attributes.codex.workspace`,
// git_*) and still supplies the cwd on the subscription route, where the
// request states none and the key is the only in-band source there is.
cwd: firstString(inBandCwd, workspace?.path),
// Set only when the substitution was refused, so the caller can log it
// rather than let a discarded guess vanish.
refused_workspace_cwd: workspace && inBandCwd && !pathsEqual(workspace.path, inBandCwd)
? workspace.path
: undefined,
client_version: client.version,
entrypoint: originator,
sandbox,
// @ref LLP 0032#capture: repo identity for the graph bridge, already in the
// turn metadata (also kept in attributes.codex.* for provenance). Only
// git_remote/head_sha are first-class: they feed Repo/Commit convergence and
// need no repo root. repo_root is deliberately NOT derived from the workspace
// path. Codex exposes no verified git toplevel, and the workspace may be a
// repo *subdir*, which would mis-relativize (or collide) File keys. Codex
// File nodes therefore keep absolute keys in V1. @ref LLP 0032#codex-repo-root
git_remote: git_origin_url,
head_sha: git_commit,
attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
}
}
/**
* The transport signals that spell a name out of Codex's own proprietary
* vocabulary: the ChatGPT upstream, the `/backend-api/codex/` route namespace,
* or an `x-codex-*` compatibility header. Producing any of them takes knowledge
* of a route or header name Codex never published as an interface, which is a
* meaningfully higher bar than copying a product string.
*
* This, and NOT the looser `hasCodexTransportSignal`, is what corroborates a
* `client_metadata` map carrying no Codex-owned key of its own.
* @ref LLP 0165#flat-pair-corroboration [implements]: the strict half of the
* split, the only half a partition key is allowed to rest on.
*
* @param {AiGatewayExchangeInput} input
* @param {string} provider
* @param {string} path
*/
function hasCodexNamespaceSignal(input, provider, path) {
if (provider === 'chatgpt') return true
if (isCodexNamespacePath(path)) return true
if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true
return Boolean(readHeader(input.request_headers, X_CODEX_WINDOW_ID))
}
/**
* Whether the transport alone identifies this exchange as Codex, before any part
* of the request body is consulted: any namespace signal above, or a Codex
* user-agent product.
*
* Deliberately the loose half of the split. The user-agent is a product-name
* convention, not a namespace: any process on the user's own machine can set
* `codex_cli_rs/...` without knowing anything Codex-specific. That is enough to
* label a row's client, which is a description, and not enough to promote an
* unnamespaced `client_metadata` pair into `conversation_id` and the LLP 0030
* partition key, which is an identity.
* @ref LLP 0165#flat-pair-corroboration [implements]: the loose half, which may
* name the client and nothing more.
*
* @param {AiGatewayExchangeInput} input
* @param {string} provider
* @param {string} path
*/
function hasCodexTransportSignal(input, provider, path) {
if (hasCodexNamespaceSignal(input, provider, path)) return true
const userAgent = readHeader(input.request_headers, 'user-agent')
return codexClientFromUserAgent(userAgent).entrypoint !== undefined
}
/**
* The request body's flat `client_metadata` map: the surface Codex fills for
* every Responses request kind, so the one lineage surface always present.
*
* Declines a map carrying no Codex-owned key, so a `client_metadata` an
* unrelated client happens to send cannot masquerade as Codex lineage. An
* `x-codex-` prefixed key is Codex-exclusive and is accepted on its own; Codex
* writes `x-codex-installation-id` and `x-codex-window-id` into this map on
* every request, so that branch alone covers every request real Codex makes.
*
* The flat `session_id` + `thread_id` pair is NOT Codex-exclusive: those are
* ordinary names any agent framework may put in a `client_metadata` map, and the
* matched path set includes the generic `/v1/responses` and
* `/v1/chat/completions`. Honouring the pair on its own would therefore let an
* unrelated client be stamped `client_name: 'codex'` and dictate this row's
* `conversation_id` and `session_id`, which is the same defect class as the
* bare `thread-id` header read this document removed, only through the body. So
* the pair is trusted only once `corroborated` says a Codex-namespaced transport
* signal already identified the exchange, where it adds lineage detail to a
* client that is already known rather than naming the client. A Codex-shaped
* user-agent is NOT such a signal: see `hasCodexNamespaceSignal`.
* @ref LLP 0151#body-is-authority: the always-present lineage surface.
* @ref LLP 0151#body-is-a-codex-signal [constrained-by]: which keys of the map
* are evidence of Codex, and which only carry detail.
* @ref LLP 0165#flat-pair-corroboration [constrained-by]: which transport signals
* may corroborate the pair.
*
* @param {unknown} reqBody
* @param {boolean} corroborated Whether a Codex-namespaced transport signal
* (the ChatGPT upstream, the Codex route namespace, or an `x-codex-*` header)
* already identified this exchange as Codex.
* @returns {Record<string, unknown> | undefined}
*/
function readCodexClientMetadata(reqBody, corroborated) {
const clientMetadata = readKey(reqBody, 'client_metadata')
if (!isPlainObject(clientMetadata)) return undefined
const hasCodexKey = Object.keys(clientMetadata)
.some((key) => key.toLowerCase().startsWith('x-codex-'))
if (hasCodexKey) return clientMetadata
if (!corroborated) return undefined
const hasFlatIdentity = readStringKey(clientMetadata, 'thread_id') !== undefined &&
readStringKey(clientMetadata, 'session_id') !== undefined
return hasFlatIdentity ? clientMetadata : undefined
}
/**
* The turn-metadata blob. Codex transports it twice per HTTP request: as the
* `x-codex-turn-metadata` header, and as the same-named string entry of the
* body's `client_metadata` map. The header is read first so already-recorded
* rows keep their exact identity (@ref LLP 0151#row-identity); the body entry