Spaces:
Paused
Paused
icebear icebear0828 commited on
fix: handle new Responses SSE lifecycle events (#137)
Browse files* fix: handle new Responses SSE lifecycle events (output_item.added message, content_part)
Upstream Codex API now wraps text responses in message/content_part
lifecycle events. Our parser only recognized output_item.added with
item.type=function_call, causing unknown event log spam.
- Widen output_item.added to accept any item.type (message, etc.)
- Add content_part.added and content_part.done as recognized no-op events
- Update extractor to handle optional call_id/name on non-function_call items
* chore: remove loose index signature from CodexOutputItemAddedEvent.item
---------
Co-authored-by: icebear0828 <icebear0828@users.noreply.github.com>
- CHANGELOG.md +1 -0
- src/translation/codex-event-extractor.ts +4 -2
- src/types/__tests__/codex-events.test.ts +75 -0
- src/types/codex-events.ts +42 -16
CHANGELOG.md
CHANGED
|
@@ -8,6 +8,7 @@
|
|
| 8 |
|
| 9 |
### Fixed
|
| 10 |
|
|
|
|
| 11 |
- 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
|
| 12 |
- 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
|
| 13 |
- 同一 Team 的多个账号因共享 `chatgpt_account_id` 只能添加一个的问题(#126)
|
|
|
|
| 8 |
|
| 9 |
### Fixed
|
| 10 |
|
| 11 |
+
- Responses SSE 新事件(`response.output_item.added` with `item.type=message`、`response.content_part.added/done`)未被识别,导致 `[CodexEvents] Unknown event` 日志刷屏
|
| 12 |
- 新模型(如 `gpt-5.4-mini`)无法被动态发现的问题
|
| 13 |
- 移除 `isCodexCompatibleId()` 白名单过滤,信任后端 `/codex/models` 返回
|
| 14 |
- 同一 Team 的多个账号因共享 `chatgpt_account_id` 只能添加一个的问题(#126)
|
src/translation/codex-event-extractor.ts
CHANGED
|
@@ -92,7 +92,7 @@ export async function* iterateCodexEvents(
|
|
| 92 |
break;
|
| 93 |
|
| 94 |
case "response.output_item.added":
|
| 95 |
-
if (typed.item.type === "function_call") {
|
| 96 |
// Register item_id → call_id mapping
|
| 97 |
itemIdToCallInfo.set(typed.item.id, {
|
| 98 |
callId: typed.item.call_id,
|
|
@@ -128,7 +128,9 @@ export async function* iterateCodexEvents(
|
|
| 128 |
}
|
| 129 |
|
| 130 |
case "response.output_item.done":
|
| 131 |
-
|
|
|
|
|
|
|
| 132 |
break;
|
| 133 |
|
| 134 |
case "response.incomplete":
|
|
|
|
| 92 |
break;
|
| 93 |
|
| 94 |
case "response.output_item.added":
|
| 95 |
+
if (typed.item.type === "function_call" && typed.item.call_id && typed.item.name) {
|
| 96 |
// Register item_id → call_id mapping
|
| 97 |
itemIdToCallInfo.set(typed.item.id, {
|
| 98 |
callId: typed.item.call_id,
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
case "response.output_item.done":
|
| 131 |
+
case "response.content_part.added":
|
| 132 |
+
case "response.content_part.done":
|
| 133 |
+
// Lifecycle markers — no data extraction needed
|
| 134 |
break;
|
| 135 |
|
| 136 |
case "response.incomplete":
|
src/types/__tests__/codex-events.test.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from "vitest";
|
| 2 |
+
import { parseCodexEvent } from "../codex-events.js";
|
| 3 |
+
import type { CodexSSEEvent } from "../../proxy/codex-api.js";
|
| 4 |
+
|
| 5 |
+
function makeRaw(event: string, data: unknown): CodexSSEEvent {
|
| 6 |
+
return { event, data };
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
describe("parseCodexEvent — message output_item.added", () => {
|
| 10 |
+
it("parses output_item.added with item.type=message as known event", () => {
|
| 11 |
+
const raw = makeRaw("response.output_item.added", {
|
| 12 |
+
type: "response.output_item.added",
|
| 13 |
+
item: {
|
| 14 |
+
id: "msg_abc",
|
| 15 |
+
type: "message",
|
| 16 |
+
status: "in_progress",
|
| 17 |
+
content: [],
|
| 18 |
+
role: "assistant",
|
| 19 |
+
},
|
| 20 |
+
output_index: 0,
|
| 21 |
+
sequence_number: 2,
|
| 22 |
+
});
|
| 23 |
+
const result = parseCodexEvent(raw);
|
| 24 |
+
expect(result.type).toBe("response.output_item.added");
|
| 25 |
+
expect(result.type).not.toBe("unknown");
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it("still parses output_item.added with item.type=function_call", () => {
|
| 29 |
+
const raw = makeRaw("response.output_item.added", {
|
| 30 |
+
type: "response.output_item.added",
|
| 31 |
+
item: {
|
| 32 |
+
id: "fc_1",
|
| 33 |
+
type: "function_call",
|
| 34 |
+
call_id: "call_1",
|
| 35 |
+
name: "get_weather",
|
| 36 |
+
},
|
| 37 |
+
output_index: 0,
|
| 38 |
+
});
|
| 39 |
+
const result = parseCodexEvent(raw);
|
| 40 |
+
expect(result.type).toBe("response.output_item.added");
|
| 41 |
+
if (result.type === "response.output_item.added") {
|
| 42 |
+
expect(result.item.type).toBe("function_call");
|
| 43 |
+
expect(result.item.call_id).toBe("call_1");
|
| 44 |
+
expect(result.item.name).toBe("get_weather");
|
| 45 |
+
}
|
| 46 |
+
});
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
describe("parseCodexEvent — content_part events", () => {
|
| 50 |
+
it("parses response.content_part.added as known event", () => {
|
| 51 |
+
const raw = makeRaw("response.content_part.added", {
|
| 52 |
+
type: "response.content_part.added",
|
| 53 |
+
content_index: 0,
|
| 54 |
+
item_id: "msg_abc",
|
| 55 |
+
output_index: 0,
|
| 56 |
+
part: { type: "output_text", annotations: [], logprobs: [], text: "" },
|
| 57 |
+
sequence_number: 3,
|
| 58 |
+
});
|
| 59 |
+
const result = parseCodexEvent(raw);
|
| 60 |
+
expect(result.type).toBe("response.content_part.added");
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it("parses response.content_part.done as known event", () => {
|
| 64 |
+
const raw = makeRaw("response.content_part.done", {
|
| 65 |
+
type: "response.content_part.done",
|
| 66 |
+
content_index: 0,
|
| 67 |
+
item_id: "msg_abc",
|
| 68 |
+
output_index: 0,
|
| 69 |
+
part: { type: "output_text", annotations: [], logprobs: [], text: "Hello world" },
|
| 70 |
+
sequence_number: 4,
|
| 71 |
+
});
|
| 72 |
+
const result = parseCodexEvent(raw);
|
| 73 |
+
expect(result.type).toBe("response.content_part.done");
|
| 74 |
+
});
|
| 75 |
+
});
|
src/types/codex-events.ts
CHANGED
|
@@ -63,13 +63,29 @@ export interface CodexOutputItemAddedEvent {
|
|
| 63 |
type: "response.output_item.added";
|
| 64 |
outputIndex: number;
|
| 65 |
item: {
|
| 66 |
-
type:
|
| 67 |
id: string;
|
| 68 |
-
call_id: string;
|
| 69 |
-
name: string;
|
| 70 |
};
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
export interface CodexFunctionCallArgsDeltaEvent {
|
| 74 |
type: "response.function_call_arguments.delta";
|
| 75 |
delta: string;
|
|
@@ -133,6 +149,8 @@ export type TypedCodexEvent =
|
|
| 133 |
| CodexCompletedEvent
|
| 134 |
| CodexOutputItemAddedEvent
|
| 135 |
| CodexOutputItemDoneEvent
|
|
|
|
|
|
|
| 136 |
| CodexIncompleteEvent
|
| 137 |
| CodexQueuedEvent
|
| 138 |
| CodexFunctionCallArgsDeltaEvent
|
|
@@ -223,22 +241,30 @@ export function parseCodexEvent(evt: CodexSSEEvent): TypedCodexEvent {
|
|
| 223 |
: { type: "unknown", raw: data };
|
| 224 |
}
|
| 225 |
case "response.output_item.added": {
|
| 226 |
-
if (
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
typeof data.item.
|
| 232 |
-
|
| 233 |
return {
|
| 234 |
type: "response.output_item.added",
|
| 235 |
outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
|
| 236 |
-
item
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
};
|
| 243 |
}
|
| 244 |
return { type: "unknown", raw: data };
|
|
|
|
| 63 |
type: "response.output_item.added";
|
| 64 |
outputIndex: number;
|
| 65 |
item: {
|
| 66 |
+
type: string;
|
| 67 |
id: string;
|
| 68 |
+
call_id?: string;
|
| 69 |
+
name?: string;
|
| 70 |
};
|
| 71 |
}
|
| 72 |
|
| 73 |
+
export interface CodexContentPartAddedEvent {
|
| 74 |
+
type: "response.content_part.added";
|
| 75 |
+
contentIndex: number;
|
| 76 |
+
outputIndex: number;
|
| 77 |
+
itemId: string;
|
| 78 |
+
part: Record<string, unknown>;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export interface CodexContentPartDoneEvent {
|
| 82 |
+
type: "response.content_part.done";
|
| 83 |
+
contentIndex: number;
|
| 84 |
+
outputIndex: number;
|
| 85 |
+
itemId: string;
|
| 86 |
+
part: Record<string, unknown>;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
export interface CodexFunctionCallArgsDeltaEvent {
|
| 90 |
type: "response.function_call_arguments.delta";
|
| 91 |
delta: string;
|
|
|
|
| 149 |
| CodexCompletedEvent
|
| 150 |
| CodexOutputItemAddedEvent
|
| 151 |
| CodexOutputItemDoneEvent
|
| 152 |
+
| CodexContentPartAddedEvent
|
| 153 |
+
| CodexContentPartDoneEvent
|
| 154 |
| CodexIncompleteEvent
|
| 155 |
| CodexQueuedEvent
|
| 156 |
| CodexFunctionCallArgsDeltaEvent
|
|
|
|
| 241 |
: { type: "unknown", raw: data };
|
| 242 |
}
|
| 243 |
case "response.output_item.added": {
|
| 244 |
+
if (isRecord(data) && isRecord(data.item) && typeof data.item.type === "string") {
|
| 245 |
+
const item: CodexOutputItemAddedEvent["item"] = {
|
| 246 |
+
type: data.item.type,
|
| 247 |
+
id: typeof data.item.id === "string" ? data.item.id : "",
|
| 248 |
+
};
|
| 249 |
+
if (typeof data.item.call_id === "string") item.call_id = data.item.call_id;
|
| 250 |
+
if (typeof data.item.name === "string") item.name = data.item.name;
|
| 251 |
return {
|
| 252 |
type: "response.output_item.added",
|
| 253 |
outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
|
| 254 |
+
item,
|
| 255 |
+
};
|
| 256 |
+
}
|
| 257 |
+
return { type: "unknown", raw: data };
|
| 258 |
+
}
|
| 259 |
+
case "response.content_part.added":
|
| 260 |
+
case "response.content_part.done": {
|
| 261 |
+
if (isRecord(data) && isRecord(data.part)) {
|
| 262 |
+
return {
|
| 263 |
+
type: evt.event as "response.content_part.added" | "response.content_part.done",
|
| 264 |
+
contentIndex: typeof data.content_index === "number" ? data.content_index : 0,
|
| 265 |
+
outputIndex: typeof data.output_index === "number" ? data.output_index : 0,
|
| 266 |
+
itemId: typeof data.item_id === "string" ? data.item_id : "",
|
| 267 |
+
part: data.part as Record<string, unknown>,
|
| 268 |
};
|
| 269 |
}
|
| 270 |
return { type: "unknown", raw: data };
|