pi-openai-codex-compat 0.0.1 → 0.0.3

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.
@@ -34,8 +34,15 @@ import {
34
34
  type JsonRecord,
35
35
  type ResponsesItem,
36
36
  } from "./codex-protocol.ts";
37
+ import { codexCacheKey } from "./codex-cache-key.ts";
37
38
  import { processCodexStream } from "./codex-stream.ts";
38
- import { CodexTransport } from "./codex-transport.ts";
39
+ import {
40
+ CodexTransport,
41
+ validateCodexAuthentication,
42
+ type CodexContinuationHandle,
43
+ type CodexTransportDiagnostic,
44
+ type CodexWebSocketResponseHandle,
45
+ } from "./codex-transport.ts";
39
46
  import type { CodexCompatConfig, ImageDetail } from "./config.ts";
40
47
  import { nativeResponseData, NATIVE_RESPONSE_ENTRY_TYPE } from "./native-history.ts";
41
48
  import {
@@ -49,10 +56,10 @@ import {
49
56
  createGrammarToolInputProperties,
50
57
  type ResponsesItem as SerializedResponsesItem,
51
58
  } from "./vendor/pi-ai/openai-responses-serialization.ts";
59
+ import { formatProviderError } from "./provider-error.ts";
52
60
 
53
61
  const CODEX_PROVIDER = "openai-codex";
54
62
  const CODEX_API = "openai-codex-responses";
55
- const CHECKPOINT_STATUS_ID = "openai-codex-compat-compaction";
56
63
  const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
57
64
 
58
65
  type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
@@ -78,7 +85,6 @@ type RuntimeScope = {
78
85
  config: CodexCompatConfig;
79
86
  hasUI: boolean;
80
87
  notify(message: string, level: "info" | "warning" | "error"): void;
81
- setStatus(message: string | undefined): void;
82
88
  };
83
89
 
84
90
  type RequestTemplate = {
@@ -94,11 +100,6 @@ type CodexCompat = {
94
100
  supportsOpenAIGrammarTools?: boolean;
95
101
  };
96
102
 
97
- function clampPromptCacheKey(key: string | undefined): string | undefined {
98
- if (key === undefined) return undefined;
99
- return Array.from(key).slice(0, 64).join("");
100
- }
101
-
102
103
  function markerSummary(): string {
103
104
  return `OpenAI Codex remote compaction checkpoint (${randomUUID()}).`;
104
105
  }
@@ -153,7 +154,6 @@ function nativeOverrideRequired(
153
154
  function captureRawEvents(
154
155
  events: AsyncIterable<JsonRecord>,
155
156
  items: ResponsesItem[],
156
- metadata: { serviceTier?: string },
157
157
  ): AsyncIterable<JsonRecord> {
158
158
  return {
159
159
  async *[Symbol.asyncIterator]() {
@@ -171,12 +171,23 @@ function captureRawEvents(
171
171
  items.splice(0, items.length, ...terminalItems.map((item) => structuredClone(item)));
172
172
  }
173
173
  }
174
- if (
175
- (event.type === "response.completed" || event.type === "response.incomplete") &&
176
- isObject(event.response) &&
177
- typeof event.response.service_tier === "string"
178
- ) {
179
- metadata.serviceTier = event.response.service_tier;
174
+ yield event;
175
+ }
176
+ },
177
+ };
178
+ }
179
+
180
+ function startOnFirstEvent(
181
+ events: AsyncIterable<JsonRecord>,
182
+ onStart: () => void,
183
+ ): AsyncIterable<JsonRecord> {
184
+ return {
185
+ async *[Symbol.asyncIterator]() {
186
+ let started = false;
187
+ for await (const event of events) {
188
+ if (!started) {
189
+ started = true;
190
+ onStart();
180
191
  }
181
192
  yield event;
182
193
  }
@@ -184,6 +195,13 @@ function captureRawEvents(
184
195
  };
185
196
  }
186
197
 
198
+ function clearStreamingScratchState(message: AssistantMessage): void {
199
+ for (const block of message.content) {
200
+ delete (block as { partialJson?: string }).partialJson;
201
+ delete (block as { customInput?: unknown }).customInput;
202
+ }
203
+ }
204
+
187
205
  function updateInput(payload: JsonRecord, input: readonly ResponsesItem[]): JsonRecord {
188
206
  const result: JsonRecord = {
189
207
  ...payload,
@@ -194,14 +212,15 @@ function updateInput(payload: JsonRecord, input: readonly ResponsesItem[]): Json
194
212
  return result;
195
213
  }
196
214
 
197
- function successfulStopReason(
215
+ function assertSuccessfulOutput(
198
216
  message: AssistantMessage,
199
- ): message is AssistantMessage & { stopReason: "stop" | "length" | "toolUse" } {
200
- return (
201
- message.stopReason === "stop" ||
202
- message.stopReason === "length" ||
203
- message.stopReason === "toolUse"
204
- );
217
+ ): asserts message is AssistantMessage & { stopReason: "stop" | "length" | "toolUse" } {
218
+ if (message.stopReason === "pending") {
219
+ throw new Error("Codex stream ended without a stop reason");
220
+ }
221
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
222
+ throw new Error(message.errorMessage || "An unknown error occurred");
223
+ }
205
224
  }
206
225
 
207
226
  function applyServiceTierPricing(
@@ -308,7 +327,6 @@ export class CodexProviderRuntime {
308
327
  config: this.resolveConfig(ctx),
309
328
  hasUI: ctx.hasUI,
310
329
  notify: (message, level) => ctx.ui.notify(message, level),
311
- setStatus: (message) => ctx.ui.setStatus(CHECKPOINT_STATUS_ID, message),
312
330
  });
313
331
  }
314
332
 
@@ -319,7 +337,10 @@ export class CodexProviderRuntime {
319
337
  this.transport.close(sessionId);
320
338
  }
321
339
 
322
- private async acquireRequest(sessionId: string | undefined): Promise<() => void> {
340
+ private async acquireRequest(
341
+ sessionId: string | undefined,
342
+ signal?: AbortSignal,
343
+ ): Promise<() => void> {
323
344
  if (!sessionId) return () => {};
324
345
  const previous = this.requestTails.get(sessionId) ?? Promise.resolve();
325
346
  let releaseCurrent!: () => void;
@@ -327,11 +348,44 @@ export class CodexProviderRuntime {
327
348
  releaseCurrent = resolve;
328
349
  });
329
350
  this.requestTails.set(sessionId, current);
330
- await previous;
331
- return () => {
351
+ let released = false;
352
+ const release = () => {
353
+ if (released) return;
354
+ released = true;
332
355
  releaseCurrent();
333
356
  if (this.requestTails.get(sessionId) === current) this.requestTails.delete(sessionId);
334
357
  };
358
+
359
+ if (signal?.aborted) {
360
+ void previous.then(release);
361
+ throw new Error("Request was aborted");
362
+ }
363
+
364
+ let onAbort: (() => void) | undefined;
365
+ try {
366
+ await Promise.race([
367
+ previous,
368
+ ...(signal
369
+ ? [
370
+ new Promise<never>((_resolve, reject) => {
371
+ onAbort = () => reject(new Error("Request was aborted"));
372
+ signal.addEventListener("abort", onAbort, { once: true });
373
+ }),
374
+ ]
375
+ : []),
376
+ ]);
377
+ } catch (error) {
378
+ void previous.then(release);
379
+ throw error;
380
+ } finally {
381
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
382
+ }
383
+
384
+ if (signal?.aborted) {
385
+ release();
386
+ throw new Error("Request was aborted");
387
+ }
388
+ return release;
335
389
  }
336
390
 
337
391
  createProvider(base: Provider): Provider {
@@ -381,7 +435,8 @@ export class CodexProviderRuntime {
381
435
  model: Model<any>,
382
436
  context: Context,
383
437
  options: OpenAICodexResponsesOptions,
384
- sessionId: string | undefined,
438
+ runtimeSessionId: string | undefined,
439
+ cacheSessionId: string | undefined,
385
440
  grammarToolInputProperties: GrammarToolInputProperties,
386
441
  ): JsonRecord {
387
442
  const compat = model.compat as CodexCompat | undefined;
@@ -393,12 +448,12 @@ export class CodexProviderRuntime {
393
448
  instructions: context.systemPrompt || "You are a helpful assistant.",
394
449
  input: this.wireHistory(
395
450
  model,
396
- Object.assign({}, context, { sessionId }),
451
+ Object.assign({}, context, { sessionId: runtimeSessionId }),
397
452
  grammarToolInputProperties,
398
453
  ),
399
454
  text: { verbosity: options.textVerbosity ?? "low" },
400
455
  include: ["reasoning.encrypted_content"],
401
- prompt_cache_key: sessionId,
456
+ prompt_cache_key: cacheSessionId,
402
457
  tool_choice: options.toolChoice ?? "auto",
403
458
  parallel_tool_calls: true,
404
459
  };
@@ -439,21 +494,38 @@ export class CodexProviderRuntime {
439
494
  }): Promise<{ checkpoint: CheckpointData; usage?: Usage }> {
440
495
  const sessionId = options.requestOptions.sessionId;
441
496
  if (!sessionId) throw new Error("Codex compaction requires a Pi session id.");
497
+ const accountId = validateCodexAuthentication(options.model, options.requestOptions.apiKey);
442
498
  const payload = remoteCompactionPayload({
443
499
  template: options.template,
444
500
  modelId: options.model.id,
445
501
  history: options.history,
446
502
  instructions: options.instructions,
447
- sessionId,
503
+ sessionId:
504
+ options.requestOptions.cacheRetention === "none" ? undefined : codexCacheKey(sessionId),
448
505
  priority: options.priority,
449
506
  });
450
507
  const transformed = await options.requestOptions.onPayload?.(payload, options.model);
451
- const request = isObject(transformed) ? transformed : payload;
452
- const compacted = await collectRemoteCompaction(
453
- this.transport.request(options.model, request, options.requestOptions),
454
- options.model,
455
- options.priority,
456
- );
508
+ const request = transformed === undefined ? payload : (transformed as JsonRecord);
509
+ let webSocketResponseHandle: CodexWebSocketResponseHandle | undefined;
510
+ let compacted: Awaited<ReturnType<typeof collectRemoteCompaction>>;
511
+ try {
512
+ compacted = await collectRemoteCompaction(
513
+ this.transport.request(options.model, request, {
514
+ ...options.requestOptions,
515
+ accountId,
516
+ onWebSocketResponseHandle(handle) {
517
+ webSocketResponseHandle = handle;
518
+ },
519
+ }),
520
+ options.model,
521
+ options.priority,
522
+ );
523
+ } catch (error) {
524
+ if (!options.requestOptions.signal?.aborted) {
525
+ webSocketResponseHandle?.failParsing(error);
526
+ }
527
+ throw error;
528
+ }
457
529
  return {
458
530
  checkpoint: checkpointData(
459
531
  options.model.id,
@@ -515,44 +587,39 @@ export class CodexProviderRuntime {
515
587
  return body;
516
588
  }
517
589
 
518
- scope.setStatus("Codex compacting…");
519
- try {
520
- const compacted = await this.performCompaction({
521
- model,
522
- requestOptions: options,
523
- history: split.history,
524
- postCompactionTail: split.tail,
525
- template: withoutConversationInput(body),
526
- instructions:
527
- typeof body.instructions === "string"
528
- ? body.instructions
529
- : context.systemPrompt || "You are a helpful assistant.",
530
- grammarToolInputProperties,
531
- priority: scope.config.fastMode,
532
- });
533
- const firstKeptEntryId = userEntryAfterLastSampled(branch)?.id ?? scope.manager.getLeafId();
534
- if (!firstKeptEntryId || typeof scope.manager.appendCompaction !== "function") {
535
- throw new Error("Pi's mutable SessionManager is unavailable for percentage compaction.");
536
- }
537
- if (scope.manager.getLeafId() !== scope.leafId) {
538
- throw new Error("Pi's active session branch changed while Codex was compacting.");
539
- }
540
- scope.manager.appendCompaction(
541
- markerSummary(),
542
- firstKeptEntryId,
543
- scope.contextTokens ?? 0,
544
- compacted.checkpoint,
545
- true,
546
- compacted.usage,
547
- );
548
- scope.notify(
549
- `OpenAI Codex context compacted at ${scope.contextPercent.toFixed(1)}% and will continue.`,
550
- "info",
551
- );
552
- return updateInput(body, compacted.checkpoint.history);
553
- } finally {
554
- scope.setStatus(undefined);
590
+ const compacted = await this.performCompaction({
591
+ model,
592
+ requestOptions: options,
593
+ history: split.history,
594
+ postCompactionTail: split.tail,
595
+ template: withoutConversationInput(body),
596
+ instructions:
597
+ typeof body.instructions === "string"
598
+ ? body.instructions
599
+ : context.systemPrompt || "You are a helpful assistant.",
600
+ grammarToolInputProperties,
601
+ priority: scope.config.fastMode,
602
+ });
603
+ const firstKeptEntryId = userEntryAfterLastSampled(branch)?.id ?? scope.manager.getLeafId();
604
+ if (!firstKeptEntryId || typeof scope.manager.appendCompaction !== "function") {
605
+ throw new Error("Pi's mutable SessionManager is unavailable for percentage compaction.");
555
606
  }
607
+ if (scope.manager.getLeafId() !== scope.leafId) {
608
+ throw new Error("Pi's active session branch changed while Codex was compacting.");
609
+ }
610
+ scope.manager.appendCompaction(
611
+ markerSummary(),
612
+ firstKeptEntryId,
613
+ scope.contextTokens ?? 0,
614
+ compacted.checkpoint,
615
+ true,
616
+ compacted.usage,
617
+ );
618
+ scope.notify(
619
+ `OpenAI Codex context compacted at ${scope.contextPercent.toFixed(1)}% and will continue.`,
620
+ "info",
621
+ );
622
+ return updateInput(body, compacted.checkpoint.history);
556
623
  }
557
624
 
558
625
  stream(
@@ -581,9 +648,12 @@ export class CodexProviderRuntime {
581
648
  timestamp: Date.now(),
582
649
  };
583
650
  const runtimeSessionId = requestOptions.sessionId;
584
- const releaseRequest = await this.acquireRequest(runtimeSessionId);
651
+ let releaseRequest = () => {};
585
652
  try {
586
- const cacheSessionId = clampPromptCacheKey(runtimeSessionId);
653
+ const accountId = validateCodexAuthentication(model, requestOptions.apiKey);
654
+ releaseRequest = await this.acquireRequest(runtimeSessionId, requestOptions.signal);
655
+ const cacheSessionId =
656
+ requestOptions.cacheRetention === "none" ? undefined : codexCacheKey(runtimeSessionId);
587
657
  const grammarToolInputProperties = createGrammarToolInputProperties(
588
658
  context.tools,
589
659
  (model.compat as CodexCompat | undefined)?.supportsOpenAIGrammarTools ?? false,
@@ -592,11 +662,12 @@ export class CodexProviderRuntime {
592
662
  model,
593
663
  context,
594
664
  requestOptions,
665
+ runtimeSessionId,
595
666
  cacheSessionId,
596
667
  grammarToolInputProperties,
597
668
  );
598
669
  const transformed = await requestOptions.onPayload?.(body, model);
599
- if (isObject(transformed)) body = transformed;
670
+ if (transformed !== undefined) body = transformed as JsonRecord;
600
671
  if (runtimeSessionId) {
601
672
  this.templates.set(runtimeSessionId, {
602
673
  modelId: model.id,
@@ -614,28 +685,60 @@ export class CodexProviderRuntime {
614
685
  );
615
686
 
616
687
  const rawItems: ResponsesItem[] = [];
617
- const responseMetadata: { serviceTier?: string } = {};
618
- stream.push({ type: "start", partial: output });
619
- await processCodexStream(
620
- captureRawEvents(
621
- this.transport.request(model, body, requestOptions),
622
- rawItems,
623
- responseMetadata,
624
- ),
625
- output,
626
- stream,
627
- model,
628
- grammarToolInputProperties,
629
- );
630
- if (!successfulStopReason(output)) {
631
- throw new Error(output.errorMessage || "Codex stream ended without a successful stop.");
688
+ let continuationHandle: CodexContinuationHandle | undefined;
689
+ let webSocketResponseHandle: CodexWebSocketResponseHandle | undefined;
690
+ let startEmitted = false;
691
+ const emitStart = () => {
692
+ if (startEmitted) return;
693
+ startEmitted = true;
694
+ stream.push({ type: "start", partial: output });
695
+ };
696
+ const transportRequestOptions = {
697
+ ...requestOptions,
698
+ accountId,
699
+ onContinuationReady(handle: CodexContinuationHandle) {
700
+ continuationHandle = handle;
701
+ },
702
+ onWebSocketResponseHandle(handle: CodexWebSocketResponseHandle) {
703
+ webSocketResponseHandle = handle;
704
+ },
705
+ onTransportStart: emitStart,
706
+ onTransportDiagnostic(diagnostic: CodexTransportDiagnostic) {
707
+ output.diagnostics = [...(output.diagnostics ?? []), diagnostic];
708
+ },
709
+ };
710
+ try {
711
+ await processCodexStream(
712
+ startOnFirstEvent(
713
+ captureRawEvents(
714
+ this.transport.request(model, body, transportRequestOptions),
715
+ rawItems,
716
+ ),
717
+ emitStart,
718
+ ),
719
+ output,
720
+ stream,
721
+ model,
722
+ grammarToolInputProperties,
723
+ {
724
+ applyServiceTierPricing(usage, responseServiceTier) {
725
+ applyServiceTierPricing(usage, model, body.service_tier, responseServiceTier);
726
+ },
727
+ },
728
+ );
729
+ } catch (error) {
730
+ if (!requestOptions.signal?.aborted) {
731
+ webSocketResponseHandle?.failParsing(error);
732
+ }
733
+ throw error;
734
+ }
735
+ if (requestOptions.signal?.aborted) throw new Error("Request was aborted");
736
+ try {
737
+ assertSuccessfulOutput(output);
738
+ } catch (error) {
739
+ webSocketResponseHandle?.discard();
740
+ throw error;
632
741
  }
633
- applyServiceTierPricing(
634
- output.usage,
635
- model,
636
- body.service_tier,
637
- responseMetadata.serviceTier,
638
- );
639
742
 
640
743
  const compat = model.compat as CodexCompat | undefined;
641
744
  const canonicalContext: Context = {
@@ -667,19 +770,26 @@ export class CodexProviderRuntime {
667
770
  (item) =>
668
771
  item["type"] !== "function_call_output" && item["type"] !== "custom_tool_call_output",
669
772
  );
670
- if (rawItems.length > 0 && nativeOverrideRequired(rawItems, canonicalItems)) {
773
+ const persistNativeItems =
774
+ rawItems.length > 0 && nativeOverrideRequired(rawItems, canonicalItems);
775
+ if (persistNativeItems) {
671
776
  if (!output.responseId) throw new Error("Codex response is missing a response id.");
672
777
  this.pi.appendEntry(
673
778
  NATIVE_RESPONSE_ENTRY_TYPE,
674
779
  nativeResponseData(model.id, output.responseId, rawItems),
675
780
  );
676
781
  }
782
+ const readyContinuation = continuationHandle;
783
+ if (readyContinuation && readyContinuation.responseId === output.responseId) {
784
+ readyContinuation.replaceResponseItems(persistNativeItems ? rawItems : canonicalItems);
785
+ }
677
786
 
678
787
  stream.push({ type: "done", reason: output.stopReason, message: output });
679
788
  stream.end();
680
789
  } catch (error) {
790
+ clearStreamingScratchState(output);
681
791
  output.stopReason = requestOptions.signal?.aborted ? "aborted" : "error";
682
- output.errorMessage = error instanceof Error ? error.message : String(error);
792
+ output.errorMessage = formatProviderError(error);
683
793
  stream.push({ type: "error", reason: output.stopReason, error: output });
684
794
  stream.end();
685
795
  } finally {
@@ -694,6 +804,7 @@ export class CodexProviderRuntime {
694
804
  context: Context,
695
805
  options?: SimpleStreamOptions,
696
806
  ): AssistantMessageEventStream {
807
+ if (!options?.apiKey) throw new Error(`No API key for provider: ${model.provider}`);
697
808
  const effort = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
698
809
  return this.stream(model, context, {
699
810
  ...options,
@@ -705,7 +816,7 @@ export class CodexProviderRuntime {
705
816
  return this.templates.get(sessionId);
706
817
  }
707
818
 
708
- compact(options: {
819
+ async compact(options: {
709
820
  model: Model<any>;
710
821
  requestOptions: OpenAICodexResponsesOptions;
711
822
  history: ResponsesItem[];
@@ -714,13 +825,16 @@ export class CodexProviderRuntime {
714
825
  template: JsonRecord;
715
826
  priority: boolean;
716
827
  }): Promise<{ checkpoint: CheckpointData; usage?: Usage }> {
717
- return this.acquireRequest(options.requestOptions.sessionId).then(async (release) => {
718
- try {
719
- return await this.performCompaction(options);
720
- } finally {
721
- release();
722
- }
723
- });
828
+ validateCodexAuthentication(options.model, options.requestOptions.apiKey);
829
+ const release = await this.acquireRequest(
830
+ options.requestOptions.sessionId,
831
+ options.requestOptions.signal,
832
+ );
833
+ try {
834
+ return await this.performCompaction(options);
835
+ } finally {
836
+ release();
837
+ }
724
838
  }
725
839
  }
726
840