@bitkyc08/opencodex 0.1.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 (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +164 -0
  3. package/README.md +165 -0
  4. package/README.zh-CN.md +162 -0
  5. package/gui/README.md +73 -0
  6. package/gui/dist/assets/index-C1wlp1SM.css +1 -0
  7. package/gui/dist/assets/index-C9y3iMF1.js +9 -0
  8. package/gui/dist/favicon.png +0 -0
  9. package/gui/dist/icons.svg +24 -0
  10. package/gui/dist/index.html +15 -0
  11. package/gui/dist/logo.png +0 -0
  12. package/package.json +56 -0
  13. package/scripts/postinstall.mjs +57 -0
  14. package/src/adapters/anthropic.ts +306 -0
  15. package/src/adapters/azure.ts +31 -0
  16. package/src/adapters/base.ts +20 -0
  17. package/src/adapters/google.ts +195 -0
  18. package/src/adapters/image.ts +23 -0
  19. package/src/adapters/openai-chat.ts +265 -0
  20. package/src/adapters/openai-responses.ts +43 -0
  21. package/src/bridge.ts +296 -0
  22. package/src/cli.ts +183 -0
  23. package/src/codex-catalog.ts +318 -0
  24. package/src/codex-inject.ts +186 -0
  25. package/src/config.ts +108 -0
  26. package/src/index.ts +20 -0
  27. package/src/init.ts +163 -0
  28. package/src/model-cache.ts +42 -0
  29. package/src/oauth/anthropic.ts +151 -0
  30. package/src/oauth/callback-server.ts +249 -0
  31. package/src/oauth/index.ts +235 -0
  32. package/src/oauth/key-providers.ts +126 -0
  33. package/src/oauth/kimi.ts +160 -0
  34. package/src/oauth/local-token-detect.ts +71 -0
  35. package/src/oauth/login-cli.ts +90 -0
  36. package/src/oauth/pkce.ts +15 -0
  37. package/src/oauth/store.ts +39 -0
  38. package/src/oauth/types.ts +22 -0
  39. package/src/oauth/xai.ts +234 -0
  40. package/src/responses/parser.ts +402 -0
  41. package/src/responses/schema.ts +145 -0
  42. package/src/router.ts +86 -0
  43. package/src/server.ts +522 -0
  44. package/src/service.ts +130 -0
  45. package/src/star-prompt.ts +50 -0
  46. package/src/types.ts +228 -0
  47. package/src/update.ts +64 -0
  48. package/src/vision/describe.ts +98 -0
  49. package/src/vision/index.ts +141 -0
  50. package/src/web-search/executor.ts +75 -0
  51. package/src/web-search/format-result.ts +45 -0
  52. package/src/web-search/index.ts +62 -0
  53. package/src/web-search/loop.ts +188 -0
  54. package/src/web-search/parse.ts +128 -0
  55. package/src/web-search/synthetic-tool.ts +42 -0
@@ -0,0 +1,23 @@
1
+ import type { OcxContentPart } from "../types";
2
+
3
+ /**
4
+ * Parse a `data:<media-type>;base64,<data>` URL into its parts. Codex sends inline images as base64
5
+ * data URLs (`into_data_url()`), which Anthropic/Google need split into media_type + raw base64.
6
+ * Returns null for non-data URLs (e.g. a remote https image), which callers pass through differently.
7
+ */
8
+ export function parseDataUrl(url: string): { mediaType: string; base64: string } | null {
9
+ const m = url.match(/^data:([^;,]+);base64,(.*)$/s);
10
+ if (!m) return null;
11
+ return { mediaType: m[1], base64: m[2] };
12
+ }
13
+
14
+ /**
15
+ * Flatten tool-result content to a string for chat/Gemini tool messages (which are text-only). After
16
+ * the vision sidecar runs, images are already text; this is the fallback for an undescribed image
17
+ * (vision model via view_image): a short marker, never the token-exploding image_url.
18
+ */
19
+ export function contentPartsToText(content: string | OcxContentPart[]): string {
20
+ if (typeof content === "string") return content;
21
+ const text = content.map(p => (p.type === "text" ? p.text : "[image]")).join("");
22
+ return text || "[image]";
23
+ }
@@ -0,0 +1,265 @@
1
+ import type { ProviderAdapter } from "./base";
2
+ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall } from "../types";
3
+ import { namespacedToolName } from "../types";
4
+ import { contentPartsToText } from "./image";
5
+
6
+ function messagesToChatFormat(parsed: OcxParsedRequest): unknown[] {
7
+ const out: unknown[] = [];
8
+ const { context, options } = parsed;
9
+
10
+ if (context.systemPrompt && context.systemPrompt.length > 0) {
11
+ // Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
12
+ // base_instructions is ignored at request time). Neutralize that one identity line
13
+ // so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI.
14
+ const sys = context.systemPrompt.join("\n\n").replace(
15
+ "You are Codex, a coding agent based on GPT-5.",
16
+ `You are a coding agent (underlying model: ${parsed.modelId}) running via the opencodex proxy. Do not claim to be GPT-5 or to be made by OpenAI.`,
17
+ );
18
+ out.push({ role: "system", content: sys });
19
+ }
20
+
21
+ for (const msg of context.messages) {
22
+ switch (msg.role) {
23
+ case "user":
24
+ case "developer": {
25
+ const role = msg.role === "developer" ? "system" : "user";
26
+ if (typeof msg.content === "string") {
27
+ out.push({ role, content: msg.content });
28
+ } else {
29
+ const parts = msg.content as OcxContentPart[];
30
+ if (!parts.some(p => p.type === "image")) {
31
+ out.push({ role, content: parts.map(p => (p as OcxTextContent).text).join("") });
32
+ } else {
33
+ // Vision: chat-completions content-parts array. Images are only valid on the user role,
34
+ // and the data URL goes straight into image_url.url (never the token-exploding text path).
35
+ const chatParts = parts.map(p => p.type === "image"
36
+ ? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
37
+ : { type: "text", text: (p as OcxTextContent).text });
38
+ out.push({ role: "user", content: chatParts });
39
+ }
40
+ }
41
+ break;
42
+ }
43
+ case "assistant": {
44
+ const aMsg = msg as OcxAssistantMessage;
45
+ const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
46
+ const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
47
+ const chatMsg: Record<string, unknown> = { role: "assistant" };
48
+ if (textParts.length > 0) {
49
+ chatMsg.content = textParts.map(p => p.text).join("");
50
+ }
51
+ if (toolCalls.length > 0) {
52
+ chatMsg.tool_calls = toolCalls.map(tc => ({
53
+ id: tc.id,
54
+ type: "function",
55
+ function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
56
+ }));
57
+ if (!chatMsg.content) chatMsg.content = null;
58
+ }
59
+ // Skip empty assistant messages (e.g. reasoning-only history items): chat APIs
60
+ // like DeepSeek reject an assistant message with neither content nor tool_calls.
61
+ if (chatMsg.content === undefined && chatMsg.tool_calls === undefined) break;
62
+ out.push(chatMsg);
63
+ break;
64
+ }
65
+ case "toolResult": {
66
+ out.push({
67
+ role: "tool",
68
+ tool_call_id: msg.toolCallId,
69
+ content: contentPartsToText(msg.content),
70
+ });
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ return out;
77
+ }
78
+
79
+ function toolsToChatFormat(parsed: OcxParsedRequest): unknown[] | undefined {
80
+ if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
81
+ return parsed.context.tools.map(t => ({
82
+ type: "function",
83
+ function: {
84
+ name: namespacedToolName(t.namespace, t.name),
85
+ description: t.description,
86
+ parameters: t.parameters,
87
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
88
+ },
89
+ }));
90
+ }
91
+
92
+ function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"]): unknown {
93
+ if (!tc) return undefined;
94
+ if (tc === "auto" || tc === "none" || tc === "required") return tc;
95
+ if ("name" in tc) return { type: "function", function: { name: tc.name } };
96
+ return undefined;
97
+ }
98
+
99
+ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
100
+ return {
101
+ name: "openai-chat",
102
+
103
+ buildRequest(parsed: OcxParsedRequest) {
104
+ const messages = messagesToChatFormat(parsed);
105
+ const tools = toolsToChatFormat(parsed);
106
+ const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice);
107
+
108
+ const body: Record<string, unknown> = {
109
+ model: parsed.modelId,
110
+ messages,
111
+ stream: parsed.stream,
112
+ };
113
+ if (tools) body.tools = tools;
114
+ if (toolChoice !== undefined) body.tool_choice = toolChoice;
115
+ if (parsed.options.maxOutputTokens !== undefined) body.max_tokens = parsed.options.maxOutputTokens;
116
+ if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature;
117
+ if (parsed.options.topP !== undefined) body.top_p = parsed.options.topP;
118
+ if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
119
+ // Some models reject a reasoning/thinking param entirely (e.g. xAI grok-build-0.1,
120
+ // grok-composer-2.5-fast). Drop reasoning_effort for them even if Codex selected an effort.
121
+ if (parsed.options.reasoning !== undefined && !provider.noReasoningModels?.includes(parsed.modelId)) {
122
+ // Forward the reasoning ladder (low/medium/high/xhigh) as-is. "minimal" (Codex-native lowest,
123
+ // widely unsupported downstream) maps to "low"; "max" isn't a real tier (no longer advertised)
124
+ // so it folds to "xhigh".
125
+ const r = parsed.options.reasoning;
126
+ body.reasoning_effort = r === "minimal" ? "low" : r === "max" ? "xhigh" : r;
127
+ }
128
+ if (parsed.options.presencePenalty !== undefined) body.presence_penalty = parsed.options.presencePenalty;
129
+ if (parsed.options.frequencyPenalty !== undefined) body.frequency_penalty = parsed.options.frequencyPenalty;
130
+
131
+ if (parsed.stream) {
132
+ body.stream_options = { include_usage: true };
133
+ }
134
+
135
+ const url = `${provider.baseUrl}/chat/completions`;
136
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
137
+ if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
138
+ if (provider.headers) Object.assign(headers, provider.headers);
139
+
140
+ return { url, method: "POST", headers, body: JSON.stringify(body) };
141
+ },
142
+
143
+ async *parseStream(response: Response): AsyncGenerator<AdapterEvent> {
144
+ if (!response.body) {
145
+ yield { type: "error", message: "No response body" };
146
+ return;
147
+ }
148
+
149
+ const reader = response.body.getReader();
150
+ const decoder = new TextDecoder();
151
+ let buffer = "";
152
+ let currentToolCallId = "";
153
+ let currentToolCallName = "";
154
+ let pendingUsage: { inputTokens: number; outputTokens: number } | undefined;
155
+
156
+ try {
157
+ while (true) {
158
+ const { done, value } = await reader.read();
159
+ if (done) break;
160
+ buffer += decoder.decode(value, { stream: true });
161
+
162
+ const lines = buffer.split("\n");
163
+ buffer = lines.pop() ?? "";
164
+
165
+ for (const line of lines) {
166
+ if (!line.startsWith("data: ")) continue;
167
+ const payload = line.slice(6).trim();
168
+ if (payload === "[DONE]") {
169
+ if (currentToolCallId) {
170
+ yield { type: "tool_call_end" };
171
+ currentToolCallId = "";
172
+ }
173
+ yield { type: "done", usage: pendingUsage };
174
+ return;
175
+ }
176
+
177
+ let chunk: Record<string, unknown>;
178
+ try {
179
+ chunk = JSON.parse(payload) as Record<string, unknown>;
180
+ } catch {
181
+ continue;
182
+ }
183
+
184
+ if (chunk.usage) {
185
+ const u = chunk.usage as Record<string, number>;
186
+ pendingUsage = {
187
+ inputTokens: u.prompt_tokens ?? 0,
188
+ outputTokens: u.completion_tokens ?? 0,
189
+ };
190
+ continue;
191
+ }
192
+
193
+ const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
194
+ if (!choices || choices.length === 0) continue;
195
+ const delta = choices[0].delta;
196
+ if (!delta) continue;
197
+
198
+ if (typeof delta.content === "string" && delta.content.length > 0) {
199
+ yield { type: "text_delta", text: delta.content };
200
+ }
201
+
202
+ if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
203
+ yield { type: "thinking_delta", thinking: delta.reasoning_content };
204
+ }
205
+
206
+ const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
207
+ if (toolCalls) {
208
+ for (const tc of toolCalls) {
209
+ if (tc.id && tc.id !== currentToolCallId) {
210
+ if (currentToolCallId) yield { type: "tool_call_end" };
211
+ currentToolCallId = tc.id;
212
+ currentToolCallName = tc.function?.name ?? "";
213
+ yield { type: "tool_call_start", id: tc.id, name: currentToolCallName };
214
+ }
215
+ if (tc.function?.arguments) {
216
+ yield { type: "tool_call_delta", arguments: tc.function.arguments };
217
+ }
218
+ }
219
+ }
220
+
221
+ if (choices[0].finish_reason === "tool_calls" && currentToolCallId) {
222
+ yield { type: "tool_call_end" };
223
+ currentToolCallId = "";
224
+ }
225
+ }
226
+ }
227
+
228
+ if (currentToolCallId) {
229
+ yield { type: "tool_call_end" };
230
+ }
231
+ yield { type: "done" };
232
+ } finally {
233
+ reader.releaseLock();
234
+ }
235
+ },
236
+
237
+ async parseResponse(response: Response): Promise<AdapterEvent[]> {
238
+ const json = await response.json() as Record<string, unknown>;
239
+ const events: AdapterEvent[] = [];
240
+ const choices = json.choices as { message?: Record<string, unknown> }[] | undefined;
241
+ if (choices && choices.length > 0) {
242
+ const msg = choices[0].message;
243
+ if (msg) {
244
+ if (typeof msg.content === "string") {
245
+ events.push({ type: "text_delta", text: msg.content });
246
+ }
247
+ const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
248
+ if (toolCalls) {
249
+ for (const tc of toolCalls) {
250
+ events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
251
+ events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
252
+ events.push({ type: "tool_call_end" });
253
+ }
254
+ }
255
+ }
256
+ }
257
+ const usage = json.usage as Record<string, number> | undefined;
258
+ events.push({
259
+ type: "done",
260
+ usage: usage ? { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0 } : undefined,
261
+ });
262
+ return events;
263
+ },
264
+ };
265
+ }
@@ -0,0 +1,43 @@
1
+ import type { IncomingMeta, ProviderAdapter } from "./base";
2
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types";
3
+
4
+ // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
5
+ // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
6
+ export const FORWARD_HEADERS = ["authorization", "chatgpt-account-id", "openai-beta", "originator", "session_id"];
7
+
8
+ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
9
+ return {
10
+ name: "openai-responses",
11
+ passthrough: true as const,
12
+
13
+ buildRequest(parsed: OcxParsedRequest, incoming?: IncomingMeta) {
14
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
15
+ let url: string;
16
+
17
+ if (provider.authMode === "forward") {
18
+ // OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1).
19
+ url = `${provider.baseUrl}/responses`;
20
+ if (provider.headers) Object.assign(headers, provider.headers); // static headers first…
21
+ for (const h of FORWARD_HEADERS) {
22
+ const v = incoming?.headers.get(h);
23
+ if (v) headers[h] = v; // …so forwarded auth always wins.
24
+ }
25
+ } else {
26
+ url = `${provider.baseUrl}/v1/responses`;
27
+ if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
28
+ if (provider.headers) Object.assign(headers, provider.headers);
29
+ }
30
+
31
+ return {
32
+ url,
33
+ method: "POST",
34
+ headers,
35
+ body: JSON.stringify(parsed._rawBody),
36
+ };
37
+ },
38
+
39
+ async *parseStream(): AsyncGenerator<AdapterEvent> {
40
+ yield { type: "error", message: "passthrough adapter should not parse stream" };
41
+ },
42
+ };
43
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,296 @@
1
+ import type { AdapterEvent, OcxUsage } from "./types";
2
+
3
+ function uuid(): string {
4
+ return crypto.randomUUID().replace(/-/g, "");
5
+ }
6
+
7
+ function sseEvent(name: string, data: Record<string, unknown>): string {
8
+ return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
9
+ }
10
+
11
+ interface OutputItem {
12
+ type: string;
13
+ id: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ export function bridgeToResponsesSSE(
18
+ events: AsyncIterable<AdapterEvent>,
19
+ modelId: string,
20
+ toolNsMap?: Map<string, { namespace: string; name: string }>,
21
+ freeformToolNames?: Set<string>,
22
+ toolSearchToolNames?: Set<string>,
23
+ ): ReadableStream<Uint8Array> {
24
+ // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
25
+ // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
26
+ const freeformInput = (args: string): string => {
27
+ try { const o = JSON.parse(args); if (o && typeof o.input === "string") return o.input; } catch { /* raw */ }
28
+ return args;
29
+ };
30
+ // tool_search_call carries arguments as a JSON object ({query, limit}); parse the model's arg string.
31
+ const parseArgsObj = (args: string): Record<string, unknown> => {
32
+ try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
33
+ };
34
+ const encoder = new TextEncoder();
35
+ const responseId = `resp_${uuid()}`;
36
+ let seq = 0;
37
+
38
+ return new ReadableStream<Uint8Array>({
39
+ async start(controller) {
40
+ const emit = (name: string, data: Record<string, unknown>) => {
41
+ controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
42
+ };
43
+ const emitDone = () => controller.enqueue(encoder.encode("data: [DONE]\n\n"));
44
+
45
+ const createdAt = Math.floor(Date.now() / 1000);
46
+ let outputIndex = 0;
47
+ const finishedItems: OutputItem[] = [];
48
+
49
+ const responseSnapshot = (status: string, output: OutputItem[]) => ({
50
+ id: responseId, object: "response", created_at: createdAt,
51
+ status, model: modelId, output, usage: null,
52
+ });
53
+
54
+ emit("response.created", { response: responseSnapshot("in_progress", []) });
55
+
56
+ let currentMsg: { itemId: string; outputIndex: number; text: string } | null = null;
57
+ let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
58
+ let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean } | null = null;
59
+
60
+ const closeCurrentMessage = () => {
61
+ if (!currentMsg) return;
62
+ // Finalize the text part (Responses protocol). Without these .done events Codex never
63
+ // commits the content part and renders the message as truncated / cut off.
64
+ emit("response.output_text.done", {
65
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0, text: currentMsg.text,
66
+ });
67
+ emit("response.content_part.done", {
68
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex, content_index: 0,
69
+ part: { type: "output_text", text: currentMsg.text, annotations: [] },
70
+ });
71
+ const item = {
72
+ type: "message", id: currentMsg.itemId, status: "completed", role: "assistant",
73
+ content: [{ type: "output_text", text: currentMsg.text, annotations: [] }],
74
+ };
75
+ emit("response.output_item.done", { output_index: currentMsg.outputIndex, item });
76
+ finishedItems.push(item as OutputItem);
77
+ outputIndex++;
78
+ currentMsg = null;
79
+ };
80
+
81
+ const closeCurrentReasoning = () => {
82
+ if (!currentReasoning) return;
83
+ emit("response.reasoning_summary_text.done", {
84
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex, summary_index: 0, text: currentReasoning.text,
85
+ });
86
+ emit("response.reasoning_summary_part.done", {
87
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex, summary_index: 0,
88
+ part: { type: "summary_text", text: currentReasoning.text },
89
+ });
90
+ const item = {
91
+ type: "reasoning", id: currentReasoning.itemId,
92
+ summary: [{ type: "summary_text", text: currentReasoning.text }],
93
+ };
94
+ emit("response.output_item.done", { output_index: currentReasoning.outputIndex, item });
95
+ finishedItems.push(item as OutputItem);
96
+ outputIndex++;
97
+ currentReasoning = null;
98
+ };
99
+
100
+ const closeCurrentToolCall = () => {
101
+ if (!currentToolCall) return;
102
+ // Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as
103
+ // "{}", never "" — Codex echoes the call back as a function_call next turn, and JSON.parse("")
104
+ // would 400 the whole session ("invalid JSON arguments"), poisoning all later turns.
105
+ const argsStr = currentToolCall.args || "{}";
106
+ // Finalize streamed function-call arguments so Codex commits the call (incl. MCP / computer_use).
107
+ if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
108
+ emit("response.function_call_arguments.done", {
109
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex, arguments: argsStr,
110
+ });
111
+ }
112
+ const item = currentToolCall.toolSearch
113
+ ? {
114
+ type: "tool_search_call", id: currentToolCall.itemId,
115
+ call_id: currentToolCall.callId, execution: "client",
116
+ arguments: parseArgsObj(currentToolCall.args), status: "completed",
117
+ }
118
+ : currentToolCall.freeform
119
+ ? {
120
+ type: "custom_tool_call", id: currentToolCall.itemId,
121
+ call_id: currentToolCall.callId, name: currentToolCall.name,
122
+ input: freeformInput(currentToolCall.args), status: "completed",
123
+ }
124
+ : {
125
+ type: "function_call", id: currentToolCall.itemId,
126
+ call_id: currentToolCall.callId, name: currentToolCall.name,
127
+ arguments: argsStr, status: "completed",
128
+ ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
129
+ };
130
+ emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
131
+ finishedItems.push(item as OutputItem);
132
+ outputIndex++;
133
+ currentToolCall = null;
134
+ };
135
+
136
+ try {
137
+ for await (const event of events) {
138
+ switch (event.type) {
139
+ case "text_delta": {
140
+ if (currentReasoning) closeCurrentReasoning();
141
+ if (currentToolCall) closeCurrentToolCall();
142
+ if (!currentMsg) {
143
+ const itemId = `msg_${uuid()}`;
144
+ const item = {
145
+ type: "message", id: itemId, status: "in_progress", role: "assistant",
146
+ content: [] as { type: string; text: string; annotations: never[] }[],
147
+ };
148
+ emit("response.output_item.added", { output_index: outputIndex, item });
149
+ emit("response.content_part.added", {
150
+ item_id: itemId, output_index: outputIndex, content_index: 0,
151
+ part: { type: "output_text", text: "", annotations: [] },
152
+ });
153
+ currentMsg = { itemId, outputIndex, text: "" };
154
+ }
155
+ currentMsg.text += event.text;
156
+ emit("response.output_text.delta", {
157
+ item_id: currentMsg.itemId, output_index: currentMsg.outputIndex,
158
+ content_index: 0, delta: event.text,
159
+ });
160
+ break;
161
+ }
162
+ case "thinking_delta": {
163
+ if (currentMsg) closeCurrentMessage();
164
+ if (currentToolCall) closeCurrentToolCall();
165
+ if (!currentReasoning) {
166
+ const itemId = `rs_${uuid()}`;
167
+ const item = { type: "reasoning", id: itemId, summary: [] as { type: string; text: string }[] };
168
+ emit("response.output_item.added", { output_index: outputIndex, item });
169
+ emit("response.reasoning_summary_part.added", {
170
+ item_id: itemId, output_index: outputIndex, summary_index: 0,
171
+ part: { type: "summary_text", text: "" },
172
+ });
173
+ currentReasoning = { itemId, outputIndex, text: "" };
174
+ }
175
+ currentReasoning.text += event.thinking;
176
+ emit("response.reasoning_summary_text.delta", {
177
+ item_id: currentReasoning.itemId, output_index: currentReasoning.outputIndex,
178
+ summary_index: 0, delta: event.thinking,
179
+ });
180
+ break;
181
+ }
182
+ case "tool_call_start": {
183
+ if (currentMsg) closeCurrentMessage();
184
+ if (currentReasoning) closeCurrentReasoning();
185
+ if (currentToolCall) closeCurrentToolCall();
186
+ const itemId = `fc_${uuid()}`;
187
+ const mapped = toolNsMap?.get(event.name);
188
+ const realName = mapped?.name ?? event.name;
189
+ const ns = mapped?.namespace;
190
+ const toolSearch = toolSearchToolNames?.has(realName) ?? false;
191
+ const freeform = !toolSearch && (freeformToolNames?.has(realName) ?? false);
192
+ const item = toolSearch
193
+ ? { type: "tool_search_call", id: itemId, call_id: event.id, execution: "client", arguments: {}, status: "in_progress" }
194
+ : freeform
195
+ ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
196
+ : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
197
+ emit("response.output_item.added", { output_index: outputIndex, item });
198
+ currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", namespace: ns, freeform, toolSearch };
199
+ break;
200
+ }
201
+ case "tool_call_delta": {
202
+ if (currentToolCall) {
203
+ currentToolCall.args += event.arguments;
204
+ if (!currentToolCall.freeform && !currentToolCall.toolSearch) {
205
+ emit("response.function_call_arguments.delta", {
206
+ item_id: currentToolCall.itemId, output_index: currentToolCall.outputIndex,
207
+ delta: event.arguments,
208
+ });
209
+ }
210
+ }
211
+ break;
212
+ }
213
+ case "tool_call_end": {
214
+ closeCurrentToolCall();
215
+ break;
216
+ }
217
+ case "done": {
218
+ if (currentMsg) closeCurrentMessage();
219
+ if (currentReasoning) closeCurrentReasoning();
220
+ if (currentToolCall) closeCurrentToolCall();
221
+ const usage = event.usage ? {
222
+ input_tokens: event.usage.inputTokens,
223
+ output_tokens: event.usage.outputTokens,
224
+ total_tokens: event.usage.inputTokens + event.usage.outputTokens,
225
+ } : { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
226
+ emit("response.completed", {
227
+ response: { ...responseSnapshot("completed", finishedItems), usage },
228
+ });
229
+ break;
230
+ }
231
+ case "error": {
232
+ if (currentMsg) closeCurrentMessage();
233
+ if (currentReasoning) closeCurrentReasoning();
234
+ if (currentToolCall) closeCurrentToolCall();
235
+ emit("response.failed", {
236
+ response: {
237
+ ...responseSnapshot("failed", finishedItems),
238
+ last_error: { type: "upstream_error", message: event.message },
239
+ },
240
+ });
241
+ break;
242
+ }
243
+ }
244
+ }
245
+ } catch (err) {
246
+ emit("response.failed", {
247
+ response: {
248
+ ...responseSnapshot("failed", finishedItems),
249
+ last_error: { type: "proxy_error", message: err instanceof Error ? err.message : String(err) },
250
+ },
251
+ });
252
+ }
253
+
254
+ emitDone();
255
+ controller.close();
256
+ },
257
+ });
258
+ }
259
+
260
+ export function buildResponseJSON(
261
+ events: AdapterEvent[],
262
+ modelId: string,
263
+ ): Record<string, unknown> {
264
+ const responseId = `resp_${uuid()}`;
265
+ const output: OutputItem[] = [];
266
+ let text = "";
267
+ let usage: OcxUsage | undefined;
268
+
269
+ for (const e of events) {
270
+ if (e.type === "text_delta") text += e.text;
271
+ if (e.type === "done") usage = e.usage;
272
+ }
273
+
274
+ if (text) {
275
+ output.push({
276
+ type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
277
+ content: [{ type: "output_text", text, annotations: [] }],
278
+ });
279
+ }
280
+
281
+ return {
282
+ id: responseId, object: "response",
283
+ created_at: Math.floor(Date.now() / 1000),
284
+ status: "completed", model: modelId, output,
285
+ usage: usage ? {
286
+ input_tokens: usage.inputTokens, output_tokens: usage.outputTokens,
287
+ total_tokens: usage.inputTokens + usage.outputTokens,
288
+ } : { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
289
+ };
290
+ }
291
+
292
+ export function formatErrorResponse(status: number, type: string, message: string): Response {
293
+ return new Response(JSON.stringify({ error: { message, type, code: null } }), {
294
+ status, headers: { "Content-Type": "application/json" },
295
+ });
296
+ }