pi-openai-codex-compat 0.0.2 → 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.
@@ -8,6 +8,7 @@ import {
8
8
  type TextSignatureV1,
9
9
  type ThinkingContent,
10
10
  type ToolCall,
11
+ type Usage,
11
12
  } from "@earendil-works/pi-ai";
12
13
  import { isObject, type JsonRecord } from "./codex-protocol.ts";
13
14
  import { CODEX_NAMESPACED_TOOL_NAMES, namespacedToolCallName } from "./namespaced-tools.ts";
@@ -38,6 +39,27 @@ type OutputSlot =
38
39
 
39
40
  type ToolCallSlot = Extract<OutputSlot, { type: "toolCall" }>;
40
41
 
42
+ type ProcessCodexStreamOptions = {
43
+ applyServiceTierPricing?(usage: Usage, responseServiceTier: string | undefined): void;
44
+ };
45
+
46
+ type CodexResponseStatus =
47
+ | "completed"
48
+ | "incomplete"
49
+ | "failed"
50
+ | "cancelled"
51
+ | "queued"
52
+ | "in_progress";
53
+
54
+ const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
55
+ "completed",
56
+ "incomplete",
57
+ "failed",
58
+ "cancelled",
59
+ "queued",
60
+ "in_progress",
61
+ ]);
62
+
41
63
  function outputIndex(event: JsonRecord): number {
42
64
  return typeof event["output_index"] === "number" ? event["output_index"] : 0;
43
65
  }
@@ -60,7 +82,7 @@ function appendGrammarDelta(
60
82
  ): string | undefined {
61
83
  if (buffer.closed) {
62
84
  if (close && nextInput === buffer.input) return undefined;
63
- throw new Error(`grammar tool input for property "${property}" changed after closure`);
85
+ throw new Error(`grammar tool input for property "${property}" changed after it was closed`);
64
86
  }
65
87
  if (!nextInput.startsWith(buffer.input)) {
66
88
  throw new Error(`grammar tool input for property "${property}" changed non-monotonically`);
@@ -128,13 +150,35 @@ function reasoningText(item: JsonRecord): string {
128
150
  .join("\n\n")
129
151
  : "";
130
152
  if (summary) return summary;
131
- return itemContentText(item);
153
+ return Array.isArray(item.content)
154
+ ? item.content
155
+ .filter(isObject)
156
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
157
+ .join("\n\n")
158
+ : "";
132
159
  }
133
160
 
134
- function mapStopReason(status: unknown): AssistantMessage["stopReason"] {
135
- if (status === "incomplete") return "length";
136
- if (status === "failed" || status === "cancelled") return "error";
137
- return "stop";
161
+ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined {
162
+ return typeof status === "string" && CODEX_RESPONSE_STATUSES.has(status as CodexResponseStatus)
163
+ ? (status as CodexResponseStatus)
164
+ : undefined;
165
+ }
166
+
167
+ function mapStopReason(
168
+ status: CodexResponseStatus | undefined,
169
+ incompleteReason: string | undefined,
170
+ ): { stopReason: AssistantMessage["stopReason"]; errorMessage?: string } {
171
+ if (status === "incomplete") {
172
+ if (incompleteReason === "max_output_tokens") return { stopReason: "length" };
173
+ return {
174
+ stopReason: "error",
175
+ errorMessage: incompleteReason
176
+ ? `Response incomplete: ${incompleteReason}`
177
+ : "Response incomplete without a provider reason",
178
+ };
179
+ }
180
+ if (status === "failed" || status === "cancelled") return { stopReason: "error" };
181
+ return { stopReason: "stop" };
138
182
  }
139
183
 
140
184
  export async function processCodexStream(
@@ -143,10 +187,16 @@ export async function processCodexStream(
143
187
  stream: AssistantMessageEventStream,
144
188
  model: Model<any>,
145
189
  grammarToolInputProperties: ReadonlyMap<string, string>,
190
+ options?: ProcessCodexStreamOptions,
146
191
  ): Promise<void> {
147
192
  let terminal = false;
148
193
  const slots = new Map<number, OutputSlot>();
149
194
  const reasoningById = new Map<string, ThinkingContent>();
195
+ const applyMessagePhaseStopReason = (item: JsonRecord): void => {
196
+ if (item.type === "message" && item["phase"] === "final_answer") {
197
+ output.stopReason = "stop";
198
+ }
199
+ };
150
200
 
151
201
  const getSlot = <TType extends OutputSlot["type"]>(
152
202
  index: number,
@@ -180,6 +230,7 @@ export async function processCodexStream(
180
230
  return slot;
181
231
  }
182
232
  if (item.type === "message") {
233
+ applyMessagePhaseStopReason(item);
183
234
  const block: TextContent = { type: "text", text: "" };
184
235
  output.content.push(block);
185
236
  const slot = {
@@ -270,12 +321,15 @@ export async function processCodexStream(
270
321
  typeof outputDetails?.["reasoning_tokens"] === "number"
271
322
  ? outputDetails["reasoning_tokens"]
272
323
  : 0,
273
- totalTokens:
274
- typeof usage.total_tokens === "number" ? usage.total_tokens : input + outputTokens,
324
+ totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens || 0 : 0,
275
325
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
276
326
  };
277
- calculateCost(model, output.usage);
278
327
  }
328
+ calculateCost(model, output.usage);
329
+ options?.applyServiceTierPricing?.(
330
+ output.usage,
331
+ typeof response.service_tier === "string" ? response.service_tier : undefined,
332
+ );
279
333
  for (const item of responseItems(response["output"])) {
280
334
  if (item.type !== "reasoning" || typeof item.id !== "string") continue;
281
335
  const block = reasoningById.get(item.id);
@@ -288,8 +342,20 @@ export async function processCodexStream(
288
342
  });
289
343
  }
290
344
  }
291
- if (typeof response["status"] === "string") output.rawStopReason = response["status"];
292
- output.stopReason = mapStopReason(response["status"]);
345
+ const status = normalizeCodexStatus(response["status"]);
346
+ const incompleteDetails = isObject(response["incomplete_details"])
347
+ ? response["incomplete_details"]
348
+ : undefined;
349
+ const incompleteReason =
350
+ typeof incompleteDetails?.["reason"] === "string" ? incompleteDetails["reason"] : undefined;
351
+ const rawStopReason =
352
+ status === "incomplete" && incompleteReason ? `${status}.${incompleteReason}` : status;
353
+ if (rawStopReason === undefined) delete output.rawStopReason;
354
+ else output.rawStopReason = rawStopReason;
355
+ const mappedStop = mapStopReason(status, incompleteReason);
356
+ output.stopReason = mappedStop.stopReason;
357
+ if (mappedStop.errorMessage === undefined) delete output.errorMessage;
358
+ else output.errorMessage = mappedStop.errorMessage;
293
359
  if (output.stopReason === "stop" && output.content.some((block) => block.type === "toolCall")) {
294
360
  output.stopReason = "toolUse";
295
361
  }
@@ -353,8 +419,10 @@ export async function processCodexStream(
353
419
  const previous = slot.block.partialJson;
354
420
  slot.block.partialJson = event.arguments;
355
421
  slot.block.arguments = parseStreamingJson(event.arguments);
356
- if (event.arguments.startsWith(previous))
357
- pushToolDelta(slot, event.arguments.slice(previous.length));
422
+ if (event.arguments.startsWith(previous)) {
423
+ const delta = event.arguments.slice(previous.length);
424
+ if (delta.length > 0) pushToolDelta(slot, delta);
425
+ }
358
426
  } else if (event.type === "response.custom_tool_call_input.delta") {
359
427
  const slot = getSlot(index, "toolCall");
360
428
  if (!slot || typeof event["delta"] !== "string") continue;
@@ -368,6 +436,7 @@ export async function processCodexStream(
368
436
  pushToolDelta(slot, appendCustomInput(slot.block, event["input"], true));
369
437
  } else if (event.type === "response.output_item.done" && isObject(event.item)) {
370
438
  const item = event.item;
439
+ applyMessagePhaseStopReason(item);
371
440
  const slot = slotFor(index, item);
372
441
  if (item.type === "reasoning" && slot?.type === "thinking") {
373
442
  slot.block.thinking = reasoningText(item) || slot.block.thinking;
@@ -440,5 +509,7 @@ export async function processCodexStream(
440
509
  }
441
510
  }
442
511
 
443
- if (!terminal) throw new Error("Codex stream ended before a terminal response event");
512
+ if (!terminal) {
513
+ throw new Error("OpenAI Responses stream ended before a terminal response event");
514
+ }
444
515
  }