pi-openai-codex-compat 0.0.1-alpha.0

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,78 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import { isObject, isResponsesItem, type ResponsesItem } from "./codex-protocol.ts";
3
+
4
+ export const NATIVE_RESPONSE_ENTRY_TYPE = "openai-codex-compat-native-response";
5
+ export const NATIVE_RESPONSE_FORMAT_VERSION = 1;
6
+
7
+ export type NativeResponseData = {
8
+ kind: typeof NATIVE_RESPONSE_ENTRY_TYPE;
9
+ version: typeof NATIVE_RESPONSE_FORMAT_VERSION;
10
+ modelId: string;
11
+ responseId: string;
12
+ items: ResponsesItem[];
13
+ };
14
+
15
+ export function nativeResponseData(
16
+ modelId: string,
17
+ responseId: string,
18
+ items: readonly ResponsesItem[],
19
+ ): NativeResponseData {
20
+ return {
21
+ kind: NATIVE_RESPONSE_ENTRY_TYPE,
22
+ version: NATIVE_RESPONSE_FORMAT_VERSION,
23
+ modelId,
24
+ responseId,
25
+ items: items.map((item) => structuredClone(item)),
26
+ };
27
+ }
28
+
29
+ export function parseNativeResponse(value: unknown): NativeResponseData | undefined {
30
+ if (!isObject(value)) return undefined;
31
+ if (
32
+ value.kind !== NATIVE_RESPONSE_ENTRY_TYPE ||
33
+ value.version !== NATIVE_RESPONSE_FORMAT_VERSION ||
34
+ typeof value.modelId !== "string" ||
35
+ typeof value["responseId"] !== "string" ||
36
+ !Array.isArray(value["items"])
37
+ ) {
38
+ return undefined;
39
+ }
40
+
41
+ const items: ResponsesItem[] = [];
42
+ for (const item of value["items"]) {
43
+ if (!isResponsesItem(item)) return undefined;
44
+ items.push(structuredClone(item));
45
+ }
46
+ if (items.length === 0) return undefined;
47
+
48
+ return {
49
+ kind: NATIVE_RESPONSE_ENTRY_TYPE,
50
+ version: NATIVE_RESPONSE_FORMAT_VERSION,
51
+ modelId: value.modelId,
52
+ responseId: value["responseId"],
53
+ items,
54
+ };
55
+ }
56
+
57
+ /** Load native assistant output overrides from the active Pi branch. */
58
+ export function nativeResponseOverrides(
59
+ branch: readonly SessionEntry[],
60
+ modelId: string,
61
+ ): ReadonlyMap<string, ResponsesItem[]> {
62
+ const overrides = new Map<string, ResponsesItem[]>();
63
+
64
+ for (const entry of branch) {
65
+ if (entry.type !== "custom" || entry.customType !== NATIVE_RESPONSE_ENTRY_TYPE) continue;
66
+ const parsed = parseNativeResponse(entry.data);
67
+ if (!parsed) {
68
+ throw new Error(`Codex native response entry ${entry.id} is corrupt.`);
69
+ }
70
+ if (parsed.modelId !== modelId) continue;
71
+ overrides.set(
72
+ parsed.responseId,
73
+ parsed.items.map((item) => structuredClone(item)),
74
+ );
75
+ }
76
+
77
+ return overrides;
78
+ }
@@ -0,0 +1,198 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionContext,
5
+ SessionBeforeCompactEvent,
6
+ SessionEntry,
7
+ ToolInfo,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { Model, OpenAICodexResponsesOptions } from "@earendil-works/pi-ai";
10
+ import { addRemoteCompactionFeature, type JsonRecord } from "./codex-protocol.ts";
11
+ import {
12
+ activeResponsesTools,
13
+ providerHistory,
14
+ searchCheckpoint,
15
+ type GrammarToolInputProperties,
16
+ } from "./compaction-checkpoint.ts";
17
+ import { loadConfig, type CodexCompatConfig } from "./config.ts";
18
+ import type { CodexProviderRuntime } from "./codex-provider.ts";
19
+ import { APPLY_PATCH_INPUT_PROPERTY, APPLY_PATCH_TOOL_NAME } from "./apply-patch.ts";
20
+
21
+ const CODEX_PROVIDER = "openai-codex";
22
+ const CODEX_API = "openai-codex-responses";
23
+
24
+ type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
25
+
26
+ function selectedCodexModel(model: Model<any> | undefined): model is Model<any> {
27
+ return Boolean(model && model.provider === CODEX_PROVIDER && model.api === CODEX_API);
28
+ }
29
+
30
+ function resolveFileConfig(ctx: ExtensionContext): CodexCompatConfig {
31
+ return loadConfig(ctx.cwd, ctx.isProjectTrusted());
32
+ }
33
+
34
+ function appendFeatureHeader(headers: Record<string, string | null>): void {
35
+ const key = Object.keys(headers).find(
36
+ (header) => header.toLowerCase() === "x-codex-beta-features",
37
+ );
38
+ if (key) headers[key] = addRemoteCompactionFeature(headers[key]);
39
+ else headers["x-codex-beta-features"] = addRemoteCompactionFeature(undefined);
40
+ }
41
+
42
+ function featureHeaders(headers: Record<string, string> | undefined): Record<string, string> {
43
+ const result: Record<string, string | null> = { ...headers };
44
+ appendFeatureHeader(result);
45
+ return Object.fromEntries(
46
+ Object.entries(result).filter((entry): entry is [string, string] => entry[1] !== null),
47
+ );
48
+ }
49
+
50
+ function markerSummary(): string {
51
+ return `OpenAI Codex remote compaction checkpoint (${randomUUID()}).`;
52
+ }
53
+
54
+ function instructionsForCompaction(systemPrompt: string, customInstructions?: string): string {
55
+ const custom = customInstructions?.trim();
56
+ return custom
57
+ ? `${systemPrompt}\n\nAdditional guidance for this compaction:\n${custom}`
58
+ : systemPrompt;
59
+ }
60
+
61
+ function toolInputProperty(tool: ToolInfo | undefined): string | undefined {
62
+ if (tool?.name === APPLY_PATCH_TOOL_NAME) return APPLY_PATCH_INPUT_PROPERTY;
63
+ if (!tool || typeof tool.parameters !== "object" || tool.parameters === null) return undefined;
64
+ const schema = tool.parameters as {
65
+ required?: unknown;
66
+ properties?: Record<string, { type?: unknown } | undefined>;
67
+ };
68
+ const required = Array.isArray(schema.required)
69
+ ? schema.required.filter((name): name is string => typeof name === "string")
70
+ : [];
71
+ if (required.length !== 1 || !schema.properties) return undefined;
72
+ return schema.properties[required[0]!]?.type === "string" ? required[0] : undefined;
73
+ }
74
+
75
+ function requestGrammarToolInputProperties(
76
+ payload: JsonRecord,
77
+ tools: readonly ToolInfo[],
78
+ ): GrammarToolInputProperties {
79
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
80
+ const properties = new Map<string, string>();
81
+ if (!Array.isArray(payload.tools)) return properties;
82
+
83
+ for (const declaration of payload.tools) {
84
+ if (typeof declaration !== "object" || declaration === null || Array.isArray(declaration)) {
85
+ continue;
86
+ }
87
+ const tool = declaration as JsonRecord;
88
+ if (tool.type !== "custom" || typeof tool.name !== "string") continue;
89
+ const property = toolInputProperty(byName.get(tool.name));
90
+ if (property) properties.set(tool.name, property);
91
+ }
92
+ return properties;
93
+ }
94
+
95
+ function fallbackGrammarToolInputProperties(
96
+ activeNames: readonly string[],
97
+ model: Model<any>,
98
+ ): GrammarToolInputProperties {
99
+ const compat = model.compat as { supportsOpenAIGrammarTools?: boolean } | undefined;
100
+ return activeNames.includes(APPLY_PATCH_TOOL_NAME) && compat?.supportsOpenAIGrammarTools
101
+ ? new Map([[APPLY_PATCH_TOOL_NAME, APPLY_PATCH_INPUT_PROPERTY]])
102
+ : new Map();
103
+ }
104
+
105
+ function explain(error: unknown): string {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+
109
+ export default function registerRemoteCompaction(
110
+ pi: ExtensionAPI,
111
+ runtime: CodexProviderRuntime,
112
+ resolveConfig: ConfigResolver = resolveFileConfig,
113
+ ): void {
114
+ pi.on("session_start", (_event, ctx) => runtime.captureScope(ctx));
115
+ pi.on("session_shutdown", (_event, ctx) => {
116
+ runtime.clearSession(ctx.sessionManager.getSessionId());
117
+ });
118
+
119
+ pi.on("context", (event, ctx) => {
120
+ runtime.captureScope(ctx);
121
+ const checkpoint = searchCheckpoint(ctx.sessionManager.getBranch() as SessionEntry[]);
122
+ if (checkpoint.kind === "absent") return undefined;
123
+ return {
124
+ messages: event.messages.filter((message) => message.role !== "compactionSummary"),
125
+ };
126
+ });
127
+
128
+ pi.on("before_provider_headers", (event, ctx) => {
129
+ if (selectedCodexModel(ctx.model)) appendFeatureHeader(event.headers);
130
+ });
131
+
132
+ pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => {
133
+ if (!selectedCodexModel(ctx.model)) return undefined;
134
+ if (event.signal.aborted) return { cancel: true };
135
+ runtime.captureScope(ctx);
136
+
137
+ try {
138
+ const authentication = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
139
+ if (!authentication.ok) throw new Error(authentication.error);
140
+ if (!authentication.apiKey) throw new Error("OpenAI Codex authentication is unavailable.");
141
+
142
+ const sessionId = ctx.sessionManager.getSessionId();
143
+ const allTools = pi.getAllTools();
144
+ const cached = runtime.latestTemplate(sessionId);
145
+ const matching = cached?.modelId === ctx.model.id ? cached : undefined;
146
+ const config = resolveConfig(ctx);
147
+ const grammarToolInputProperties =
148
+ matching?.grammarToolInputProperties ??
149
+ fallbackGrammarToolInputProperties(pi.getActiveTools(), ctx.model);
150
+ const history = providerHistory({
151
+ branch: event.branchEntries as SessionEntry[],
152
+ wireModel: ctx.model,
153
+ allTools,
154
+ grammarToolInputProperties,
155
+ imageDetail: config.imageDetail,
156
+ dropLatestFailedAssistant: event.reason === "overflow" && event.willRetry,
157
+ });
158
+ const template =
159
+ matching?.payload ??
160
+ ({
161
+ tools: activeResponsesTools(allTools, pi.getActiveTools(), grammarToolInputProperties),
162
+ } satisfies JsonRecord);
163
+ const requestOptions: OpenAICodexResponsesOptions = {
164
+ ...matching?.requestOptions,
165
+ apiKey: authentication.apiKey,
166
+ headers: featureHeaders(authentication.headers),
167
+ sessionId,
168
+ signal: event.signal,
169
+ };
170
+ const compacted = await runtime.compact({
171
+ model: ctx.model,
172
+ requestOptions,
173
+ history,
174
+ instructions: instructionsForCompaction(ctx.getSystemPrompt(), event.customInstructions),
175
+ grammarToolInputProperties:
176
+ matching?.grammarToolInputProperties ??
177
+ requestGrammarToolInputProperties(template, allTools),
178
+ template,
179
+ priority: config.fastMode,
180
+ });
181
+
182
+ return {
183
+ compaction: {
184
+ summary: markerSummary(),
185
+ firstKeptEntryId: event.preparation.firstKeptEntryId,
186
+ tokensBefore: event.preparation.tokensBefore,
187
+ ...(compacted.usage ? { usage: compacted.usage } : {}),
188
+ details: compacted.checkpoint,
189
+ },
190
+ };
191
+ } catch (error) {
192
+ if (!event.signal.aborted && ctx.hasUI) {
193
+ ctx.ui.notify(`OpenAI Codex native compaction failed: ${explain(error)}`, "error");
194
+ }
195
+ return { cancel: true };
196
+ }
197
+ });
198
+ }
@@ -0,0 +1,121 @@
1
+ import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig, type CodexCompatConfig, type WebSearchMode } from "./config.ts";
4
+ import { isObject, type JsonRecord } from "./codex-protocol.ts";
5
+ import { splitNamespacedToolName, WEB_RUN_TOOL_NAME } from "./namespaced-tools.ts";
6
+
7
+ const CODEX_PROVIDER = "openai-codex";
8
+ const CODEX_API = "openai-codex-responses";
9
+
10
+ export function isCodexModel(model: Model<any> | undefined): model is Model<any> {
11
+ return Boolean(model && model.provider === CODEX_PROVIDER && model.api === CODEX_API);
12
+ }
13
+
14
+ export function supportsReasoningMode(modelId: string): boolean {
15
+ return /^gpt-5\.6(?:-|$)/.test(modelId);
16
+ }
17
+
18
+ function isWebSearchTool(value: unknown): boolean {
19
+ return isObject(value) && value.type === "web_search";
20
+ }
21
+
22
+ function isWebRunNamespace(value: unknown): boolean {
23
+ if (!isObject(value) || value.type !== "namespace") return false;
24
+ const webRun = splitNamespacedToolName(WEB_RUN_TOOL_NAME)!;
25
+ return (
26
+ value.name === webRun.namespace &&
27
+ Array.isArray(value.tools) &&
28
+ value.tools.some(
29
+ (tool) => isObject(tool) && tool.type === "function" && tool.name === webRun.name,
30
+ )
31
+ );
32
+ }
33
+
34
+ function webSearchTool(mode: Exclude<WebSearchMode, "disabled">, images: boolean): JsonRecord {
35
+ return {
36
+ type: "web_search",
37
+ external_web_access: mode !== "cached",
38
+ ...(mode === "indexed" ? { indexed_web_access: true } : {}),
39
+ ...(images ? { search_content_types: ["text", "image"] } : {}),
40
+ };
41
+ }
42
+
43
+ export function applyCodexRequestOptions(
44
+ payload: JsonRecord,
45
+ config: CodexCompatConfig,
46
+ options: { modelId: string; supportsImageSearch: boolean },
47
+ ): JsonRecord {
48
+ const result = structuredClone(payload);
49
+
50
+ if (config.fastMode) result.service_tier = "priority";
51
+
52
+ const text = isObject(result.text) ? result.text : {};
53
+ result.text = { ...text, verbosity: config.textVerbosity };
54
+
55
+ const reasoning = result["reasoning"];
56
+ if (isObject(reasoning)) {
57
+ const updatedReasoning = { ...reasoning };
58
+ if (config.reasoningSummary === "off") {
59
+ Reflect.deleteProperty(updatedReasoning, "summary");
60
+ } else {
61
+ updatedReasoning["summary"] = config.reasoningSummary;
62
+ }
63
+ if (supportsReasoningMode(options.modelId)) {
64
+ updatedReasoning["mode"] = config.reasoningMode;
65
+ } else {
66
+ Reflect.deleteProperty(updatedReasoning, "mode");
67
+ }
68
+ result["reasoning"] = updatedReasoning;
69
+ }
70
+
71
+ const tools = Array.isArray(result.tools) ? result.tools : [];
72
+ const toolsWithoutSearch = tools.filter((tool) => !isWebSearchTool(tool));
73
+ if (tools.some(isWebRunNamespace) || config.webSearch === "disabled") {
74
+ if (Array.isArray(result.tools)) result.tools = toolsWithoutSearch;
75
+ } else {
76
+ result.tools = [
77
+ ...toolsWithoutSearch,
78
+ webSearchTool(config.webSearch, options.supportsImageSearch),
79
+ ];
80
+ }
81
+
82
+ return result;
83
+ }
84
+
85
+ /** Recompute cost from canonical rates so payload-only priority mode cannot be undercounted. */
86
+ export function applyPriorityPricing(
87
+ message: AssistantMessage,
88
+ model: Model<any>,
89
+ ): AssistantMessage {
90
+ const usage = structuredClone(message.usage);
91
+ calculateCost(model, usage);
92
+ const multiplier = model.id === "gpt-5.5" ? 2.5 : 2;
93
+ usage.cost.input *= multiplier;
94
+ usage.cost.output *= multiplier;
95
+ usage.cost.cacheRead *= multiplier;
96
+ usage.cost.cacheWrite *= multiplier;
97
+ usage.cost.total =
98
+ usage.cost.input + usage.cost.output + usage.cost.cacheRead + usage.cost.cacheWrite;
99
+ return { ...message, usage };
100
+ }
101
+
102
+ type ConfigResolver = (ctx: ExtensionContext) => CodexCompatConfig;
103
+
104
+ function resolveFileConfig(ctx: ExtensionContext): CodexCompatConfig {
105
+ return loadConfig(ctx.cwd, ctx.isProjectTrusted());
106
+ }
107
+
108
+ export default function registerCodexRequestOptions(
109
+ pi: ExtensionAPI,
110
+ resolveConfig: ConfigResolver = resolveFileConfig,
111
+ ): void {
112
+ pi.on("before_provider_request", (event, ctx) => {
113
+ if (!isCodexModel(ctx.model) || !isObject(event.payload)) return undefined;
114
+
115
+ const config = resolveConfig(ctx);
116
+ return applyCodexRequestOptions(event.payload, config, {
117
+ modelId: ctx.model!.id,
118
+ supportsImageSearch: ctx.model!.input.includes("image"),
119
+ });
120
+ });
121
+ }
@@ -0,0 +1,33 @@
1
+ import { isObject, type JsonRecord } from "./codex-protocol.ts";
2
+
3
+ export function stableResponsesJson(value: unknown): string {
4
+ const normalize = (current: unknown): unknown => {
5
+ if (Array.isArray(current)) return current.map(normalize);
6
+ if (!isObject(current)) return current;
7
+ return Object.fromEntries(
8
+ Object.entries(current)
9
+ .filter(([, entry]) => entry !== undefined)
10
+ .sort(([left], [right]) => left.localeCompare(right))
11
+ .map(([key, entry]) => [key, normalize(entry)]),
12
+ );
13
+ };
14
+ return JSON.stringify(normalize(value));
15
+ }
16
+
17
+ /** Normalize completed provider output into the item shape replayed on the next request. */
18
+ export function normalizeReplayItem(item: JsonRecord): JsonRecord {
19
+ const normalized = structuredClone(item);
20
+ if (normalized.type === "message") {
21
+ normalized["status"] = "completed";
22
+ } else if (normalized.type === "function_call" || normalized.type === "custom_tool_call") {
23
+ delete normalized["status"];
24
+ }
25
+ return normalized;
26
+ }
27
+
28
+ export function replayItemsEqual(
29
+ left: readonly JsonRecord[] | undefined,
30
+ right: readonly JsonRecord[] | undefined,
31
+ ): boolean {
32
+ return stableResponsesJson(left ?? []) === stableResponsesJson(right ?? []);
33
+ }